#
tokens: 48738/50000 18/574 files (page 6/60)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 6 of 60. Use http://codebase.md/hangwin/mcp-chrome?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .gitattributes
├── .github
│   └── workflows
│       └── build-release.yml
├── .gitignore
├── .husky
│   ├── commit-msg
│   └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── .vscode
│   └── extensions.json
├── app
│   ├── chrome-extension
│   │   ├── _locales
│   │   │   ├── de
│   │   │   │   └── messages.json
│   │   │   ├── en
│   │   │   │   └── messages.json
│   │   │   ├── ja
│   │   │   │   └── messages.json
│   │   │   ├── ko
│   │   │   │   └── messages.json
│   │   │   ├── zh_CN
│   │   │   │   └── messages.json
│   │   │   └── zh_TW
│   │   │       └── messages.json
│   │   ├── .env.example
│   │   ├── assets
│   │   │   └── vue.svg
│   │   ├── common
│   │   │   ├── agent-models.ts
│   │   │   ├── constants.ts
│   │   │   ├── element-marker-types.ts
│   │   │   ├── message-types.ts
│   │   │   ├── node-types.ts
│   │   │   ├── rr-v3-keepalive-protocol.ts
│   │   │   ├── step-types.ts
│   │   │   ├── tool-handler.ts
│   │   │   └── web-editor-types.ts
│   │   ├── entrypoints
│   │   │   ├── background
│   │   │   │   ├── element-marker
│   │   │   │   │   ├── element-marker-storage.ts
│   │   │   │   │   └── index.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── keepalive-manager.ts
│   │   │   │   ├── native-host.ts
│   │   │   │   ├── quick-panel
│   │   │   │   │   ├── agent-handler.ts
│   │   │   │   │   ├── commands.ts
│   │   │   │   │   └── tabs-handler.ts
│   │   │   │   ├── record-replay
│   │   │   │   │   ├── actions
│   │   │   │   │   │   ├── adapter.ts
│   │   │   │   │   │   ├── handlers
│   │   │   │   │   │   │   ├── assert.ts
│   │   │   │   │   │   │   ├── click.ts
│   │   │   │   │   │   │   ├── common.ts
│   │   │   │   │   │   │   ├── control-flow.ts
│   │   │   │   │   │   │   ├── delay.ts
│   │   │   │   │   │   │   ├── dom.ts
│   │   │   │   │   │   │   ├── drag.ts
│   │   │   │   │   │   │   ├── extract.ts
│   │   │   │   │   │   │   ├── fill.ts
│   │   │   │   │   │   │   ├── http.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── key.ts
│   │   │   │   │   │   │   ├── navigate.ts
│   │   │   │   │   │   │   ├── screenshot.ts
│   │   │   │   │   │   │   ├── script.ts
│   │   │   │   │   │   │   ├── scroll.ts
│   │   │   │   │   │   │   ├── tabs.ts
│   │   │   │   │   │   │   └── wait.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── registry.ts
│   │   │   │   │   │   └── types.ts
│   │   │   │   │   ├── engine
│   │   │   │   │   │   ├── constants.ts
│   │   │   │   │   │   ├── execution-mode.ts
│   │   │   │   │   │   ├── logging
│   │   │   │   │   │   │   └── run-logger.ts
│   │   │   │   │   │   ├── plugins
│   │   │   │   │   │   │   ├── breakpoint.ts
│   │   │   │   │   │   │   ├── manager.ts
│   │   │   │   │   │   │   └── types.ts
│   │   │   │   │   │   ├── policies
│   │   │   │   │   │   │   ├── retry.ts
│   │   │   │   │   │   │   └── wait.ts
│   │   │   │   │   │   ├── runners
│   │   │   │   │   │   │   ├── after-script-queue.ts
│   │   │   │   │   │   │   ├── control-flow-runner.ts
│   │   │   │   │   │   │   ├── step-executor.ts
│   │   │   │   │   │   │   ├── step-runner.ts
│   │   │   │   │   │   │   └── subflow-runner.ts
│   │   │   │   │   │   ├── scheduler.ts
│   │   │   │   │   │   ├── state-manager.ts
│   │   │   │   │   │   └── utils
│   │   │   │   │   │       └── expression.ts
│   │   │   │   │   ├── flow-runner.ts
│   │   │   │   │   ├── flow-store.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── legacy-types.ts
│   │   │   │   │   ├── nodes
│   │   │   │   │   │   ├── assert.ts
│   │   │   │   │   │   ├── click.ts
│   │   │   │   │   │   ├── conditional.ts
│   │   │   │   │   │   ├── download-screenshot-attr-event-frame-loop.ts
│   │   │   │   │   │   ├── drag.ts
│   │   │   │   │   │   ├── execute-flow.ts
│   │   │   │   │   │   ├── extract.ts
│   │   │   │   │   │   ├── fill.ts
│   │   │   │   │   │   ├── http.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── key.ts
│   │   │   │   │   │   ├── loops.ts
│   │   │   │   │   │   ├── navigate.ts
│   │   │   │   │   │   ├── script.ts
│   │   │   │   │   │   ├── scroll.ts
│   │   │   │   │   │   ├── tabs.ts
│   │   │   │   │   │   ├── types.ts
│   │   │   │   │   │   └── wait.ts
│   │   │   │   │   ├── recording
│   │   │   │   │   │   ├── browser-event-listener.ts
│   │   │   │   │   │   ├── content-injection.ts
│   │   │   │   │   │   ├── content-message-handler.ts
│   │   │   │   │   │   ├── flow-builder.ts
│   │   │   │   │   │   ├── recorder-manager.ts
│   │   │   │   │   │   └── session-manager.ts
│   │   │   │   │   ├── rr-utils.ts
│   │   │   │   │   ├── selector-engine.ts
│   │   │   │   │   ├── storage
│   │   │   │   │   │   └── indexeddb-manager.ts
│   │   │   │   │   ├── trigger-store.ts
│   │   │   │   │   └── types.ts
│   │   │   │   ├── record-replay-v3
│   │   │   │   │   ├── bootstrap.ts
│   │   │   │   │   ├── domain
│   │   │   │   │   │   ├── debug.ts
│   │   │   │   │   │   ├── errors.ts
│   │   │   │   │   │   ├── events.ts
│   │   │   │   │   │   ├── flow.ts
│   │   │   │   │   │   ├── ids.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── json.ts
│   │   │   │   │   │   ├── policy.ts
│   │   │   │   │   │   ├── triggers.ts
│   │   │   │   │   │   └── variables.ts
│   │   │   │   │   ├── engine
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── keepalive
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   └── offscreen-keepalive.ts
│   │   │   │   │   │   ├── kernel
│   │   │   │   │   │   │   ├── artifacts.ts
│   │   │   │   │   │   │   ├── breakpoints.ts
│   │   │   │   │   │   │   ├── debug-controller.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── kernel.ts
│   │   │   │   │   │   │   ├── recovery-kernel.ts
│   │   │   │   │   │   │   ├── runner.ts
│   │   │   │   │   │   │   └── traversal.ts
│   │   │   │   │   │   ├── plugins
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── register-v2-replay-nodes.ts
│   │   │   │   │   │   │   ├── registry.ts
│   │   │   │   │   │   │   ├── types.ts
│   │   │   │   │   │   │   └── v2-action-adapter.ts
│   │   │   │   │   │   ├── queue
│   │   │   │   │   │   │   ├── enqueue-run.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── leasing.ts
│   │   │   │   │   │   │   ├── queue.ts
│   │   │   │   │   │   │   └── scheduler.ts
│   │   │   │   │   │   ├── recovery
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   └── recovery-coordinator.ts
│   │   │   │   │   │   ├── storage
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   └── storage-port.ts
│   │   │   │   │   │   ├── transport
│   │   │   │   │   │   │   ├── events-bus.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── rpc-server.ts
│   │   │   │   │   │   │   └── rpc.ts
│   │   │   │   │   │   └── triggers
│   │   │   │   │   │       ├── command-trigger.ts
│   │   │   │   │   │       ├── context-menu-trigger.ts
│   │   │   │   │   │       ├── cron-trigger.ts
│   │   │   │   │   │       ├── dom-trigger.ts
│   │   │   │   │   │       ├── index.ts
│   │   │   │   │   │       ├── interval-trigger.ts
│   │   │   │   │   │       ├── manual-trigger.ts
│   │   │   │   │   │       ├── once-trigger.ts
│   │   │   │   │   │       ├── trigger-handler.ts
│   │   │   │   │   │       ├── trigger-manager.ts
│   │   │   │   │   │       └── url-trigger.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── storage
│   │   │   │   │       ├── db.ts
│   │   │   │   │       ├── events.ts
│   │   │   │   │       ├── flows.ts
│   │   │   │   │       ├── import
│   │   │   │   │       │   ├── index.ts
│   │   │   │   │       │   ├── v2-reader.ts
│   │   │   │   │       │   └── v2-to-v3.ts
│   │   │   │   │       ├── index.ts
│   │   │   │   │       ├── persistent-vars.ts
│   │   │   │   │       ├── queue.ts
│   │   │   │   │       ├── runs.ts
│   │   │   │   │       └── triggers.ts
│   │   │   │   ├── semantic-similarity.ts
│   │   │   │   ├── storage-manager.ts
│   │   │   │   ├── tools
│   │   │   │   │   ├── base-browser.ts
│   │   │   │   │   ├── browser
│   │   │   │   │   │   ├── bookmark.ts
│   │   │   │   │   │   ├── common.ts
│   │   │   │   │   │   ├── computer.ts
│   │   │   │   │   │   ├── console-buffer.ts
│   │   │   │   │   │   ├── console.ts
│   │   │   │   │   │   ├── dialog.ts
│   │   │   │   │   │   ├── download.ts
│   │   │   │   │   │   ├── element-picker.ts
│   │   │   │   │   │   ├── file-upload.ts
│   │   │   │   │   │   ├── gif-auto-capture.ts
│   │   │   │   │   │   ├── gif-enhanced-renderer.ts
│   │   │   │   │   │   ├── gif-recorder.ts
│   │   │   │   │   │   ├── history.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── inject-script.ts
│   │   │   │   │   │   ├── interaction.ts
│   │   │   │   │   │   ├── javascript.ts
│   │   │   │   │   │   ├── keyboard.ts
│   │   │   │   │   │   ├── network-capture-debugger.ts
│   │   │   │   │   │   ├── network-capture-web-request.ts
│   │   │   │   │   │   ├── network-capture.ts
│   │   │   │   │   │   ├── network-request.ts
│   │   │   │   │   │   ├── performance.ts
│   │   │   │   │   │   ├── read-page.ts
│   │   │   │   │   │   ├── screenshot.ts
│   │   │   │   │   │   ├── userscript.ts
│   │   │   │   │   │   ├── vector-search.ts
│   │   │   │   │   │   ├── web-fetcher.ts
│   │   │   │   │   │   └── window.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── record-replay.ts
│   │   │   │   ├── utils
│   │   │   │   │   └── sidepanel.ts
│   │   │   │   └── web-editor
│   │   │   │       └── index.ts
│   │   │   ├── builder
│   │   │   │   ├── App.vue
│   │   │   │   ├── index.html
│   │   │   │   └── main.ts
│   │   │   ├── content.ts
│   │   │   ├── element-picker.content.ts
│   │   │   ├── offscreen
│   │   │   │   ├── gif-encoder.ts
│   │   │   │   ├── index.html
│   │   │   │   ├── main.ts
│   │   │   │   └── rr-keepalive.ts
│   │   │   ├── options
│   │   │   │   ├── App.vue
│   │   │   │   ├── index.html
│   │   │   │   └── main.ts
│   │   │   ├── popup
│   │   │   │   ├── App.vue
│   │   │   │   ├── components
│   │   │   │   │   ├── builder
│   │   │   │   │   │   ├── components
│   │   │   │   │   │   │   ├── Canvas.vue
│   │   │   │   │   │   │   ├── EdgePropertyPanel.vue
│   │   │   │   │   │   │   ├── KeyValueEditor.vue
│   │   │   │   │   │   │   ├── nodes
│   │   │   │   │   │   │   │   ├── node-util.ts
│   │   │   │   │   │   │   │   ├── NodeCard.vue
│   │   │   │   │   │   │   │   └── NodeIf.vue
│   │   │   │   │   │   │   ├── properties
│   │   │   │   │   │   │   │   ├── PropertyAssert.vue
│   │   │   │   │   │   │   │   ├── PropertyClick.vue
│   │   │   │   │   │   │   │   ├── PropertyCloseTab.vue
│   │   │   │   │   │   │   │   ├── PropertyDelay.vue
│   │   │   │   │   │   │   │   ├── PropertyDrag.vue
│   │   │   │   │   │   │   │   ├── PropertyExecuteFlow.vue
│   │   │   │   │   │   │   │   ├── PropertyExtract.vue
│   │   │   │   │   │   │   │   ├── PropertyFill.vue
│   │   │   │   │   │   │   │   ├── PropertyForeach.vue
│   │   │   │   │   │   │   │   ├── PropertyFormRenderer.vue
│   │   │   │   │   │   │   │   ├── PropertyFromSpec.vue
│   │   │   │   │   │   │   │   ├── PropertyHandleDownload.vue
│   │   │   │   │   │   │   │   ├── PropertyHttp.vue
│   │   │   │   │   │   │   │   ├── PropertyIf.vue
│   │   │   │   │   │   │   │   ├── PropertyKey.vue
│   │   │   │   │   │   │   │   ├── PropertyLoopElements.vue
│   │   │   │   │   │   │   │   ├── PropertyNavigate.vue
│   │   │   │   │   │   │   │   ├── PropertyOpenTab.vue
│   │   │   │   │   │   │   │   ├── PropertyScreenshot.vue
│   │   │   │   │   │   │   │   ├── PropertyScript.vue
│   │   │   │   │   │   │   │   ├── PropertyScroll.vue
│   │   │   │   │   │   │   │   ├── PropertySetAttribute.vue
│   │   │   │   │   │   │   │   ├── PropertySwitchFrame.vue
│   │   │   │   │   │   │   │   ├── PropertySwitchTab.vue
│   │   │   │   │   │   │   │   ├── PropertyTrigger.vue
│   │   │   │   │   │   │   │   ├── PropertyTriggerEvent.vue
│   │   │   │   │   │   │   │   ├── PropertyWait.vue
│   │   │   │   │   │   │   │   ├── PropertyWhile.vue
│   │   │   │   │   │   │   │   └── SelectorEditor.vue
│   │   │   │   │   │   │   ├── PropertyPanel.vue
│   │   │   │   │   │   │   ├── Sidebar.vue
│   │   │   │   │   │   │   └── TriggerPanel.vue
│   │   │   │   │   │   ├── model
│   │   │   │   │   │   │   ├── form-widget-registry.ts
│   │   │   │   │   │   │   ├── node-spec-registry.ts
│   │   │   │   │   │   │   ├── node-spec.ts
│   │   │   │   │   │   │   ├── node-specs-builtin.ts
│   │   │   │   │   │   │   ├── toast.ts
│   │   │   │   │   │   │   ├── transforms.ts
│   │   │   │   │   │   │   ├── ui-nodes.ts
│   │   │   │   │   │   │   ├── validation.ts
│   │   │   │   │   │   │   └── variables.ts
│   │   │   │   │   │   ├── store
│   │   │   │   │   │   │   └── useBuilderStore.ts
│   │   │   │   │   │   └── widgets
│   │   │   │   │   │       ├── FieldCode.vue
│   │   │   │   │   │       ├── FieldDuration.vue
│   │   │   │   │   │       ├── FieldExpression.vue
│   │   │   │   │   │       ├── FieldKeySequence.vue
│   │   │   │   │   │       ├── FieldSelector.vue
│   │   │   │   │   │       ├── FieldTargetLocator.vue
│   │   │   │   │   │       └── VarInput.vue
│   │   │   │   │   ├── ConfirmDialog.vue
│   │   │   │   │   ├── ElementMarkerManagement.vue
│   │   │   │   │   ├── icons
│   │   │   │   │   │   ├── BoltIcon.vue
│   │   │   │   │   │   ├── CheckIcon.vue
│   │   │   │   │   │   ├── DatabaseIcon.vue
│   │   │   │   │   │   ├── DocumentIcon.vue
│   │   │   │   │   │   ├── EditIcon.vue
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── MarkerIcon.vue
│   │   │   │   │   │   ├── RecordIcon.vue
│   │   │   │   │   │   ├── RefreshIcon.vue
│   │   │   │   │   │   ├── StopIcon.vue
│   │   │   │   │   │   ├── TabIcon.vue
│   │   │   │   │   │   ├── TrashIcon.vue
│   │   │   │   │   │   ├── VectorIcon.vue
│   │   │   │   │   │   └── WorkflowIcon.vue
│   │   │   │   │   ├── LocalModelPage.vue
│   │   │   │   │   ├── ModelCacheManagement.vue
│   │   │   │   │   ├── ProgressIndicator.vue
│   │   │   │   │   └── ScheduleDialog.vue
│   │   │   │   ├── index.html
│   │   │   │   ├── main.ts
│   │   │   │   └── style.css
│   │   │   ├── quick-panel.content.ts
│   │   │   ├── shared
│   │   │   │   ├── composables
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── useRRV3Rpc.ts
│   │   │   │   └── utils
│   │   │   │       ├── index.ts
│   │   │   │       └── rr-flow-convert.ts
│   │   │   ├── sidepanel
│   │   │   │   ├── App.vue
│   │   │   │   ├── components
│   │   │   │   │   ├── agent
│   │   │   │   │   │   ├── AttachmentPreview.vue
│   │   │   │   │   │   ├── ChatInput.vue
│   │   │   │   │   │   ├── CliSettings.vue
│   │   │   │   │   │   ├── ConnectionStatus.vue
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── MessageItem.vue
│   │   │   │   │   │   ├── MessageList.vue
│   │   │   │   │   │   ├── ProjectCreateForm.vue
│   │   │   │   │   │   └── ProjectSelector.vue
│   │   │   │   │   ├── agent-chat
│   │   │   │   │   │   ├── AgentChatShell.vue
│   │   │   │   │   │   ├── AgentComposer.vue
│   │   │   │   │   │   ├── AgentConversation.vue
│   │   │   │   │   │   ├── AgentOpenProjectMenu.vue
│   │   │   │   │   │   ├── AgentProjectMenu.vue
│   │   │   │   │   │   ├── AgentRequestThread.vue
│   │   │   │   │   │   ├── AgentSessionListItem.vue
│   │   │   │   │   │   ├── AgentSessionMenu.vue
│   │   │   │   │   │   ├── AgentSessionSettingsPanel.vue
│   │   │   │   │   │   ├── AgentSessionsView.vue
│   │   │   │   │   │   ├── AgentSettingsMenu.vue
│   │   │   │   │   │   ├── AgentTimeline.vue
│   │   │   │   │   │   ├── AgentTimelineItem.vue
│   │   │   │   │   │   ├── AgentTopBar.vue
│   │   │   │   │   │   ├── ApplyMessageChip.vue
│   │   │   │   │   │   ├── AttachmentCachePanel.vue
│   │   │   │   │   │   ├── ComposerDrawer.vue
│   │   │   │   │   │   ├── ElementChip.vue
│   │   │   │   │   │   ├── FakeCaretOverlay.vue
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── SelectionChip.vue
│   │   │   │   │   │   ├── timeline
│   │   │   │   │   │   │   ├── markstream-thinking.ts
│   │   │   │   │   │   │   ├── ThinkingNode.vue
│   │   │   │   │   │   │   ├── TimelineNarrativeStep.vue
│   │   │   │   │   │   │   ├── TimelineStatusStep.vue
│   │   │   │   │   │   │   ├── TimelineToolCallStep.vue
│   │   │   │   │   │   │   ├── TimelineToolResultCardStep.vue
│   │   │   │   │   │   │   └── TimelineUserPromptStep.vue
│   │   │   │   │   │   └── WebEditorChanges.vue
│   │   │   │   │   ├── AgentChat.vue
│   │   │   │   │   ├── rr-v3
│   │   │   │   │   │   └── DebuggerPanel.vue
│   │   │   │   │   ├── SidepanelNavigator.vue
│   │   │   │   │   └── workflows
│   │   │   │   │       ├── index.ts
│   │   │   │   │       ├── WorkflowListItem.vue
│   │   │   │   │       └── WorkflowsView.vue
│   │   │   │   ├── composables
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── useAgentChat.ts
│   │   │   │   │   ├── useAgentChatViewRoute.ts
│   │   │   │   │   ├── useAgentInputPreferences.ts
│   │   │   │   │   ├── useAgentProjects.ts
│   │   │   │   │   ├── useAgentServer.ts
│   │   │   │   │   ├── useAgentSessions.ts
│   │   │   │   │   ├── useAgentTheme.ts
│   │   │   │   │   ├── useAgentThreads.ts
│   │   │   │   │   ├── useAttachments.ts
│   │   │   │   │   ├── useFakeCaret.ts
│   │   │   │   │   ├── useFloatingDrag.ts
│   │   │   │   │   ├── useOpenProjectPreference.ts
│   │   │   │   │   ├── useRRV3Debugger.ts
│   │   │   │   │   ├── useRRV3Rpc.ts
│   │   │   │   │   ├── useTextareaAutoResize.ts
│   │   │   │   │   ├── useWebEditorTxState.ts
│   │   │   │   │   └── useWorkflowsV3.ts
│   │   │   │   ├── index.html
│   │   │   │   ├── main.ts
│   │   │   │   ├── styles
│   │   │   │   │   └── agent-chat.css
│   │   │   │   └── utils
│   │   │   │       └── loading-texts.ts
│   │   │   ├── styles
│   │   │   │   └── tailwind.css
│   │   │   ├── web-editor-v2
│   │   │   │   ├── attr-ui-refactor.md
│   │   │   │   ├── constants.ts
│   │   │   │   ├── core
│   │   │   │   │   ├── css-compare.ts
│   │   │   │   │   ├── cssom-styles-collector.ts
│   │   │   │   │   ├── debug-source.ts
│   │   │   │   │   ├── design-tokens
│   │   │   │   │   │   ├── design-tokens-service.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── token-detector.ts
│   │   │   │   │   │   ├── token-resolver.ts
│   │   │   │   │   │   └── types.ts
│   │   │   │   │   ├── editor.ts
│   │   │   │   │   ├── element-key.ts
│   │   │   │   │   ├── event-controller.ts
│   │   │   │   │   ├── execution-tracker.ts
│   │   │   │   │   ├── hmr-consistency.ts
│   │   │   │   │   ├── locator.ts
│   │   │   │   │   ├── message-listener.ts
│   │   │   │   │   ├── payload-builder.ts
│   │   │   │   │   ├── perf-monitor.ts
│   │   │   │   │   ├── position-tracker.ts
│   │   │   │   │   ├── props-bridge.ts
│   │   │   │   │   ├── snap-engine.ts
│   │   │   │   │   ├── transaction-aggregator.ts
│   │   │   │   │   └── transaction-manager.ts
│   │   │   │   ├── drag
│   │   │   │   │   └── drag-reorder-controller.ts
│   │   │   │   ├── overlay
│   │   │   │   │   ├── canvas-overlay.ts
│   │   │   │   │   └── handles-controller.ts
│   │   │   │   ├── selection
│   │   │   │   │   └── selection-engine.ts
│   │   │   │   ├── ui
│   │   │   │   │   ├── breadcrumbs.ts
│   │   │   │   │   ├── floating-drag.ts
│   │   │   │   │   ├── icons.ts
│   │   │   │   │   ├── property-panel
│   │   │   │   │   │   ├── class-editor.ts
│   │   │   │   │   │   ├── components
│   │   │   │   │   │   │   ├── alignment-grid.ts
│   │   │   │   │   │   │   ├── icon-button-group.ts
│   │   │   │   │   │   │   ├── input-container.ts
│   │   │   │   │   │   │   ├── slider-input.ts
│   │   │   │   │   │   │   └── token-pill.ts
│   │   │   │   │   │   ├── components-tree.ts
│   │   │   │   │   │   ├── controls
│   │   │   │   │   │   │   ├── appearance-control.ts
│   │   │   │   │   │   │   ├── background-control.ts
│   │   │   │   │   │   │   ├── border-control.ts
│   │   │   │   │   │   │   ├── color-field.ts
│   │   │   │   │   │   │   ├── css-helpers.ts
│   │   │   │   │   │   │   ├── effects-control.ts
│   │   │   │   │   │   │   ├── gradient-control.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── layout-control.ts
│   │   │   │   │   │   │   ├── number-stepping.ts
│   │   │   │   │   │   │   ├── position-control.ts
│   │   │   │   │   │   │   ├── size-control.ts
│   │   │   │   │   │   │   ├── spacing-control.ts
│   │   │   │   │   │   │   ├── token-picker.ts
│   │   │   │   │   │   │   └── typography-control.ts
│   │   │   │   │   │   ├── css-defaults.ts
│   │   │   │   │   │   ├── css-panel.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── property-panel.ts
│   │   │   │   │   │   ├── props-panel.ts
│   │   │   │   │   │   └── types.ts
│   │   │   │   │   ├── shadow-host.ts
│   │   │   │   │   └── toolbar.ts
│   │   │   │   └── utils
│   │   │   │       └── disposables.ts
│   │   │   ├── web-editor-v2.ts
│   │   │   └── welcome
│   │   │       ├── App.vue
│   │   │       ├── index.html
│   │   │       └── main.ts
│   │   ├── env.d.ts
│   │   ├── eslint.config.js
│   │   ├── inject-scripts
│   │   │   ├── accessibility-tree-helper.js
│   │   │   ├── click-helper.js
│   │   │   ├── dom-observer.js
│   │   │   ├── element-marker.js
│   │   │   ├── element-picker.js
│   │   │   ├── fill-helper.js
│   │   │   ├── inject-bridge.js
│   │   │   ├── interactive-elements-helper.js
│   │   │   ├── keyboard-helper.js
│   │   │   ├── network-helper.js
│   │   │   ├── props-agent.js
│   │   │   ├── recorder.js
│   │   │   ├── screenshot-helper.js
│   │   │   ├── wait-helper.js
│   │   │   ├── web-editor.js
│   │   │   └── web-fetcher-helper.js
│   │   ├── LICENSE
│   │   ├── package.json
│   │   ├── public
│   │   │   ├── icon
│   │   │   │   ├── 128.png
│   │   │   │   ├── 16.png
│   │   │   │   ├── 32.png
│   │   │   │   ├── 48.png
│   │   │   │   └── 96.png
│   │   │   ├── libs
│   │   │   │   └── ort.min.js
│   │   │   └── wxt.svg
│   │   ├── README.md
│   │   ├── shared
│   │   │   ├── element-picker
│   │   │   │   ├── controller.ts
│   │   │   │   └── index.ts
│   │   │   ├── quick-panel
│   │   │   │   ├── core
│   │   │   │   │   ├── agent-bridge.ts
│   │   │   │   │   ├── search-engine.ts
│   │   │   │   │   └── types.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── providers
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── tabs-provider.ts
│   │   │   │   └── ui
│   │   │   │       ├── ai-chat-panel.ts
│   │   │   │       ├── index.ts
│   │   │   │       ├── markdown-renderer.ts
│   │   │   │       ├── message-renderer.ts
│   │   │   │       ├── panel-shell.ts
│   │   │   │       ├── quick-entries.ts
│   │   │   │       ├── search-input.ts
│   │   │   │       ├── shadow-host.ts
│   │   │   │       └── styles.ts
│   │   │   └── selector
│   │   │       ├── dom-path.ts
│   │   │       ├── fingerprint.ts
│   │   │       ├── generator.ts
│   │   │       ├── index.ts
│   │   │       ├── locator.ts
│   │   │       ├── shadow-dom.ts
│   │   │       ├── stability.ts
│   │   │       ├── strategies
│   │   │       │   ├── anchor-relpath.ts
│   │   │       │   ├── aria.ts
│   │   │       │   ├── css-path.ts
│   │   │       │   ├── css-unique.ts
│   │   │       │   ├── index.ts
│   │   │       │   ├── testid.ts
│   │   │       │   └── text.ts
│   │   │       └── types.ts
│   │   ├── tailwind.config.ts
│   │   ├── tests
│   │   │   ├── __mocks__
│   │   │   │   └── hnswlib-wasm-static.ts
│   │   │   ├── record-replay
│   │   │   │   ├── _test-helpers.ts
│   │   │   │   ├── adapter-policy.contract.test.ts
│   │   │   │   ├── flow-store-strip-steps.contract.test.ts
│   │   │   │   ├── high-risk-actions.integration.test.ts
│   │   │   │   ├── hybrid-actions.integration.test.ts
│   │   │   │   ├── script-control-flow.integration.test.ts
│   │   │   │   ├── session-dag-sync.contract.test.ts
│   │   │   │   ├── step-executor.contract.test.ts
│   │   │   │   └── tab-cursor.integration.test.ts
│   │   │   ├── record-replay-v3
│   │   │   │   ├── command-trigger.test.ts
│   │   │   │   ├── context-menu-trigger.test.ts
│   │   │   │   ├── cron-trigger.test.ts
│   │   │   │   ├── debugger.contract.test.ts
│   │   │   │   ├── dom-trigger.test.ts
│   │   │   │   ├── e2e.integration.test.ts
│   │   │   │   ├── events.contract.test.ts
│   │   │   │   ├── interval-trigger.test.ts
│   │   │   │   ├── manual-trigger.test.ts
│   │   │   │   ├── once-trigger.test.ts
│   │   │   │   ├── queue.contract.test.ts
│   │   │   │   ├── recovery.test.ts
│   │   │   │   ├── rpc-api.test.ts
│   │   │   │   ├── runner.onError.contract.test.ts
│   │   │   │   ├── scheduler-integration.test.ts
│   │   │   │   ├── scheduler.test.ts
│   │   │   │   ├── spec-smoke.test.ts
│   │   │   │   ├── trigger-manager.test.ts
│   │   │   │   ├── triggers.test.ts
│   │   │   │   ├── url-trigger.test.ts
│   │   │   │   ├── v2-action-adapter.test.ts
│   │   │   │   ├── v2-adapter-integration.test.ts
│   │   │   │   ├── v2-to-v3-conversion.test.ts
│   │   │   │   └── v3-e2e-harness.ts
│   │   │   ├── vitest.setup.ts
│   │   │   └── web-editor-v2
│   │   │       ├── design-tokens.test.ts
│   │   │       ├── drag-reorder-controller.test.ts
│   │   │       ├── event-controller.test.ts
│   │   │       ├── locator.test.ts
│   │   │       ├── property-panel-live-sync.test.ts
│   │   │       ├── selection-engine.test.ts
│   │   │       ├── snap-engine.test.ts
│   │   │       └── test-utils
│   │   │           └── dom.ts
│   │   ├── tsconfig.json
│   │   ├── types
│   │   │   ├── gifenc.d.ts
│   │   │   └── icons.d.ts
│   │   ├── utils
│   │   │   ├── cdp-session-manager.ts
│   │   │   ├── content-indexer.ts
│   │   │   ├── i18n.ts
│   │   │   ├── image-utils.ts
│   │   │   ├── indexeddb-client.ts
│   │   │   ├── lru-cache.ts
│   │   │   ├── model-cache-manager.ts
│   │   │   ├── offscreen-manager.ts
│   │   │   ├── output-sanitizer.ts
│   │   │   ├── screenshot-context.ts
│   │   │   ├── semantic-similarity-engine.ts
│   │   │   ├── simd-math-engine.ts
│   │   │   ├── text-chunker.ts
│   │   │   └── vector-database.ts
│   │   ├── vitest.config.ts
│   │   ├── workers
│   │   │   ├── ort-wasm-simd-threaded.jsep.mjs
│   │   │   ├── ort-wasm-simd-threaded.jsep.wasm
│   │   │   ├── ort-wasm-simd-threaded.mjs
│   │   │   ├── ort-wasm-simd-threaded.wasm
│   │   │   ├── simd_math_bg.wasm
│   │   │   ├── simd_math.js
│   │   │   └── similarity.worker.js
│   │   └── wxt.config.ts
│   └── native-server
│       ├── .npmignore
│       ├── debug.sh
│       ├── install.md
│       ├── jest.config.js
│       ├── package.json
│       ├── README.md
│       ├── src
│       │   ├── agent
│       │   │   ├── attachment-service.ts
│       │   │   ├── ccr-detector.ts
│       │   │   ├── chat-service.ts
│       │   │   ├── db
│       │   │   │   ├── client.ts
│       │   │   │   ├── index.ts
│       │   │   │   └── schema.ts
│       │   │   ├── directory-picker.ts
│       │   │   ├── engines
│       │   │   │   ├── claude.ts
│       │   │   │   ├── codex.ts
│       │   │   │   └── types.ts
│       │   │   ├── message-service.ts
│       │   │   ├── open-project.ts
│       │   │   ├── project-service.ts
│       │   │   ├── project-types.ts
│       │   │   ├── session-service.ts
│       │   │   ├── storage.ts
│       │   │   ├── stream-manager.ts
│       │   │   ├── tool-bridge.ts
│       │   │   └── types.ts
│       │   ├── cli.ts
│       │   ├── constant
│       │   │   └── index.ts
│       │   ├── file-handler.ts
│       │   ├── index.ts
│       │   ├── mcp
│       │   │   ├── mcp-server-stdio.ts
│       │   │   ├── mcp-server.ts
│       │   │   ├── register-tools.ts
│       │   │   └── stdio-config.json
│       │   ├── native-messaging-host.ts
│       │   ├── scripts
│       │   │   ├── browser-config.ts
│       │   │   ├── build.ts
│       │   │   ├── constant.ts
│       │   │   ├── doctor.ts
│       │   │   ├── postinstall.ts
│       │   │   ├── register-dev.ts
│       │   │   ├── register.ts
│       │   │   ├── report.ts
│       │   │   ├── run_host.bat
│       │   │   ├── run_host.sh
│       │   │   └── utils.ts
│       │   ├── server
│       │   │   ├── index.ts
│       │   │   ├── routes
│       │   │   │   ├── agent.ts
│       │   │   │   └── index.ts
│       │   │   └── server.test.ts
│       │   ├── shims
│       │   │   └── devtools.d.ts
│       │   ├── trace-analyzer.ts
│       │   ├── types
│       │   │   └── devtools-frontend.d.ts
│       │   └── util
│       │       └── logger.ts
│       └── tsconfig.json
├── commitlint.config.cjs
├── docs
│   ├── ARCHITECTURE_zh.md
│   ├── ARCHITECTURE.md
│   ├── CHANGELOG.md
│   ├── CONTRIBUTING_zh.md
│   ├── CONTRIBUTING.md
│   ├── ISSUE.md
│   ├── mcp-cli-config.md
│   ├── TOOLS_zh.md
│   ├── TOOLS.md
│   ├── TROUBLESHOOTING_zh.md
│   ├── TROUBLESHOOTING.md
│   ├── VisualEditor_zh.md
│   ├── VisualEditor.md
│   └── WINDOWS_INSTALL_zh.md
├── eslint.config.js
├── LICENSE
├── package.json
├── packages
│   ├── shared
│   │   ├── package.json
│   │   ├── src
│   │   │   ├── agent-types.ts
│   │   │   ├── constants.ts
│   │   │   ├── index.ts
│   │   │   ├── labels.ts
│   │   │   ├── node-spec-registry.ts
│   │   │   ├── node-spec.ts
│   │   │   ├── node-specs-builtin.ts
│   │   │   ├── rr-graph.ts
│   │   │   ├── step-types.ts
│   │   │   ├── tools.ts
│   │   │   └── types.ts
│   │   └── tsconfig.json
│   └── wasm-simd
│       ├── .gitignore
│       ├── BUILD.md
│       ├── Cargo.toml
│       ├── package.json
│       ├── README.md
│       └── src
│           └── lib.rs
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── prompt
│   ├── content-analize.md
│   ├── excalidraw-prompt.md
│   └── modify-web.md
├── README_zh.md
├── README.md
└── releases
    ├── chrome-extension
    │   └── latest
    │       └── chrome-mcp-server-lastest.zip
    └── README.md
```

# Files

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/engine/runners/subflow-runner.ts:
--------------------------------------------------------------------------------

```typescript
  1 | // subflow-runner.ts — execute a subflow (nodes/edges) using DAG traversal with branch support
  2 | 
  3 | import { STEP_TYPES } from 'chrome-mcp-shared';
  4 | import type { ExecCtx } from '../../nodes';
  5 | import { RunLogger } from '../logging/run-logger';
  6 | import { PluginManager } from '../plugins/manager';
  7 | import { mapDagNodeToStep } from '../../rr-utils';
  8 | import type { Edge, NodeBase, Step } from '../../types';
  9 | import { StepRunner } from './step-runner';
 10 | import { ENGINE_CONSTANTS } from '../constants';
 11 | 
 12 | export interface SubflowEnv {
 13 |   runId: string;
 14 |   flow: any;
 15 |   vars: Record<string, any>;
 16 |   logger: RunLogger;
 17 |   pluginManager: PluginManager;
 18 |   stepRunner: StepRunner;
 19 | }
 20 | 
 21 | export class SubflowRunner {
 22 |   constructor(private env: SubflowEnv) {}
 23 | 
 24 |   async runSubflowById(subflowId: string, ctx: ExecCtx, pausedRef: () => boolean): Promise<void> {
 25 |     const sub = (this.env.flow.subflows || {})[subflowId];
 26 |     if (!sub || !Array.isArray(sub.nodes) || sub.nodes.length === 0) return;
 27 | 
 28 |     try {
 29 |       await this.env.pluginManager.subflowStart({
 30 |         runId: this.env.runId,
 31 |         flow: this.env.flow,
 32 |         vars: this.env.vars,
 33 |         subflowId,
 34 |       });
 35 |     } catch (e: any) {
 36 |       this.env.logger.push({
 37 |         stepId: `subflow:${subflowId}`,
 38 |         status: 'warning',
 39 |         message: `plugin.subflowStart error: ${e?.message || String(e)}`,
 40 |       });
 41 |     }
 42 | 
 43 |     const sNodes: NodeBase[] = sub.nodes;
 44 |     const sEdges: Edge[] = sub.edges || [];
 45 | 
 46 |     // Build lookup maps
 47 |     const id2node = new Map(sNodes.map((n) => [n.id, n] as const));
 48 |     const outEdges = new Map<string, Edge[]>();
 49 |     for (const e of sEdges) {
 50 |       if (!outEdges.has(e.from)) outEdges.set(e.from, []);
 51 |       outEdges.get(e.from)!.push(e);
 52 |     }
 53 | 
 54 |     // Calculate in-degrees to find root nodes
 55 |     const indeg = new Map<string, number>(sNodes.map((n) => [n.id, 0] as const));
 56 |     for (const e of sEdges) {
 57 |       indeg.set(e.to, (indeg.get(e.to) || 0) + 1);
 58 |     }
 59 | 
 60 |     // Find start node: prefer non-trigger nodes with indeg=0
 61 |     const findFirstExecutableRoot = (): string | undefined => {
 62 |       const executableRoot = sNodes.find(
 63 |         (n) => (indeg.get(n.id) || 0) === 0 && n.type !== STEP_TYPES.TRIGGER,
 64 |       );
 65 |       if (executableRoot) return executableRoot.id;
 66 | 
 67 |       // If all roots are triggers, follow default edge to first executable
 68 |       const triggerRoot = sNodes.find((n) => (indeg.get(n.id) || 0) === 0);
 69 |       if (triggerRoot) {
 70 |         const defaultEdge = (outEdges.get(triggerRoot.id) || []).find(
 71 |           (e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT,
 72 |         );
 73 |         if (defaultEdge) return defaultEdge.to;
 74 |       }
 75 | 
 76 |       return sNodes[0]?.id;
 77 |     };
 78 | 
 79 |     let currentId: string | undefined = findFirstExecutableRoot();
 80 |     let guard = 0;
 81 |     const maxIterations = ENGINE_CONSTANTS.MAX_ITERATIONS;
 82 | 
 83 |     const ok = (s: Step) => this.env.logger.overlayAppend(`✔ ${s.type} (${s.id})`);
 84 |     const fail = (s: Step, e: any) =>
 85 |       this.env.logger.overlayAppend(`✘ ${s.type} (${s.id}) -> ${e?.message || String(e)}`);
 86 | 
 87 |     while (currentId) {
 88 |       if (pausedRef()) break;
 89 |       if (guard++ >= maxIterations) {
 90 |         this.env.logger.push({
 91 |           stepId: `subflow:${subflowId}`,
 92 |           status: 'warning',
 93 |           message: `Subflow exceeded ${maxIterations} iterations - possible cycle`,
 94 |         });
 95 |         break;
 96 |       }
 97 | 
 98 |       const node = id2node.get(currentId);
 99 |       if (!node) break;
100 | 
101 |       // Skip trigger nodes
102 |       if (node.type === STEP_TYPES.TRIGGER) {
103 |         const defaultEdge = (outEdges.get(currentId) || []).find(
104 |           (e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT,
105 |         );
106 |         if (defaultEdge) {
107 |           currentId = defaultEdge.to;
108 |           continue;
109 |         }
110 |         break;
111 |       }
112 | 
113 |       const step: Step = mapDagNodeToStep(node);
114 |       const r = await this.env.stepRunner.run(ctx, step, ok, fail);
115 | 
116 |       if (r.status === 'paused' || pausedRef()) break;
117 | 
118 |       if (r.status === 'failed') {
119 |         // Try to find on_error edge
120 |         const errEdge = (outEdges.get(currentId) || []).find(
121 |           (e) => e.label === ENGINE_CONSTANTS.EDGE_LABELS.ON_ERROR,
122 |         );
123 |         if (errEdge) {
124 |           currentId = errEdge.to;
125 |           continue;
126 |         }
127 |         break;
128 |       }
129 | 
130 |       // Determine next edge by label
131 |       const suggestedLabel = r.nextLabel
132 |         ? String(r.nextLabel)
133 |         : ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT;
134 |       const oes = outEdges.get(currentId) || [];
135 |       const nextEdge =
136 |         oes.find((e) => (e.label || ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT) === suggestedLabel) ||
137 |         oes.find((e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT);
138 | 
139 |       if (!nextEdge) {
140 |         // Log warning if we expected a labeled edge but couldn't find it
141 |         if (r.nextLabel && oes.length > 0) {
142 |           const availableLabels = oes.map((e) => e.label || ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT);
143 |           this.env.logger.push({
144 |             stepId: step.id,
145 |             status: 'warning',
146 |             message: `No edge for label '${suggestedLabel}'. Available: [${availableLabels.join(', ')}]`,
147 |           });
148 |         }
149 |         break;
150 |       }
151 |       currentId = nextEdge.to;
152 |     }
153 | 
154 |     try {
155 |       await this.env.pluginManager.subflowEnd({
156 |         runId: this.env.runId,
157 |         flow: this.env.flow,
158 |         vars: this.env.vars,
159 |         subflowId,
160 |       });
161 |     } catch (e: any) {
162 |       this.env.logger.push({
163 |         stepId: `subflow:${subflowId}`,
164 |         status: 'warning',
165 |         message: `plugin.subflowEnd error: ${e?.message || String(e)}`,
166 |       });
167 |     }
168 |   }
169 | }
170 | 
```

--------------------------------------------------------------------------------
/docs/CHANGELOG.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Changelog
  2 | 
  3 | All notable changes to this project will be documented in this file.
  4 | 
  5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
  6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
  7 | 
  8 | ## [v0.0.5]
  9 | 
 10 | ### Improved
 11 | 
 12 | - **Image Compression**: Compress base64 images when using screenshot tool
 13 | - **Interactive Elements Detection Optimization**: Enhanced interactive elements detection tool with expanded search scope, now supports finding interactive div elements
 14 | 
 15 | ## [v0.0.4]
 16 | 
 17 | ### Added
 18 | 
 19 | - **STDIO Connection Support**: Added support for connecting to the MCP server via standard input/output (stdio) method
 20 | - **Console Output Capture Tool**: New `chrome_console` tool for capturing browser console output
 21 | 
 22 | ## [v0.0.3]
 23 | 
 24 | ### Added
 25 | 
 26 | - **Inject script tool**: For injecting content scripts into web page
 27 | - **Send command to inject script tool**: For sending commands to the injected script
 28 | 
 29 | ## [v0.0.2]
 30 | 
 31 | ### Added
 32 | 
 33 | - **Conditional Semantic Engine Initialization**: Smart cache-based initialization that only loads models when cached versions are available
 34 | - **Enhanced Model Cache Management**: Comprehensive cache management system with automatic cleanup and size limits
 35 | - **Windows Platform Compatibility**: Full support for Windows Chrome Native Messaging with registry-based manifest detection
 36 | - **Cache Statistics and Manual Management**: User interface for viewing cache stats and manual cache cleanup
 37 | - **Concurrent Initialization Protection**: Prevents duplicate initialization attempts across components
 38 | 
 39 | ### Improved
 40 | 
 41 | - **Startup Performance**: Dramatically reduced startup time when no model cache exists (from ~3s to ~0.5s)
 42 | - **Memory Usage**: Optimized memory consumption through on-demand model loading
 43 | - **Cache Expiration Logic**: Intelligent cache expiration (14 days) with automatic cleanup
 44 | - **Error Handling**: Enhanced error handling for model initialization failures
 45 | - **Component Coordination**: Simplified initialization flow between semantic engine and content indexer
 46 | 
 47 | ### Fixed
 48 | 
 49 | - **Windows Native Host Issues**: Resolved Node.js environment conflicts with multiple NVM installations
 50 | - **Race Condition Prevention**: Eliminated concurrent initialization attempts that could cause conflicts
 51 | - **Cache Size Management**: Automatic cleanup when cache exceeds 500MB limit
 52 | - **Model Download Optimization**: Prevents unnecessary model downloads during plugin startup
 53 | 
 54 | ### Technical Improvements
 55 | 
 56 | - **ModelCacheManager**: Added `isModelCached()` and `hasAnyValidCache()` methods for cache detection
 57 | - **SemanticSimilarityEngine**: Added cache checking functions and conditional initialization logic
 58 | - **Background Script**: Implemented smart initialization based on cache availability
 59 | - **VectorSearchTool**: Simplified to passive initialization model
 60 | - **ContentIndexer**: Enhanced with semantic engine readiness checks
 61 | 
 62 | ### Documentation
 63 | 
 64 | - Added comprehensive conditional initialization documentation
 65 | - Updated cache management system documentation
 66 | - Created troubleshooting guides for Windows platform issues
 67 | 
 68 | ## [v0.0.1]
 69 | 
 70 | ### Added
 71 | 
 72 | - **Core Browser Tools**: Complete set of browser automation tools for web interaction
 73 | 
 74 |   - **Click Tool**: Intelligent element clicking with coordinate and selector support
 75 |   - **Fill Tool**: Form filling with text input and selection capabilities
 76 |   - **Screenshot Tool**: Full page and element-specific screenshot capture
 77 |   - **Navigation Tools**: URL navigation and page interaction utilities
 78 |   - **Keyboard Tool**: Keyboard input simulation and hotkey support
 79 | 
 80 | - **Vector Search Engine**: Advanced semantic search capabilities
 81 | 
 82 |   - **Content Indexing**: Automatic indexing of browser tab content
 83 |   - **Semantic Similarity**: AI-powered text similarity matching
 84 |   - **Vector Database**: Efficient storage and retrieval of embeddings
 85 |   - **Multi-language Support**: Comprehensive multilingual text processing
 86 | 
 87 | - **Native Host Integration**: Seamless communication with external applications
 88 | 
 89 |   - **Chrome Native Messaging**: Bidirectional communication channel
 90 |   - **Cross-platform Support**: Windows, macOS, and Linux compatibility
 91 |   - **Message Protocol**: Structured messaging system for tool execution
 92 | 
 93 | - **AI Model Integration**: State-of-the-art language models for semantic processing
 94 | 
 95 |   - **Transformer Models**: Support for multiple pre-trained models
 96 |   - **ONNX Runtime**: Optimized model inference with WebAssembly
 97 |   - **Model Management**: Dynamic model loading and switching
 98 |   - **Performance Optimization**: SIMD acceleration and memory pooling
 99 | 
100 | - **User Interface**: Intuitive popup interface for extension management
101 |   - **Model Selection**: Easy switching between different AI models
102 |   - **Status Monitoring**: Real-time initialization and download progress
103 |   - **Settings Management**: User preferences and configuration options
104 |   - **Cache Management**: Visual cache statistics and cleanup controls
105 | 
106 | ### Technical Foundation
107 | 
108 | - **Extension Architecture**: Robust Chrome extension with background scripts and content injection
109 | - **Worker-based Processing**: Offscreen document for heavy computational tasks
110 | - **Memory Management**: LRU caching and efficient resource utilization
111 | - **Error Handling**: Comprehensive error reporting and recovery mechanisms
112 | - **TypeScript Implementation**: Full type safety and modern JavaScript features
113 | 
114 | ### Initial Features
115 | 
116 | - Multi-tab content analysis and search
117 | - Real-time semantic similarity computation
118 | - Automated web page interaction
119 | - Cross-platform native messaging
120 | - Extensible tool framework for future enhancements
121 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/events-bus.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview EventsBus Interface and Implementation
  3 |  * @description Event subscription, publishing, and persistence
  4 |  */
  5 | 
  6 | import type { RunId } from '../../domain/ids';
  7 | import type { RunEvent, RunEventInput, Unsubscribe } from '../../domain/events';
  8 | import type { EventsStore } from '../storage/storage-port';
  9 | 
 10 | /**
 11 |  * Event query parameters
 12 |  */
 13 | export interface EventsQuery {
 14 |   /** Run ID */
 15 |   runId: RunId;
 16 |   /** Starting sequence number (inclusive) */
 17 |   fromSeq?: number;
 18 |   /** Maximum number of results */
 19 |   limit?: number;
 20 | }
 21 | 
 22 | /**
 23 |  * Subscription filter
 24 |  */
 25 | export interface EventsFilter {
 26 |   /** Only receive events for this Run */
 27 |   runId?: RunId;
 28 | }
 29 | 
 30 | /**
 31 |  * EventsBus Interface
 32 |  * @description Responsible for event subscription, publishing, and persistence
 33 |  */
 34 | export interface EventsBus {
 35 |   /**
 36 |    * Subscribe to events
 37 |    * @param listener Event listener
 38 |    * @param filter Optional filter
 39 |    * @returns Unsubscribe function
 40 |    */
 41 |   subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe;
 42 | 
 43 |   /**
 44 |    * Append event
 45 |    * @description Delegates to EventsStore for atomic seq allocation, then broadcasts
 46 |    * @param event Event input (without seq)
 47 |    * @returns Complete event (with seq and ts)
 48 |    */
 49 |   append(event: RunEventInput): Promise<RunEvent>;
 50 | 
 51 |   /**
 52 |    * Query historical events
 53 |    * @param query Query parameters
 54 |    * @returns Events sorted by seq ascending
 55 |    */
 56 |   list(query: EventsQuery): Promise<RunEvent[]>;
 57 | }
 58 | 
 59 | /**
 60 |  * Create NotImplemented EventsBus
 61 |  * @description Phase 0 placeholder
 62 |  */
 63 | export function createNotImplementedEventsBus(): EventsBus {
 64 |   const notImplemented = () => {
 65 |     throw new Error('EventsBus not implemented');
 66 |   };
 67 | 
 68 |   return {
 69 |     subscribe: () => {
 70 |       notImplemented();
 71 |       return () => {};
 72 |     },
 73 |     append: async () => notImplemented(),
 74 |     list: async () => notImplemented(),
 75 |   };
 76 | }
 77 | 
 78 | /**
 79 |  * Listener entry for subscription management
 80 |  */
 81 | interface ListenerEntry {
 82 |   listener: (event: RunEvent) => void;
 83 |   filter?: EventsFilter;
 84 | }
 85 | 
 86 | /**
 87 |  * Storage-backed EventsBus Implementation
 88 |  * @description
 89 |  * - seq allocation is done by EventsStore.append() (atomic with RunRecordV3.nextSeq)
 90 |  * - broadcast happens only after append resolves (i.e. after commit)
 91 |  */
 92 | export class StorageBackedEventsBus implements EventsBus {
 93 |   private listeners = new Set<ListenerEntry>();
 94 | 
 95 |   constructor(private readonly store: EventsStore) {}
 96 | 
 97 |   subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe {
 98 |     const entry: ListenerEntry = { listener, filter };
 99 |     this.listeners.add(entry);
100 |     return () => {
101 |       this.listeners.delete(entry);
102 |     };
103 |   }
104 | 
105 |   async append(input: RunEventInput): Promise<RunEvent> {
106 |     // Delegate to storage for atomic seq allocation
107 |     const event = await this.store.append(input);
108 | 
109 |     // Broadcast after successful commit
110 |     this.broadcast(event);
111 | 
112 |     return event;
113 |   }
114 | 
115 |   async list(query: EventsQuery): Promise<RunEvent[]> {
116 |     return this.store.list(query.runId, {
117 |       fromSeq: query.fromSeq,
118 |       limit: query.limit,
119 |     });
120 |   }
121 | 
122 |   /**
123 |    * Broadcast event to all matching listeners
124 |    */
125 |   private broadcast(event: RunEvent): void {
126 |     const { runId } = event;
127 |     for (const { listener, filter } of this.listeners) {
128 |       if (!filter || !filter.runId || filter.runId === runId) {
129 |         try {
130 |           listener(event);
131 |         } catch (error) {
132 |           console.error('[StorageBackedEventsBus] Listener error:', error);
133 |         }
134 |       }
135 |     }
136 |   }
137 | }
138 | 
139 | /**
140 |  * In-memory EventsBus for testing
141 |  * @description Uses internal seq counter, NOT suitable for production
142 |  * @deprecated Use StorageBackedEventsBus with mock EventsStore for testing
143 |  */
144 | export class InMemoryEventsBus implements EventsBus {
145 |   private events = new Map<RunId, RunEvent[]>();
146 |   private seqCounters = new Map<RunId, number>();
147 |   private listeners = new Set<ListenerEntry>();
148 | 
149 |   subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe {
150 |     const entry: ListenerEntry = { listener, filter };
151 |     this.listeners.add(entry);
152 |     return () => {
153 |       this.listeners.delete(entry);
154 |     };
155 |   }
156 | 
157 |   async append(input: RunEventInput): Promise<RunEvent> {
158 |     const { runId } = input;
159 | 
160 |     // Allocate seq (NOT atomic, for testing only)
161 |     const currentSeq = this.seqCounters.get(runId) ?? 0;
162 |     const seq = currentSeq + 1;
163 |     this.seqCounters.set(runId, seq);
164 | 
165 |     // Create complete event
166 |     const event: RunEvent = {
167 |       ...input,
168 |       seq,
169 |       ts: input.ts ?? Date.now(),
170 |     } as RunEvent;
171 | 
172 |     // Store
173 |     const runEvents = this.events.get(runId) ?? [];
174 |     runEvents.push(event);
175 |     this.events.set(runId, runEvents);
176 | 
177 |     // Broadcast
178 |     for (const { listener, filter } of this.listeners) {
179 |       if (!filter || !filter.runId || filter.runId === runId) {
180 |         try {
181 |           listener(event);
182 |         } catch (error) {
183 |           console.error('[InMemoryEventsBus] Listener error:', error);
184 |         }
185 |       }
186 |     }
187 | 
188 |     return event;
189 |   }
190 | 
191 |   async list(query: EventsQuery): Promise<RunEvent[]> {
192 |     const runEvents = this.events.get(query.runId) ?? [];
193 | 
194 |     let result = runEvents;
195 | 
196 |     if (query.fromSeq !== undefined) {
197 |       result = result.filter((e) => e.seq >= query.fromSeq!);
198 |     }
199 | 
200 |     if (query.limit !== undefined) {
201 |       result = result.slice(0, query.limit);
202 |     }
203 | 
204 |     return result;
205 |   }
206 | 
207 |   /**
208 |    * Clear all data (for testing)
209 |    */
210 |   clear(): void {
211 |     this.events.clear();
212 |     this.seqCounters.clear();
213 |     this.listeners.clear();
214 |   }
215 | 
216 |   /**
217 |    * Get current seq for a run (for testing)
218 |    */
219 |   getSeq(runId: RunId): number {
220 |     return this.seqCounters.get(runId) ?? 0;
221 |   }
222 | }
223 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/base-browser.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { ToolExecutor } from '@/common/tool-handler';
  2 | import type { ToolResult } from '@/common/tool-handler';
  3 | import { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants';
  4 | 
  5 | const PING_TIMEOUT_MS = 300;
  6 | 
  7 | /**
  8 |  * Base class for browser tool executors
  9 |  */
 10 | export abstract class BaseBrowserToolExecutor implements ToolExecutor {
 11 |   abstract name: string;
 12 |   abstract execute(args: any): Promise<ToolResult>;
 13 | 
 14 |   /**
 15 |    * Inject content script into tab
 16 |    */
 17 |   protected async injectContentScript(
 18 |     tabId: number,
 19 |     files: string[],
 20 |     injectImmediately = false,
 21 |     world: 'MAIN' | 'ISOLATED' = 'ISOLATED',
 22 |     allFrames: boolean = false,
 23 |     frameIds?: number[],
 24 |   ): Promise<void> {
 25 |     console.log(`Injecting ${files.join(', ')} into tab ${tabId}`);
 26 | 
 27 |     // check if script is already injected
 28 |     try {
 29 |       const pingFrameId = frameIds?.[0];
 30 |       const response = await Promise.race([
 31 |         typeof pingFrameId === 'number'
 32 |           ? chrome.tabs.sendMessage(
 33 |               tabId,
 34 |               { action: `${this.name}_ping` },
 35 |               { frameId: pingFrameId },
 36 |             )
 37 |           : chrome.tabs.sendMessage(tabId, { action: `${this.name}_ping` }),
 38 |         new Promise((_, reject) =>
 39 |           setTimeout(
 40 |             () => reject(new Error(`${this.name} Ping action to tab ${tabId} timed out`)),
 41 |             PING_TIMEOUT_MS,
 42 |           ),
 43 |         ),
 44 |       ]);
 45 | 
 46 |       if (response && response.status === 'pong') {
 47 |         console.log(
 48 |           `pong received for action '${this.name}' in tab ${tabId}. Assuming script is active.`,
 49 |         );
 50 |         return;
 51 |       } else {
 52 |         console.warn(`Unexpected ping response in tab ${tabId}:`, response);
 53 |       }
 54 |     } catch (error) {
 55 |       console.error(
 56 |         `ping content script failed: ${error instanceof Error ? error.message : String(error)}`,
 57 |       );
 58 |     }
 59 | 
 60 |     try {
 61 |       const target: { tabId: number; allFrames?: boolean; frameIds?: number[] } = { tabId };
 62 |       if (frameIds && frameIds.length > 0) {
 63 |         target.frameIds = frameIds;
 64 |       } else if (allFrames) {
 65 |         target.allFrames = true;
 66 |       }
 67 |       await chrome.scripting.executeScript({
 68 |         target,
 69 |         files,
 70 |         injectImmediately,
 71 |         world,
 72 |       } as any);
 73 |       console.log(`'${files.join(', ')}' injection successful for tab ${tabId}`);
 74 |     } catch (injectionError) {
 75 |       const errorMessage =
 76 |         injectionError instanceof Error ? injectionError.message : String(injectionError);
 77 |       console.error(
 78 |         `Content script '${files.join(', ')}' injection failed for tab ${tabId}: ${errorMessage}`,
 79 |       );
 80 |       throw new Error(
 81 |         `${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Failed to inject content script in tab ${tabId}: ${errorMessage}`,
 82 |       );
 83 |     }
 84 |   }
 85 | 
 86 |   /**
 87 |    * Send message to tab
 88 |    */
 89 |   protected async sendMessageToTab(tabId: number, message: any, frameId?: number): Promise<any> {
 90 |     try {
 91 |       const response =
 92 |         typeof frameId === 'number'
 93 |           ? await chrome.tabs.sendMessage(tabId, message, { frameId })
 94 |           : await chrome.tabs.sendMessage(tabId, message);
 95 | 
 96 |       if (response && response.error) {
 97 |         throw new Error(String(response.error));
 98 |       }
 99 | 
100 |       return response;
101 |     } catch (error) {
102 |       const errorMessage = error instanceof Error ? error.message : String(error);
103 |       console.error(
104 |         `Error sending message to tab ${tabId} for action ${message?.action || 'unknown'}: ${errorMessage}`,
105 |       );
106 | 
107 |       if (error instanceof Error) {
108 |         throw error;
109 |       }
110 |       throw new Error(errorMessage);
111 |     }
112 |   }
113 | 
114 |   /**
115 |    * Try to get an existing tab by id. Returns null when not found.
116 |    */
117 |   protected async tryGetTab(tabId?: number): Promise<chrome.tabs.Tab | null> {
118 |     if (typeof tabId !== 'number') return null;
119 |     try {
120 |       return await chrome.tabs.get(tabId);
121 |     } catch {
122 |       return null;
123 |     }
124 |   }
125 | 
126 |   /**
127 |    * Get the active tab in the current window. Throws when not found.
128 |    */
129 |   protected async getActiveTabOrThrow(): Promise<chrome.tabs.Tab> {
130 |     const [active] = await chrome.tabs.query({ active: true, currentWindow: true });
131 |     if (!active || !active.id) throw new Error('Active tab not found');
132 |     return active;
133 |   }
134 | 
135 |   /**
136 |    * Optionally focus window and/or activate tab. Defaults preserve current behavior
137 |    * when caller sets activate/focus flags explicitly.
138 |    */
139 |   protected async ensureFocus(
140 |     tab: chrome.tabs.Tab,
141 |     options: { activate?: boolean; focusWindow?: boolean } = {},
142 |   ): Promise<void> {
143 |     const activate = options.activate === true;
144 |     const focusWindow = options.focusWindow === true;
145 |     if (focusWindow && typeof tab.windowId === 'number') {
146 |       await chrome.windows.update(tab.windowId, { focused: true });
147 |     }
148 |     if (activate && typeof tab.id === 'number') {
149 |       await chrome.tabs.update(tab.id, { active: true });
150 |     }
151 |   }
152 | 
153 |   /**
154 |    * Get the active tab. When windowId provided, search within that window; otherwise currentWindow.
155 |    */
156 |   protected async getActiveTabInWindow(windowId?: number): Promise<chrome.tabs.Tab | null> {
157 |     if (typeof windowId === 'number') {
158 |       const tabs = await chrome.tabs.query({ active: true, windowId });
159 |       return tabs && tabs[0] ? tabs[0] : null;
160 |     }
161 |     const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
162 |     return tabs && tabs[0] ? tabs[0] : null;
163 |   }
164 | 
165 |   /**
166 |    * Same as getActiveTabInWindow, but throws if not found.
167 |    */
168 |   protected async getActiveTabOrThrowInWindow(windowId?: number): Promise<chrome.tabs.Tab> {
169 |     const tab = await this.getActiveTabInWindow(windowId);
170 |     if (!tab || !tab.id) throw new Error('Active tab not found');
171 |     return tab;
172 |   }
173 | }
174 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/storage/indexeddb-manager.ts:
--------------------------------------------------------------------------------

```typescript
  1 | // indexeddb-manager.ts
  2 | // IndexedDB storage manager for Record & Replay data.
  3 | // Stores: flows, runs, published, schedules, triggers.
  4 | 
  5 | import type { Flow, RunRecord } from '../types';
  6 | import type { FlowSchedule } from '../flow-store';
  7 | import type { PublishedFlowInfo } from '../flow-store';
  8 | import type { FlowTrigger } from '../trigger-store';
  9 | import { IndexedDbClient } from '@/utils/indexeddb-client';
 10 | 
 11 | type StoreName = 'flows' | 'runs' | 'published' | 'schedules' | 'triggers';
 12 | 
 13 | const DB_NAME = 'rr_storage';
 14 | // Version history:
 15 | // v1: Initial schema with flows, runs, published, schedules, triggers stores
 16 | // v2: (Previous iteration - no schema change, version was bumped during development)
 17 | // v3: Current - ensure all stores exist, support upgrade from any previous version
 18 | const DB_VERSION = 3;
 19 | 
 20 | const REQUIRED_STORES = ['flows', 'runs', 'published', 'schedules', 'triggers'] as const;
 21 | 
 22 | const idb = new IndexedDbClient(DB_NAME, DB_VERSION, (db, oldVersion) => {
 23 |   // Idempotent upgrade: ensure all required stores exist regardless of oldVersion
 24 |   // This handles both fresh installs (oldVersion=0) and upgrades from any version
 25 |   for (const storeName of REQUIRED_STORES) {
 26 |     if (!db.objectStoreNames.contains(storeName)) {
 27 |       db.createObjectStore(storeName, { keyPath: 'id' });
 28 |     }
 29 |   }
 30 | });
 31 | 
 32 | const tx = <T>(
 33 |   store: StoreName,
 34 |   mode: IDBTransactionMode,
 35 |   op: (s: IDBObjectStore, t: IDBTransaction) => T | Promise<T>,
 36 | ) => idb.tx<T>(store, mode, op);
 37 | 
 38 | async function getAll<T>(store: StoreName): Promise<T[]> {
 39 |   return idb.getAll<T>(store);
 40 | }
 41 | 
 42 | async function getOne<T>(store: StoreName, key: string): Promise<T | undefined> {
 43 |   return idb.get<T>(store, key);
 44 | }
 45 | 
 46 | async function putOne<T>(store: StoreName, value: T): Promise<void> {
 47 |   return idb.put(store, value);
 48 | }
 49 | 
 50 | async function deleteOne(store: StoreName, key: string): Promise<void> {
 51 |   return idb.delete(store, key);
 52 | }
 53 | 
 54 | async function clearStore(store: StoreName): Promise<void> {
 55 |   return idb.clear(store);
 56 | }
 57 | 
 58 | async function putMany<T>(storeName: StoreName, values: T[]): Promise<void> {
 59 |   return idb.putMany(storeName, values);
 60 | }
 61 | 
 62 | export const IndexedDbStorage = {
 63 |   flows: {
 64 |     async list(): Promise<Flow[]> {
 65 |       return getAll<Flow>('flows');
 66 |     },
 67 |     async get(id: string): Promise<Flow | undefined> {
 68 |       return getOne<Flow>('flows', id);
 69 |     },
 70 |     async save(flow: Flow): Promise<void> {
 71 |       return putOne<Flow>('flows', flow);
 72 |     },
 73 |     async delete(id: string): Promise<void> {
 74 |       return deleteOne('flows', id);
 75 |     },
 76 |   },
 77 |   runs: {
 78 |     async list(): Promise<RunRecord[]> {
 79 |       return getAll<RunRecord>('runs');
 80 |     },
 81 |     async save(record: RunRecord): Promise<void> {
 82 |       return putOne<RunRecord>('runs', record);
 83 |     },
 84 |     async replaceAll(records: RunRecord[]): Promise<void> {
 85 |       return tx<void>('runs', 'readwrite', async (st) => {
 86 |         st.clear();
 87 |         for (const r of records) st.put(r);
 88 |         return;
 89 |       });
 90 |     },
 91 |   },
 92 |   published: {
 93 |     async list(): Promise<PublishedFlowInfo[]> {
 94 |       return getAll<PublishedFlowInfo>('published');
 95 |     },
 96 |     async save(info: PublishedFlowInfo): Promise<void> {
 97 |       return putOne<PublishedFlowInfo>('published', info);
 98 |     },
 99 |     async delete(id: string): Promise<void> {
100 |       return deleteOne('published', id);
101 |     },
102 |   },
103 |   schedules: {
104 |     async list(): Promise<FlowSchedule[]> {
105 |       return getAll<FlowSchedule>('schedules');
106 |     },
107 |     async save(s: FlowSchedule): Promise<void> {
108 |       return putOne<FlowSchedule>('schedules', s);
109 |     },
110 |     async delete(id: string): Promise<void> {
111 |       return deleteOne('schedules', id);
112 |     },
113 |   },
114 |   triggers: {
115 |     async list(): Promise<FlowTrigger[]> {
116 |       return getAll<FlowTrigger>('triggers');
117 |     },
118 |     async save(t: FlowTrigger): Promise<void> {
119 |       return putOne<FlowTrigger>('triggers', t);
120 |     },
121 |     async delete(id: string): Promise<void> {
122 |       return deleteOne('triggers', id);
123 |     },
124 |   },
125 | };
126 | 
127 | // One-time migration from chrome.storage.local to IndexedDB
128 | let migrationPromise: Promise<void> | null = null;
129 | let migrationFailed = false;
130 | 
131 | export async function ensureMigratedFromLocal(): Promise<void> {
132 |   // If previous migration failed, allow retry
133 |   if (migrationFailed) {
134 |     migrationPromise = null;
135 |     migrationFailed = false;
136 |   }
137 |   if (migrationPromise) return migrationPromise;
138 | 
139 |   migrationPromise = (async () => {
140 |     try {
141 |       const flag = await chrome.storage.local.get(['rr_idb_migrated']);
142 |       if (flag && flag['rr_idb_migrated']) return;
143 | 
144 |       // Read existing data from chrome.storage.local
145 |       const res = await chrome.storage.local.get([
146 |         'rr_flows',
147 |         'rr_runs',
148 |         'rr_published_flows',
149 |         'rr_schedules',
150 |         'rr_triggers',
151 |       ]);
152 |       const flows = (res['rr_flows'] as Flow[]) || [];
153 |       const runs = (res['rr_runs'] as RunRecord[]) || [];
154 |       const published = (res['rr_published_flows'] as PublishedFlowInfo[]) || [];
155 |       const schedules = (res['rr_schedules'] as FlowSchedule[]) || [];
156 |       const triggers = (res['rr_triggers'] as FlowTrigger[]) || [];
157 | 
158 |       // Write into IDB
159 |       if (flows.length) await putMany('flows', flows);
160 |       if (runs.length) await putMany('runs', runs);
161 |       if (published.length) await putMany('published', published);
162 |       if (schedules.length) await putMany('schedules', schedules);
163 |       if (triggers.length) await putMany('triggers', triggers);
164 | 
165 |       await chrome.storage.local.set({ rr_idb_migrated: true });
166 |     } catch (e) {
167 |       migrationFailed = true;
168 |       console.error('IndexedDbStorage migration failed:', e);
169 |       // Re-throw to let callers know migration failed
170 |       throw e;
171 |     }
172 |   })();
173 |   return migrationPromise;
174 | }
175 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/wxt.config.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { defineConfig } from 'wxt';
  2 | import tailwindcss from '@tailwindcss/vite';
  3 | import { viteStaticCopy } from 'vite-plugin-static-copy';
  4 | import { config } from 'dotenv';
  5 | import { resolve } from 'path';
  6 | import Icons from 'unplugin-icons/vite';
  7 | import Components from 'unplugin-vue-components/vite';
  8 | import IconsResolver from 'unplugin-icons/resolver';
  9 | 
 10 | config({ path: resolve(process.cwd(), '.env') });
 11 | config({ path: resolve(process.cwd(), '.env.local') });
 12 | 
 13 | const CHROME_EXTENSION_KEY = process.env.CHROME_EXTENSION_KEY;
 14 | // Detect dev mode early for manifest-level switches
 15 | const IS_DEV = process.env.NODE_ENV !== 'production' && process.env.MODE !== 'production';
 16 | 
 17 | // See https://wxt.dev/api/config.html
 18 | export default defineConfig({
 19 |   modules: ['@wxt-dev/module-vue'],
 20 |   runner: {
 21 |     // 方案1: 禁用自动启动(推荐)
 22 |     disabled: true,
 23 | 
 24 |     // 方案2: 如果要启用自动启动并使用现有配置,取消注释下面的配置
 25 |     // chromiumArgs: [
 26 |     //   '--user-data-dir=' + homedir() + (process.platform === 'darwin'
 27 |     //     ? '/Library/Application Support/Google/Chrome'
 28 |     //     : process.platform === 'win32'
 29 |     //     ? '/AppData/Local/Google/Chrome/User Data'
 30 |     //     : '/.config/google-chrome'),
 31 |     //   '--remote-debugging-port=9222',
 32 |     // ],
 33 |   },
 34 |   manifest: {
 35 |     // Use environment variable for the key, fallback to undefined if not set
 36 |     key: CHROME_EXTENSION_KEY,
 37 |     default_locale: 'zh_CN',
 38 |     name: '__MSG_extensionName__',
 39 |     description: '__MSG_extensionDescription__',
 40 |     permissions: [
 41 |       'nativeMessaging',
 42 |       'tabs',
 43 |       'activeTab',
 44 |       'scripting',
 45 |       'contextMenus',
 46 |       'downloads',
 47 |       'webRequest',
 48 |       'webNavigation',
 49 |       'debugger',
 50 |       'history',
 51 |       'bookmarks',
 52 |       'offscreen',
 53 |       'storage',
 54 |       'declarativeNetRequest',
 55 |       'alarms',
 56 |       // Allow programmatic control of Chrome Side Panel
 57 |       'sidePanel',
 58 |     ],
 59 |     host_permissions: ['<all_urls>'],
 60 |     options_ui: {
 61 |       page: 'options.html',
 62 |       open_in_tab: true,
 63 |     },
 64 |     action: {
 65 |       default_popup: 'popup.html',
 66 |       default_title: 'Chrome MCP Server',
 67 |     },
 68 |     // Chrome Side Panel entry for workflow management
 69 |     // Ref: https://developer.chrome.com/docs/extensions/reference/api/sidePanel
 70 |     side_panel: {
 71 |       default_path: 'sidepanel.html',
 72 |     },
 73 |     // Keyboard shortcuts for quick triggers
 74 |     commands: {
 75 |       // run_quick_trigger_1: {
 76 |       //   suggested_key: { default: 'Ctrl+Shift+1' },
 77 |       //   description: 'Run quick trigger 1',
 78 |       // },
 79 |       // run_quick_trigger_2: {
 80 |       //   suggested_key: { default: 'Ctrl+Shift+2' },
 81 |       //   description: 'Run quick trigger 2',
 82 |       // },
 83 |       // run_quick_trigger_3: {
 84 |       //   suggested_key: { default: 'Ctrl+Shift+3' },
 85 |       //   description: 'Run quick trigger 3',
 86 |       // },
 87 |       // open_workflow_sidepanel: {
 88 |       //   suggested_key: { default: 'Ctrl+Shift+O' },
 89 |       //   description: 'Open workflow sidepanel',
 90 |       // },
 91 |       toggle_web_editor: {
 92 |         suggested_key: { default: 'Ctrl+Shift+O', mac: 'Command+Shift+O' },
 93 |         description: 'Toggle Web Editor mode',
 94 |       },
 95 |       toggle_quick_panel: {
 96 |         suggested_key: { default: 'Ctrl+Shift+U', mac: 'Command+Shift+U' },
 97 |         description: 'Toggle Quick Panel AI Chat',
 98 |       },
 99 |     },
100 |     web_accessible_resources: [
101 |       {
102 |         resources: [
103 |           '/models/*', // 允许访问 public/models/ 下的所有文件
104 |           '/workers/*', // 允许访问 workers 文件
105 |           '/inject-scripts/*', // 允许内容脚本注入的助手文件
106 |         ],
107 |         matches: ['<all_urls>'],
108 |       },
109 |     ],
110 |     // 注意:以下安全策略在开发环境会阻断 dev server 的资源加载,
111 |     // 只在生产环境启用,开发环境交由 WXT 默认策略处理。
112 |     ...(IS_DEV
113 |       ? {}
114 |       : {
115 |           cross_origin_embedder_policy: { value: 'require-corp' as const },
116 |           cross_origin_opener_policy: { value: 'same-origin' as const },
117 |           content_security_policy: {
118 |             // Allow inline styles injected by Vite (compiled CSS) and data images used in UI thumbnails
119 |             extension_pages:
120 |               "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:;",
121 |           },
122 |         }),
123 |   },
124 |   vite: (env) => ({
125 |     plugins: [
126 |       // TailwindCSS v4 Vite plugin – no PostCSS config required
127 |       tailwindcss(),
128 |       // Auto-register SVG icons as Vue components; all icons are bundled locally
129 |       Components({
130 |         dts: false,
131 |         resolvers: [IconsResolver({ prefix: 'i', enabledCollections: ['lucide', 'mdi', 'ri'] })],
132 |       }) as any,
133 |       Icons({ compiler: 'vue3', autoInstall: false }) as any,
134 |       // Ensure static assets are available as early as possible to avoid race conditions in dev
135 |       // Copy workers/_locales/inject-scripts into the build output before other steps
136 |       viteStaticCopy({
137 |         targets: [
138 |           {
139 |             src: 'inject-scripts/*.js',
140 |             dest: 'inject-scripts',
141 |           },
142 |           {
143 |             src: ['workers/*'],
144 |             dest: 'workers',
145 |           },
146 |           {
147 |             src: '_locales/**/*',
148 |             dest: '_locales',
149 |           },
150 |         ],
151 |         // Use writeBundle so outDir exists for dev and prod
152 |         hook: 'writeBundle',
153 |         // Enable watch so changes to these files are reflected during dev
154 |         watch: {
155 |           // Use default patterns inferred from targets; explicit true enables watching
156 |           // Vite plugin will watch src patterns and re-copy on change
157 |         } as any,
158 |       }) as any,
159 |     ],
160 |     build: {
161 |       // 我们的构建产物需要兼容到es6
162 |       target: 'es2015',
163 |       // 非生产环境下生成sourcemap
164 |       sourcemap: env.mode !== 'production',
165 |       // 禁用gzip 压缩大小报告,因为压缩大型文件可能会很慢
166 |       reportCompressedSize: false,
167 |       // chunk大小超过1500kb是触发警告
168 |       chunkSizeWarningLimit: 1500,
169 |       minify: false,
170 |     },
171 |   }),
172 | });
173 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Click and Double-Click Action Handlers
  3 |  *
  4 |  * Handles click interactions:
  5 |  * - Single click
  6 |  * - Double click
  7 |  * - Post-click navigation/network wait
  8 |  * - Selector fallback with logging
  9 |  */
 10 | 
 11 | import { handleCallTool } from '@/entrypoints/background/tools';
 12 | import { TOOL_NAMES } from 'chrome-mcp-shared';
 13 | import { ENGINE_CONSTANTS } from '../../engine/constants';
 14 | import {
 15 |   maybeQuickWaitForNav,
 16 |   waitForNavigationDone,
 17 |   waitForNetworkIdle,
 18 | } from '../../engine/policies/wait';
 19 | import { failed, invalid, ok } from '../registry';
 20 | import type {
 21 |   Action,
 22 |   ActionExecutionContext,
 23 |   ActionExecutionResult,
 24 |   ActionHandler,
 25 | } from '../types';
 26 | import {
 27 |   clampInt,
 28 |   ensureElementVisible,
 29 |   logSelectorFallback,
 30 |   readTabUrl,
 31 |   selectorLocator,
 32 |   toSelectorTarget,
 33 | } from './common';
 34 | 
 35 | /**
 36 |  * Shared click execution logic for both click and dblclick
 37 |  */
 38 | async function executeClick<T extends 'click' | 'dblclick'>(
 39 |   ctx: ActionExecutionContext,
 40 |   action: Action<T>,
 41 | ): Promise<ActionExecutionResult<T>> {
 42 |   const vars = ctx.vars;
 43 |   const tabId = ctx.tabId;
 44 |   // Check if StepRunner owns nav-wait (skip internal nav-wait logic)
 45 |   const skipNavWait = ctx.execution?.skipNavWait === true;
 46 | 
 47 |   if (typeof tabId !== 'number') {
 48 |     return failed('TAB_NOT_FOUND', 'No active tab found');
 49 |   }
 50 | 
 51 |   // Ensure page is read before locating element
 52 |   await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
 53 | 
 54 |   // Only read beforeUrl if we need to do nav-wait
 55 |   const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId);
 56 |   const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(
 57 |     action.params.target,
 58 |     vars,
 59 |   );
 60 | 
 61 |   // Locate element using shared selector locator
 62 |   const located = await selectorLocator.locate(tabId, selectorTarget, {
 63 |     frameId: ctx.frameId,
 64 |     preferRef: false,
 65 |   });
 66 | 
 67 |   const frameId = located?.frameId ?? ctx.frameId;
 68 |   const refToUse = located?.ref ?? selectorTarget.ref;
 69 |   const selectorToUse = !located?.ref ? firstCssOrAttr : undefined;
 70 | 
 71 |   if (!refToUse && !selectorToUse) {
 72 |     return failed('TARGET_NOT_FOUND', 'Could not locate target element');
 73 |   }
 74 | 
 75 |   // Verify element visibility if we have a ref
 76 |   if (located?.ref) {
 77 |     const isVisible = await ensureElementVisible(tabId, located.ref, frameId);
 78 |     if (!isVisible) {
 79 |       return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');
 80 |     }
 81 |   }
 82 | 
 83 |   // Execute click with tool timeout
 84 |   const toolTimeout = clampInt(action.policy?.timeout?.ms ?? 10000, 1000, 30000);
 85 | 
 86 |   const clickResult = await handleCallTool({
 87 |     name: TOOL_NAMES.BROWSER.CLICK,
 88 |     args: {
 89 |       ref: refToUse,
 90 |       selector: selectorToUse,
 91 |       waitForNavigation: false,
 92 |       timeout: toolTimeout,
 93 |       frameId,
 94 |       tabId,
 95 |       double: action.type === 'dblclick',
 96 |     },
 97 |   });
 98 | 
 99 |   if ((clickResult as { isError?: boolean })?.isError) {
100 |     const errorContent = (clickResult as { content?: Array<{ text?: string }> })?.content;
101 |     const errorMsg = errorContent?.[0]?.text || `${action.type} action failed`;
102 |     return failed('UNKNOWN', errorMsg);
103 |   }
104 | 
105 |   // Log selector fallback if used
106 |   const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
107 |   const fallbackUsed =
108 |     resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
109 | 
110 |   if (fallbackUsed) {
111 |     logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
112 |   }
113 | 
114 |   // Skip post-click wait if StepRunner handles it
115 |   if (skipNavWait) {
116 |     return { status: 'success' };
117 |   }
118 | 
119 |   // Post-click wait handling (only when handler owns nav-wait)
120 |   const waitMs = clampInt(
121 |     action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS,
122 |     0,
123 |     ENGINE_CONSTANTS.MAX_WAIT_MS,
124 |   );
125 |   const after = action.params.after ?? {};
126 | 
127 |   if (after.waitForNavigation) {
128 |     await waitForNavigationDone(beforeUrl, waitMs);
129 |   } else if (after.waitForNetworkIdle) {
130 |     const totalMs = clampInt(waitMs, 1000, ENGINE_CONSTANTS.MAX_WAIT_MS);
131 |     const idleMs = Math.min(1500, Math.max(500, Math.floor(totalMs / 3)));
132 |     await waitForNetworkIdle(totalMs, idleMs);
133 |   } else {
134 |     // Quick sniff for navigation that might have been triggered
135 |     await maybeQuickWaitForNav(beforeUrl, waitMs);
136 |   }
137 | 
138 |   return { status: 'success' };
139 | }
140 | 
141 | /**
142 |  * Validate click target configuration
143 |  */
144 | function validateClickTarget(target: {
145 |   ref?: string;
146 |   candidates?: unknown[];
147 | }): { ok: true } | { ok: false; errors: [string, ...string[]] } {
148 |   const hasRef = typeof target?.ref === 'string' && target.ref.trim().length > 0;
149 |   const hasCandidates = Array.isArray(target?.candidates) && target.candidates.length > 0;
150 | 
151 |   if (hasRef || hasCandidates) {
152 |     return ok();
153 |   }
154 |   return invalid('Missing target selector or ref');
155 | }
156 | 
157 | export const clickHandler: ActionHandler<'click'> = {
158 |   type: 'click',
159 | 
160 |   validate: (action) =>
161 |     validateClickTarget(action.params.target as { ref?: string; candidates?: unknown[] }),
162 | 
163 |   describe: (action) => {
164 |     const target = action.params.target;
165 |     if (typeof (target as { ref?: string }).ref === 'string') {
166 |       return `Click element ${(target as { ref: string }).ref}`;
167 |     }
168 |     return 'Click element';
169 |   },
170 | 
171 |   run: async (ctx, action) => {
172 |     return await executeClick(ctx, action);
173 |   },
174 | };
175 | 
176 | export const dblclickHandler: ActionHandler<'dblclick'> = {
177 |   type: 'dblclick',
178 | 
179 |   validate: (action) =>
180 |     validateClickTarget(action.params.target as { ref?: string; candidates?: unknown[] }),
181 | 
182 |   describe: (action) => {
183 |     const target = action.params.target;
184 |     if (typeof (target as { ref?: string }).ref === 'string') {
185 |       return `Double-click element ${(target as { ref: string }).ref}`;
186 |     }
187 |     return 'Double-click element';
188 |   },
189 | 
190 |   run: async (ctx, action) => {
191 |     return await executeClick(ctx, action);
192 |   },
193 | };
194 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/storage/db.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview V3 IndexedDB 数据库定义
  3 |  * @description 定义 rr_v3 数据库的 schema 和初始化逻辑
  4 |  */
  5 | 
  6 | /** 数据库名称 */
  7 | export const RR_V3_DB_NAME = 'rr_v3';
  8 | 
  9 | /** 数据库版本 */
 10 | export const RR_V3_DB_VERSION = 1;
 11 | 
 12 | /**
 13 |  * Store 名称常量
 14 |  */
 15 | export const RR_V3_STORES = {
 16 |   FLOWS: 'flows',
 17 |   RUNS: 'runs',
 18 |   EVENTS: 'events',
 19 |   QUEUE: 'queue',
 20 |   PERSISTENT_VARS: 'persistent_vars',
 21 |   TRIGGERS: 'triggers',
 22 | } as const;
 23 | 
 24 | /**
 25 |  * Store 配置
 26 |  */
 27 | export interface StoreConfig {
 28 |   keyPath: string | string[];
 29 |   autoIncrement?: boolean;
 30 |   indexes?: Array<{
 31 |     name: string;
 32 |     keyPath: string | string[];
 33 |     options?: IDBIndexParameters;
 34 |   }>;
 35 | }
 36 | 
 37 | /**
 38 |  * V3 Store Schema 定义
 39 |  * @description 包含 Phase 1-3 所需的所有索引,避免后续升级
 40 |  */
 41 | export const RR_V3_STORE_SCHEMAS: Record<string, StoreConfig> = {
 42 |   [RR_V3_STORES.FLOWS]: {
 43 |     keyPath: 'id',
 44 |     indexes: [
 45 |       { name: 'name', keyPath: 'name' },
 46 |       { name: 'updatedAt', keyPath: 'updatedAt' },
 47 |     ],
 48 |   },
 49 |   [RR_V3_STORES.RUNS]: {
 50 |     keyPath: 'id',
 51 |     indexes: [
 52 |       { name: 'status', keyPath: 'status' },
 53 |       { name: 'flowId', keyPath: 'flowId' },
 54 |       { name: 'createdAt', keyPath: 'createdAt' },
 55 |       { name: 'updatedAt', keyPath: 'updatedAt' },
 56 |       // Compound index for listing runs by flow and status
 57 |       { name: 'flowId_status', keyPath: ['flowId', 'status'] },
 58 |     ],
 59 |   },
 60 |   [RR_V3_STORES.EVENTS]: {
 61 |     keyPath: ['runId', 'seq'],
 62 |     indexes: [
 63 |       { name: 'runId', keyPath: 'runId' },
 64 |       { name: 'type', keyPath: 'type' },
 65 |       // Compound index for filtering events by run and type
 66 |       { name: 'runId_type', keyPath: ['runId', 'type'] },
 67 |     ],
 68 |   },
 69 |   [RR_V3_STORES.QUEUE]: {
 70 |     keyPath: 'id',
 71 |     indexes: [
 72 |       { name: 'status', keyPath: 'status' },
 73 |       { name: 'priority', keyPath: 'priority' },
 74 |       { name: 'createdAt', keyPath: 'createdAt' },
 75 |       { name: 'flowId', keyPath: 'flowId' },
 76 |       // Phase 3: Used by claimNext(); cursor direction + key ranges implement priority DESC + createdAt ASC.
 77 |       { name: 'status_priority_createdAt', keyPath: ['status', 'priority', 'createdAt'] },
 78 |       // Phase 3: Lease expiration tracking
 79 |       { name: 'lease_expiresAt', keyPath: 'lease.expiresAt' },
 80 |     ],
 81 |   },
 82 |   [RR_V3_STORES.PERSISTENT_VARS]: {
 83 |     keyPath: 'key',
 84 |     indexes: [{ name: 'updatedAt', keyPath: 'updatedAt' }],
 85 |   },
 86 |   [RR_V3_STORES.TRIGGERS]: {
 87 |     keyPath: 'id',
 88 |     indexes: [
 89 |       { name: 'kind', keyPath: 'kind' },
 90 |       { name: 'flowId', keyPath: 'flowId' },
 91 |       { name: 'enabled', keyPath: 'enabled' },
 92 |       // Compound index for listing enabled triggers by kind
 93 |       { name: 'kind_enabled', keyPath: ['kind', 'enabled'] },
 94 |     ],
 95 |   },
 96 | };
 97 | 
 98 | /**
 99 |  * 数据库升级处理器
100 |  */
101 | export function handleUpgrade(db: IDBDatabase, oldVersion: number, _newVersion: number): void {
102 |   // Version 0 -> 1: 创建所有 stores
103 |   if (oldVersion < 1) {
104 |     for (const [storeName, config] of Object.entries(RR_V3_STORE_SCHEMAS)) {
105 |       const store = db.createObjectStore(storeName, {
106 |         keyPath: config.keyPath,
107 |         autoIncrement: config.autoIncrement,
108 |       });
109 | 
110 |       // 创建索引
111 |       if (config.indexes) {
112 |         for (const index of config.indexes) {
113 |           store.createIndex(index.name, index.keyPath, index.options);
114 |         }
115 |       }
116 |     }
117 |   }
118 | }
119 | 
120 | /** 全局数据库实例 */
121 | let dbInstance: IDBDatabase | null = null;
122 | let dbPromise: Promise<IDBDatabase> | null = null;
123 | 
124 | /**
125 |  * 打开 V3 数据库
126 |  * @description 单例模式,确保只有一个数据库连接
127 |  */
128 | export async function openRrV3Db(): Promise<IDBDatabase> {
129 |   if (dbInstance) {
130 |     return dbInstance;
131 |   }
132 | 
133 |   if (dbPromise) {
134 |     return dbPromise;
135 |   }
136 | 
137 |   dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
138 |     const request = indexedDB.open(RR_V3_DB_NAME, RR_V3_DB_VERSION);
139 | 
140 |     request.onerror = () => {
141 |       dbPromise = null;
142 |       reject(new Error(`Failed to open database: ${request.error?.message}`));
143 |     };
144 | 
145 |     request.onsuccess = () => {
146 |       dbInstance = request.result;
147 | 
148 |       // 处理版本变更(其他 tab 升级了数据库)
149 |       dbInstance.onversionchange = () => {
150 |         dbInstance?.close();
151 |         dbInstance = null;
152 |         dbPromise = null;
153 |       };
154 | 
155 |       resolve(dbInstance);
156 |     };
157 | 
158 |     request.onupgradeneeded = (event) => {
159 |       const db = request.result;
160 |       const oldVersion = event.oldVersion;
161 |       const newVersion = event.newVersion ?? RR_V3_DB_VERSION;
162 |       handleUpgrade(db, oldVersion, newVersion);
163 |     };
164 |   });
165 | 
166 |   return dbPromise;
167 | }
168 | 
169 | /**
170 |  * 关闭数据库连接
171 |  * @description 主要用于测试
172 |  */
173 | export function closeRrV3Db(): void {
174 |   if (dbInstance) {
175 |     dbInstance.close();
176 |     dbInstance = null;
177 |     dbPromise = null;
178 |   }
179 | }
180 | 
181 | /**
182 |  * 删除数据库
183 |  * @description 主要用于测试
184 |  */
185 | export async function deleteRrV3Db(): Promise<void> {
186 |   closeRrV3Db();
187 | 
188 |   return new Promise((resolve, reject) => {
189 |     const request = indexedDB.deleteDatabase(RR_V3_DB_NAME);
190 |     request.onsuccess = () => resolve();
191 |     request.onerror = () => reject(request.error);
192 |   });
193 | }
194 | 
195 | /**
196 |  * 执行事务
197 |  * @param storeNames Store 名称(单个或多个)
198 |  * @param mode 事务模式
199 |  * @param callback 事务回调
200 |  */
201 | export async function withTransaction<T>(
202 |   storeNames: string | string[],
203 |   mode: IDBTransactionMode,
204 |   callback: (stores: Record<string, IDBObjectStore>) => Promise<T> | T,
205 | ): Promise<T> {
206 |   const db = await openRrV3Db();
207 |   const names = Array.isArray(storeNames) ? storeNames : [storeNames];
208 |   const tx = db.transaction(names, mode);
209 | 
210 |   const stores: Record<string, IDBObjectStore> = {};
211 |   for (const name of names) {
212 |     stores[name] = tx.objectStore(name);
213 |   }
214 | 
215 |   return new Promise<T>((resolve, reject) => {
216 |     let result: T;
217 | 
218 |     tx.oncomplete = () => resolve(result);
219 |     tx.onerror = () => reject(tx.error);
220 |     tx.onabort = () => reject(tx.error || new Error('Transaction aborted'));
221 | 
222 |     Promise.resolve(callback(stores))
223 |       .then((r) => {
224 |         result = r;
225 |       })
226 |       .catch((err) => {
227 |         tx.abort();
228 |         reject(err);
229 |       });
230 |   });
231 | }
232 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/shared/selector/stability.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Selector Stability - 选择器稳定性评估
  3 |  */
  4 | 
  5 | import type {
  6 |   SelectorCandidate,
  7 |   SelectorStability,
  8 |   SelectorStabilitySignals,
  9 |   SelectorType,
 10 | } from './types';
 11 | import { splitCompositeSelector } from './types';
 12 | 
 13 | const TESTID_ATTR_NAMES = [
 14 |   'data-testid',
 15 |   'data-test-id',
 16 |   'data-test',
 17 |   'data-qa',
 18 |   'data-cy',
 19 | ] as const;
 20 | 
 21 | function clamp01(n: number): number {
 22 |   if (!Number.isFinite(n)) return 0;
 23 |   return Math.min(1, Math.max(0, n));
 24 | }
 25 | 
 26 | function mergeSignals(
 27 |   a: SelectorStabilitySignals,
 28 |   b: SelectorStabilitySignals,
 29 | ): SelectorStabilitySignals {
 30 |   return {
 31 |     usesId: a.usesId || b.usesId || undefined,
 32 |     usesTestId: a.usesTestId || b.usesTestId || undefined,
 33 |     usesAria: a.usesAria || b.usesAria || undefined,
 34 |     usesText: a.usesText || b.usesText || undefined,
 35 |     usesNthOfType: a.usesNthOfType || b.usesNthOfType || undefined,
 36 |     usesAttributes: a.usesAttributes || b.usesAttributes || undefined,
 37 |     usesClass: a.usesClass || b.usesClass || undefined,
 38 |   };
 39 | }
 40 | 
 41 | function analyzeCssLike(selector: string): SelectorStabilitySignals {
 42 |   const s = String(selector || '');
 43 |   const usesNthOfType = /:nth-of-type\(/i.test(s);
 44 |   const usesAttributes = /\[[^\]]+\]/.test(s);
 45 |   const usesAria = /\[\s*aria-[^=]+\s*=|\[\s*role\s*=|\brole\s*=\s*/i.test(s);
 46 | 
 47 |   // Avoid counting `#` inside attribute values (e.g. href="#...") by requiring a token-ish pattern.
 48 |   const usesId = /(^|[\s>+~])#[^\s>+~.:#[]+/.test(s);
 49 |   const usesClass = /(^|[\s>+~])\.[^\s>+~.:#[]+/.test(s);
 50 | 
 51 |   const lower = s.toLowerCase();
 52 |   const usesTestId = TESTID_ATTR_NAMES.some((a) => lower.includes(`[${a}`));
 53 | 
 54 |   return {
 55 |     usesId: usesId || undefined,
 56 |     usesTestId: usesTestId || undefined,
 57 |     usesAria: usesAria || undefined,
 58 |     usesNthOfType: usesNthOfType || undefined,
 59 |     usesAttributes: usesAttributes || undefined,
 60 |     usesClass: usesClass || undefined,
 61 |   };
 62 | }
 63 | 
 64 | function baseScoreForCssSignals(signals: SelectorStabilitySignals): number {
 65 |   if (signals.usesTestId) return 0.95;
 66 |   if (signals.usesId) return 0.9;
 67 |   if (signals.usesAria) return 0.8;
 68 |   if (signals.usesAttributes) return 0.75;
 69 |   if (signals.usesClass) return 0.65;
 70 |   return 0.5;
 71 | }
 72 | 
 73 | function lengthPenalty(value: string): number {
 74 |   const len = value.length;
 75 |   if (len <= 60) return 0;
 76 |   if (len <= 120) return 0.05;
 77 |   if (len <= 200) return 0.1;
 78 |   return 0.18;
 79 | }
 80 | 
 81 | /**
 82 |  * 计算选择器稳定性评分
 83 |  */
 84 | export function computeSelectorStability(candidate: SelectorCandidate): SelectorStability {
 85 |   if (candidate.type === 'css' || candidate.type === 'attr') {
 86 |     const composite = splitCompositeSelector(candidate.value);
 87 |     if (composite) {
 88 |       const a = analyzeCssLike(composite.frameSelector);
 89 |       const b = analyzeCssLike(composite.innerSelector);
 90 |       const merged = mergeSignals(a, b);
 91 | 
 92 |       let score = baseScoreForCssSignals(merged);
 93 |       score -= 0.05; // iframe coupling penalty
 94 |       if (merged.usesNthOfType) score -= 0.2;
 95 |       score -= lengthPenalty(candidate.value);
 96 | 
 97 |       return { score: clamp01(score), signals: merged, note: 'composite' };
 98 |     }
 99 | 
100 |     const signals = analyzeCssLike(candidate.value);
101 |     let score = baseScoreForCssSignals(signals);
102 |     if (signals.usesNthOfType) score -= 0.2;
103 |     score -= lengthPenalty(candidate.value);
104 | 
105 |     return { score: clamp01(score), signals };
106 |   }
107 | 
108 |   if (candidate.type === 'xpath') {
109 |     const s = String(candidate.value || '');
110 |     const signals: SelectorStabilitySignals = {
111 |       usesAttributes: /@[\w-]+\s*=/.test(s) || undefined,
112 |       usesId: /@id\s*=/.test(s) || undefined,
113 |       usesTestId: /@data-testid\s*=/.test(s) || undefined,
114 |     };
115 | 
116 |     let score = 0.42;
117 |     if (signals.usesTestId) score = 0.85;
118 |     else if (signals.usesId) score = 0.75;
119 |     else if (signals.usesAttributes) score = 0.55;
120 | 
121 |     score -= lengthPenalty(s);
122 |     return { score: clamp01(score), signals };
123 |   }
124 | 
125 |   if (candidate.type === 'aria') {
126 |     const hasName = typeof candidate.name === 'string' && candidate.name.trim().length > 0;
127 |     const hasRole = typeof candidate.role === 'string' && candidate.role.trim().length > 0;
128 | 
129 |     const signals: SelectorStabilitySignals = { usesAria: true };
130 |     let score = hasName && hasRole ? 0.8 : hasName ? 0.72 : 0.6;
131 |     score -= lengthPenalty(candidate.value);
132 | 
133 |     return { score: clamp01(score), signals };
134 |   }
135 | 
136 |   // text
137 |   const text = String(candidate.value || '').trim();
138 |   const signals: SelectorStabilitySignals = { usesText: true };
139 |   let score = 0.35;
140 | 
141 |   // Very short texts tend to be ambiguous; very long texts are unstable.
142 |   if (text.length >= 6 && text.length <= 48) score = 0.45;
143 |   if (text.length > 80) score = 0.3;
144 | 
145 |   return { score: clamp01(score), signals };
146 | }
147 | 
148 | /**
149 |  * 为选择器候选添加稳定性评分
150 |  */
151 | export function withStability(candidate: SelectorCandidate): SelectorCandidate {
152 |   if (candidate.stability) return candidate;
153 |   return { ...candidate, stability: computeSelectorStability(candidate) };
154 | }
155 | 
156 | function typePriority(type: SelectorType): number {
157 |   switch (type) {
158 |     case 'attr':
159 |       return 5;
160 |     case 'css':
161 |       return 4;
162 |     case 'aria':
163 |       return 3;
164 |     case 'xpath':
165 |       return 2;
166 |     case 'text':
167 |       return 1;
168 |     default:
169 |       return 0;
170 |   }
171 | }
172 | 
173 | /**
174 |  * 比较两个选择器候选的优先级
175 |  * 返回负数表示 a 优先,正数表示 b 优先
176 |  */
177 | export function compareSelectorCandidates(a: SelectorCandidate, b: SelectorCandidate): number {
178 |   // 1. 用户指定的权重优先
179 |   const aw = a.weight ?? 0;
180 |   const bw = b.weight ?? 0;
181 |   if (aw !== bw) return bw - aw;
182 | 
183 |   // 2. 稳定性评分
184 |   const as = a.stability?.score ?? computeSelectorStability(a).score;
185 |   const bs = b.stability?.score ?? computeSelectorStability(b).score;
186 |   if (as !== bs) return bs - as;
187 | 
188 |   // 3. 类型优先级
189 |   const ap = typePriority(a.type);
190 |   const bp = typePriority(b.type);
191 |   if (ap !== bp) return bp - ap;
192 | 
193 |   // 4. 长度(越短越好)
194 |   const alen = String(a.value || '').length;
195 |   const blen = String(b.value || '').length;
196 |   return alen - blen;
197 | }
198 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/common/constants.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Chrome Extension Constants
  3 |  * Centralized configuration values and magic constants
  4 |  */
  5 | 
  6 | // Native Host Configuration
  7 | export const NATIVE_HOST = {
  8 |   NAME: 'com.chromemcp.nativehost',
  9 |   DEFAULT_PORT: 12306,
 10 | } as const;
 11 | 
 12 | // Chrome Extension Icons
 13 | export const ICONS = {
 14 |   NOTIFICATION: 'icon/48.png',
 15 | } as const;
 16 | 
 17 | // Timeouts and Delays (in milliseconds)
 18 | export const TIMEOUTS = {
 19 |   DEFAULT_WAIT: 1000,
 20 |   NETWORK_CAPTURE_MAX: 30000,
 21 |   NETWORK_CAPTURE_IDLE: 3000,
 22 |   SCREENSHOT_DELAY: 100,
 23 |   KEYBOARD_DELAY: 50,
 24 |   CLICK_DELAY: 100,
 25 | } as const;
 26 | 
 27 | // Limits and Thresholds
 28 | export const LIMITS = {
 29 |   MAX_NETWORK_REQUESTS: 100,
 30 |   MAX_SEARCH_RESULTS: 50,
 31 |   MAX_BOOKMARK_RESULTS: 100,
 32 |   MAX_HISTORY_RESULTS: 100,
 33 |   SIMILARITY_THRESHOLD: 0.1,
 34 |   VECTOR_DIMENSIONS: 384,
 35 | } as const;
 36 | 
 37 | // Error Messages
 38 | export const ERROR_MESSAGES = {
 39 |   NATIVE_CONNECTION_FAILED: 'Failed to connect to native host',
 40 |   NATIVE_DISCONNECTED: 'Native connection disconnected',
 41 |   SERVER_STATUS_LOAD_FAILED: 'Failed to load server status',
 42 |   SERVER_STATUS_SAVE_FAILED: 'Failed to save server status',
 43 |   TOOL_EXECUTION_FAILED: 'Tool execution failed',
 44 |   INVALID_PARAMETERS: 'Invalid parameters provided',
 45 |   PERMISSION_DENIED: 'Permission denied',
 46 |   TAB_NOT_FOUND: 'Tab not found',
 47 |   ELEMENT_NOT_FOUND: 'Element not found',
 48 |   NETWORK_ERROR: 'Network error occurred',
 49 | } as const;
 50 | 
 51 | // Success Messages
 52 | export const SUCCESS_MESSAGES = {
 53 |   TOOL_EXECUTED: 'Tool executed successfully',
 54 |   CONNECTION_ESTABLISHED: 'Connection established',
 55 |   SERVER_STARTED: 'Server started successfully',
 56 |   SERVER_STOPPED: 'Server stopped successfully',
 57 | } as const;
 58 | 
 59 | // External Links
 60 | export const LINKS = {
 61 |   TROUBLESHOOTING: 'https://github.com/hangwin/mcp-chrome/blob/master/docs/TROUBLESHOOTING.md',
 62 | } as const;
 63 | 
 64 | // File Extensions and MIME Types
 65 | export const FILE_TYPES = {
 66 |   STATIC_EXTENSIONS: [
 67 |     '.css',
 68 |     '.js',
 69 |     '.png',
 70 |     '.jpg',
 71 |     '.jpeg',
 72 |     '.gif',
 73 |     '.svg',
 74 |     '.ico',
 75 |     '.woff',
 76 |     '.woff2',
 77 |     '.ttf',
 78 |   ],
 79 |   FILTERED_MIME_TYPES: ['text/html', 'text/css', 'text/javascript', 'application/javascript'],
 80 |   IMAGE_FORMATS: ['png', 'jpeg', 'webp'] as const,
 81 | } as const;
 82 | 
 83 | // Network Filtering
 84 | export const NETWORK_FILTERS = {
 85 |   // Substring match against full URL (not just hostname) to support patterns like 'facebook.com/tr'
 86 |   EXCLUDED_DOMAINS: [
 87 |     // Google
 88 |     'google-analytics.com',
 89 |     'googletagmanager.com',
 90 |     'analytics.google.com',
 91 |     'doubleclick.net',
 92 |     'googlesyndication.com',
 93 |     'googleads.g.doubleclick.net',
 94 |     'stats.g.doubleclick.net',
 95 |     'adservice.google.com',
 96 |     'pagead2.googlesyndication.com',
 97 |     // Amazon
 98 |     'amazon-adsystem.com',
 99 |     // Microsoft
100 |     'bat.bing.com',
101 |     'clarity.ms',
102 |     // Facebook
103 |     'connect.facebook.net',
104 |     'facebook.com/tr',
105 |     // Twitter
106 |     'analytics.twitter.com',
107 |     'ads-twitter.com',
108 |     // Other ad networks
109 |     'ads.yahoo.com',
110 |     'adroll.com',
111 |     'adnxs.com',
112 |     'criteo.com',
113 |     'quantserve.com',
114 |     'scorecardresearch.com',
115 |     // Analytics & session recording
116 |     'segment.io',
117 |     'amplitude.com',
118 |     'mixpanel.com',
119 |     'optimizely.com',
120 |     'static.hotjar.com',
121 |     'script.hotjar.com',
122 |     'crazyegg.com',
123 |     'clicktale.net',
124 |     'mouseflow.com',
125 |     'fullstory.com',
126 |     // LinkedIn (tracking pixels)
127 |     'linkedin.com/px',
128 |   ],
129 |   // Static resource extensions (used when includeStatic=false)
130 |   STATIC_RESOURCE_EXTENSIONS: [
131 |     '.jpg',
132 |     '.jpeg',
133 |     '.png',
134 |     '.gif',
135 |     '.svg',
136 |     '.webp',
137 |     '.ico',
138 |     '.bmp',
139 |     '.cur',
140 |     '.css',
141 |     '.scss',
142 |     '.less',
143 |     '.js',
144 |     '.jsx',
145 |     '.ts',
146 |     '.tsx',
147 |     '.map',
148 |     '.woff',
149 |     '.woff2',
150 |     '.ttf',
151 |     '.eot',
152 |     '.otf',
153 |     '.mp3',
154 |     '.mp4',
155 |     '.avi',
156 |     '.mov',
157 |     '.wmv',
158 |     '.flv',
159 |     '.webm',
160 |     '.ogg',
161 |     '.wav',
162 |     '.pdf',
163 |     '.zip',
164 |     '.rar',
165 |     '.7z',
166 |     '.iso',
167 |     '.dmg',
168 |     '.doc',
169 |     '.docx',
170 |     '.xls',
171 |     '.xlsx',
172 |     '.ppt',
173 |     '.pptx',
174 |   ],
175 |   // MIME types treated as static/binary (filtered when includeStatic=false)
176 |   STATIC_MIME_TYPES_TO_FILTER: [
177 |     'image/',
178 |     'font/',
179 |     'audio/',
180 |     'video/',
181 |     'text/css',
182 |     'text/javascript',
183 |     'application/javascript',
184 |     'application/x-javascript',
185 |     'application/pdf',
186 |     'application/zip',
187 |     'application/octet-stream',
188 |   ],
189 |   // API-like MIME types (never filtered by MIME)
190 |   API_MIME_TYPES: [
191 |     'application/json',
192 |     'application/xml',
193 |     'text/xml',
194 |     'text/plain',
195 |     'text/event-stream',
196 |     'application/x-www-form-urlencoded',
197 |     'application/graphql',
198 |     'application/grpc',
199 |     'application/protobuf',
200 |     'application/x-protobuf',
201 |     'application/x-json',
202 |     'application/ld+json',
203 |     'application/problem+json',
204 |     'application/problem+xml',
205 |     'application/soap+xml',
206 |     'application/vnd.api+json',
207 |   ],
208 |   STATIC_RESOURCE_TYPES: ['stylesheet', 'image', 'font', 'media', 'other'],
209 | } as const;
210 | 
211 | // Semantic Similarity Configuration
212 | export const SEMANTIC_CONFIG = {
213 |   DEFAULT_MODEL: 'sentence-transformers/all-MiniLM-L6-v2',
214 |   CHUNK_SIZE: 512,
215 |   CHUNK_OVERLAP: 50,
216 |   BATCH_SIZE: 32,
217 |   CACHE_SIZE: 1000,
218 | } as const;
219 | 
220 | // Storage Keys
221 | export const STORAGE_KEYS = {
222 |   SERVER_STATUS: 'serverStatus',
223 |   NATIVE_SERVER_PORT: 'nativeServerPort',
224 |   NATIVE_AUTO_CONNECT_ENABLED: 'nativeAutoConnectEnabled',
225 |   SEMANTIC_MODEL: 'selectedModel',
226 |   USER_PREFERENCES: 'userPreferences',
227 |   VECTOR_INDEX: 'vectorIndex',
228 |   USERSCRIPTS: 'userscripts',
229 |   USERSCRIPTS_DISABLED: 'userscripts_disabled',
230 |   // Record & Replay storage keys
231 |   RR_FLOWS: 'rr_flows',
232 |   RR_RUNS: 'rr_runs',
233 |   RR_PUBLISHED: 'rr_published_flows',
234 |   RR_SCHEDULES: 'rr_schedules',
235 |   RR_TRIGGERS: 'rr_triggers',
236 |   // Persistent recording state (guards resume across navigations/service worker restarts)
237 |   RR_RECORDING_STATE: 'rr_recording_state',
238 | } as const;
239 | 
240 | // Notification Configuration
241 | export const NOTIFICATIONS = {
242 |   PRIORITY: 2,
243 |   TYPE: 'basic' as const,
244 | } as const;
245 | 
246 | export enum ExecutionWorld {
247 |   ISOLATED = 'ISOLATED',
248 |   MAIN = 'MAIN',
249 | }
250 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/ElementMarkerManagement.vue:
--------------------------------------------------------------------------------

```vue
  1 | <template>
  2 |   <div class="section">
  3 |     <h2 class="section-title">元素标注管理</h2>
  4 |     <div class="config-card">
  5 |       <div class="status-section" style="gap: 8px">
  6 |         <div class="status-header">
  7 |           <p class="status-label">当前页面</p>
  8 |           <span class="status-text" style="opacity: 0.85">{{ currentUrl }}</span>
  9 |         </div>
 10 |         <div class="status-header">
 11 |           <p class="status-label">已标注元素</p>
 12 |           <span class="status-text">{{ markers.length }}</span>
 13 |         </div>
 14 |       </div>
 15 | 
 16 |       <form class="mcp-config-section" @submit.prevent="onAdd">
 17 |         <div class="mcp-config-header">
 18 |           <p class="mcp-config-label">新增标注</p>
 19 |         </div>
 20 |         <div style="display: flex; gap: 8px; margin-bottom: 8px">
 21 |           <input v-model="form.name" placeholder="名称,如 登录按钮" class="port-input" />
 22 |           <select v-model="form.selectorType" class="port-input" style="max-width: 120px">
 23 |             <option value="css">CSS</option>
 24 |             <option value="xpath">XPath</option>
 25 |           </select>
 26 |           <select v-model="form.matchType" class="port-input" style="max-width: 120px">
 27 |             <option value="prefix">路径前缀</option>
 28 |             <option value="exact">精确匹配</option>
 29 |             <option value="host">域名</option>
 30 |           </select>
 31 |         </div>
 32 |         <input v-model="form.selector" placeholder="CSS 选择器" class="port-input" />
 33 |         <div style="display: flex; gap: 8px; margin-top: 8px">
 34 |           <button class="semantic-engine-button" :disabled="!form.selector" type="submit">
 35 |             保存
 36 |           </button>
 37 |           <button class="danger-button" type="button" @click="resetForm">清空</button>
 38 |         </div>
 39 |       </form>
 40 | 
 41 |       <div v-if="markers.length" class="model-list" style="margin-top: 8px">
 42 |         <div
 43 |           v-for="m in markers"
 44 |           :key="m.id"
 45 |           class="model-card"
 46 |           style="display: flex; align-items: center; justify-content: space-between; gap: 8px"
 47 |         >
 48 |           <div style="display: flex; flex-direction: column; gap: 4px">
 49 |             <strong class="model-name">{{ m.name }}</strong>
 50 |             <code style="font-size: 12px; opacity: 0.85">{{ m.selector }}</code>
 51 |             <div style="display: flex; gap: 6px; margin-top: 2px">
 52 |               <span class="model-tag dimension">{{ m.selectorType || 'css' }}</span>
 53 |               <span class="model-tag dimension">{{ m.matchType }}</span>
 54 |             </div>
 55 |           </div>
 56 |           <div style="display: flex; gap: 6px">
 57 |             <button class="semantic-engine-button" @click="validate(m)">验证</button>
 58 |             <button class="secondary-button" @click="prefill(m)">编辑</button>
 59 |             <button class="danger-button" @click="remove(m)">删除</button>
 60 |           </div>
 61 |         </div>
 62 |       </div>
 63 |     </div>
 64 |   </div>
 65 | </template>
 66 | 
 67 | <script setup lang="ts">
 68 | import { ref, onMounted } from 'vue';
 69 | import type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';
 70 | import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
 71 | 
 72 | const currentUrl = ref('');
 73 | const markers = ref<ElementMarker[]>([]);
 74 | 
 75 | const form = ref<UpsertMarkerRequest>({
 76 |   url: '',
 77 |   name: '',
 78 |   selector: '',
 79 |   matchType: 'prefix',
 80 | });
 81 | 
 82 | function resetForm() {
 83 |   form.value = { url: currentUrl.value, name: '', selector: '', matchType: 'prefix' };
 84 | }
 85 | 
 86 | async function load() {
 87 |   try {
 88 |     const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
 89 |     const t = tabs[0];
 90 |     currentUrl.value = String(t?.url || '');
 91 |     form.value.url = currentUrl.value;
 92 |     const res: any = await chrome.runtime.sendMessage({
 93 |       type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_FOR_URL,
 94 |       url: currentUrl.value,
 95 |     });
 96 |     if (res?.success) markers.value = res.markers || [];
 97 |   } catch (e) {
 98 |     /* ignore */
 99 |   }
100 | }
101 | 
102 | function prefill(m: ElementMarker) {
103 |   form.value = {
104 |     url: m.url,
105 |     name: m.name,
106 |     selector: m.selector,
107 |     selectorType: m.selectorType,
108 |     listMode: m.listMode,
109 |     matchType: m.matchType,
110 |     action: m.action,
111 |     id: m.id,
112 |   };
113 | }
114 | 
115 | async function onAdd() {
116 |   try {
117 |     if (!form.value.selector) return;
118 |     form.value.url = currentUrl.value;
119 |     const res: any = await chrome.runtime.sendMessage({
120 |       type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE,
121 |       marker: form.value,
122 |     });
123 |     if (res?.success) {
124 |       resetForm();
125 |       await load();
126 |     }
127 |   } catch (e) {
128 |     /* ignore */
129 |   }
130 | }
131 | 
132 | async function remove(m: ElementMarker) {
133 |   try {
134 |     const res: any = await chrome.runtime.sendMessage({
135 |       type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_DELETE,
136 |       id: m.id,
137 |     });
138 |     if (res?.success) await load();
139 |   } catch (e) {
140 |     /* ignore */
141 |   }
142 | }
143 | 
144 | async function validate(m: ElementMarker) {
145 |   try {
146 |     const res: any = await chrome.runtime.sendMessage({
147 |       type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_VALIDATE,
148 |       selector: m.selector,
149 |       selectorType: m.selectorType || 'css',
150 |       action: 'hover',
151 |       listMode: !!m.listMode,
152 |     } as any);
153 | 
154 |     // Trigger highlight in the page only if tool validation succeeded
155 |     if (res?.tool?.ok !== false) {
156 |       await highlightInTab(m);
157 |     }
158 |   } catch (e) {
159 |     /* ignore */
160 |   }
161 | }
162 | 
163 | async function highlightInTab(m: ElementMarker) {
164 |   try {
165 |     const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
166 |     const tabId = tabs[0]?.id;
167 |     if (!tabId) return;
168 | 
169 |     // Ensure element-marker.js is injected
170 |     try {
171 |       await chrome.scripting.executeScript({
172 |         target: { tabId, allFrames: true },
173 |         files: ['inject-scripts/element-marker.js'],
174 |         world: 'ISOLATED',
175 |       });
176 |     } catch {
177 |       // Already injected, ignore
178 |     }
179 | 
180 |     // Send highlight message to content script
181 |     await chrome.tabs.sendMessage(tabId, {
182 |       action: 'element_marker_highlight',
183 |       selector: m.selector,
184 |       selectorType: m.selectorType || 'css',
185 |       listMode: !!m.listMode,
186 |     });
187 |   } catch (e) {
188 |     // Ignore errors (tab might not support content scripts)
189 |   }
190 | }
191 | 
192 | onMounted(load);
193 | </script>
194 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/input-container.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Input Container Component
  3 |  *
  4 |  * A reusable wrapper for inputs aligned with the attr-ui.html design spec.
  5 |  * Provides container-level hover/focus-within styling with optional prefix/suffix.
  6 |  *
  7 |  * Design spec pattern:
  8 |  * ```html
  9 |  * <div class="input-bg rounded h-[28px] flex items-center px-2">
 10 |  *   <span class="text-gray-400 mr-2">X</span>  <!-- prefix -->
 11 |  *   <input type="text" class="bg-transparent w-full outline-none">
 12 |  *   <span class="text-gray-400 text-[10px]">px</span>  <!-- suffix -->
 13 |  * </div>
 14 |  * ```
 15 |  *
 16 |  * CSS classes (defined in shadow-host.ts):
 17 |  * - `.we-input-container` - wrapper with hover/focus-within styles
 18 |  * - `.we-input-container__input` - transparent input
 19 |  * - `.we-input-container__prefix` - prefix element
 20 |  * - `.we-input-container__suffix` - suffix element (typically unit)
 21 |  */
 22 | 
 23 | // =============================================================================
 24 | // Types
 25 | // =============================================================================
 26 | 
 27 | /** Content for prefix/suffix: text string or DOM node (e.g., SVG icon) */
 28 | export type InputAffix = string | Node;
 29 | 
 30 | export interface InputContainerOptions {
 31 |   /** Accessible label for the input element */
 32 |   ariaLabel: string;
 33 |   /** Input type (default: "text") */
 34 |   type?: string;
 35 |   /** Input mode for virtual keyboard (e.g., "decimal", "numeric") */
 36 |   inputMode?: string;
 37 |   /** Optional prefix content (text label or icon) */
 38 |   prefix?: InputAffix | null;
 39 |   /** Optional suffix content (unit text or icon) */
 40 |   suffix?: InputAffix | null;
 41 |   /** Additional class name(s) for root container */
 42 |   rootClassName?: string;
 43 |   /** Additional class name(s) for input element */
 44 |   inputClassName?: string;
 45 |   /** Input autocomplete attribute (default: "off") */
 46 |   autocomplete?: string;
 47 |   /** Input spellcheck attribute (default: false) */
 48 |   spellcheck?: boolean;
 49 |   /** Initial placeholder text */
 50 |   placeholder?: string;
 51 | }
 52 | 
 53 | export interface InputContainer {
 54 |   /** Root container element */
 55 |   root: HTMLDivElement;
 56 |   /** Input element for wiring events */
 57 |   input: HTMLInputElement;
 58 |   /** Update prefix content */
 59 |   setPrefix(content: InputAffix | null): void;
 60 |   /** Update suffix content */
 61 |   setSuffix(content: InputAffix | null): void;
 62 |   /** Get current suffix text (null if no suffix or if suffix is a Node) */
 63 |   getSuffixText(): string | null;
 64 | }
 65 | 
 66 | // =============================================================================
 67 | // Helpers
 68 | // =============================================================================
 69 | 
 70 | function isNonEmptyString(value: unknown): value is string {
 71 |   return typeof value === 'string' && value.trim().length > 0;
 72 | }
 73 | 
 74 | function hasAffix(value: InputAffix | null | undefined): value is InputAffix {
 75 |   if (value === null || value === undefined) return false;
 76 |   return typeof value === 'string' ? value.trim().length > 0 : true;
 77 | }
 78 | 
 79 | function joinClassNames(...parts: Array<string | null | undefined | false>): string {
 80 |   return parts.filter(isNonEmptyString).join(' ');
 81 | }
 82 | 
 83 | // =============================================================================
 84 | // Factory
 85 | // =============================================================================
 86 | 
 87 | /**
 88 |  * Create an input container with optional prefix/suffix
 89 |  */
 90 | export function createInputContainer(options: InputContainerOptions): InputContainer {
 91 |   const {
 92 |     ariaLabel,
 93 |     type = 'text',
 94 |     inputMode,
 95 |     prefix,
 96 |     suffix,
 97 |     rootClassName,
 98 |     inputClassName,
 99 |     autocomplete = 'off',
100 |     spellcheck = false,
101 |     placeholder,
102 |   } = options;
103 | 
104 |   // Root container
105 |   const root = document.createElement('div');
106 |   root.className = joinClassNames('we-input-container', rootClassName);
107 | 
108 |   // Prefix element (created lazily)
109 |   let prefixEl: HTMLSpanElement | null = null;
110 | 
111 |   // Input element
112 |   const input = document.createElement('input');
113 |   input.type = type;
114 |   input.className = joinClassNames('we-input-container__input', inputClassName);
115 |   input.setAttribute('autocomplete', autocomplete);
116 |   input.spellcheck = spellcheck;
117 |   input.setAttribute('aria-label', ariaLabel);
118 |   if (inputMode) {
119 |     input.inputMode = inputMode;
120 |   }
121 |   if (placeholder !== undefined) {
122 |     input.placeholder = placeholder;
123 |   }
124 | 
125 |   // Suffix element (created lazily)
126 |   let suffixEl: HTMLSpanElement | null = null;
127 | 
128 |   // Helper: create/update affix element
129 |   function updateAffix(
130 |     kind: 'prefix' | 'suffix',
131 |     content: InputAffix | null,
132 |     existingEl: HTMLSpanElement | null,
133 |   ): HTMLSpanElement | null {
134 |     if (!hasAffix(content)) {
135 |       // Remove existing element if present
136 |       if (existingEl) {
137 |         existingEl.remove();
138 |       }
139 |       return null;
140 |     }
141 | 
142 |     // Create element if needed
143 |     const el = existingEl ?? document.createElement('span');
144 |     el.className = `we-input-container__${kind}`;
145 | 
146 |     // Clear and set content
147 |     el.textContent = '';
148 |     if (typeof content === 'string') {
149 |       el.textContent = content;
150 |     } else {
151 |       el.append(content);
152 |     }
153 | 
154 |     return el;
155 |   }
156 | 
157 |   // Initial prefix
158 |   if (hasAffix(prefix)) {
159 |     prefixEl = updateAffix('prefix', prefix, null);
160 |     if (prefixEl) root.append(prefixEl);
161 |   }
162 | 
163 |   // Append input
164 |   root.append(input);
165 | 
166 |   // Initial suffix
167 |   if (hasAffix(suffix)) {
168 |     suffixEl = updateAffix('suffix', suffix, null);
169 |     if (suffixEl) root.append(suffixEl);
170 |   }
171 | 
172 |   // Public interface
173 |   return {
174 |     root,
175 |     input,
176 | 
177 |     setPrefix(content: InputAffix | null): void {
178 |       const newEl = updateAffix('prefix', content, prefixEl);
179 |       if (newEl && !prefixEl) {
180 |         // Insert before input
181 |         root.insertBefore(newEl, input);
182 |       }
183 |       prefixEl = newEl;
184 |     },
185 | 
186 |     setSuffix(content: InputAffix | null): void {
187 |       const newEl = updateAffix('suffix', content, suffixEl);
188 |       if (newEl && !suffixEl) {
189 |         // Append after input
190 |         root.append(newEl);
191 |       }
192 |       suffixEl = newEl;
193 |     },
194 | 
195 |     getSuffixText(): string | null {
196 |       if (!suffixEl) return null;
197 |       // Only return text content, not Node content
198 |       const text = suffixEl.textContent?.trim();
199 |       return text || null;
200 |     },
201 |   };
202 | }
203 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/engine/utils/expression.ts:
--------------------------------------------------------------------------------

```typescript
  1 | // expression.ts — minimal safe boolean expression evaluator (no access to global scope)
  2 | // Supported:
  3 | // - Literals: numbers (123, 1.23), strings ('x' or "x"), booleans (true/false)
  4 | // - Variables: vars.x, vars.a.b (only reads from provided vars object)
  5 | // - Operators: !, &&, ||, ==, !=, >, >=, <, <=, +, -, *, /
  6 | // - Parentheses: ( ... )
  7 | 
  8 | type Token = { type: string; value?: any };
  9 | 
 10 | function tokenize(input: string): Token[] {
 11 |   const s = input.trim();
 12 |   const out: Token[] = [];
 13 |   let i = 0;
 14 |   const isAlpha = (c: string) => /[a-zA-Z_]/.test(c);
 15 |   const isNum = (c: string) => /[0-9]/.test(c);
 16 |   const isIdChar = (c: string) => /[a-zA-Z0-9_]/.test(c);
 17 |   while (i < s.length) {
 18 |     const c = s[i];
 19 |     if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
 20 |       i++;
 21 |       continue;
 22 |     }
 23 |     // operators
 24 |     if (
 25 |       s.startsWith('&&', i) ||
 26 |       s.startsWith('||', i) ||
 27 |       s.startsWith('==', i) ||
 28 |       s.startsWith('!=', i) ||
 29 |       s.startsWith('>=', i) ||
 30 |       s.startsWith('<=', i)
 31 |     ) {
 32 |       out.push({ type: 'op', value: s.slice(i, i + 2) });
 33 |       i += 2;
 34 |       continue;
 35 |     }
 36 |     if ('!+-*/()<>'.includes(c)) {
 37 |       out.push({ type: 'op', value: c });
 38 |       i++;
 39 |       continue;
 40 |     }
 41 |     // number
 42 |     if (isNum(c) || (c === '.' && isNum(s[i + 1] || ''))) {
 43 |       let j = i + 1;
 44 |       while (j < s.length && (isNum(s[j]) || s[j] === '.')) j++;
 45 |       out.push({ type: 'num', value: parseFloat(s.slice(i, j)) });
 46 |       i = j;
 47 |       continue;
 48 |     }
 49 |     // string
 50 |     if (c === '"' || c === "'") {
 51 |       const quote = c;
 52 |       let j = i + 1;
 53 |       let str = '';
 54 |       while (j < s.length) {
 55 |         if (s[j] === '\\' && j + 1 < s.length) {
 56 |           str += s[j + 1];
 57 |           j += 2;
 58 |         } else if (s[j] === quote) {
 59 |           j++;
 60 |           break;
 61 |         } else {
 62 |           str += s[j++];
 63 |         }
 64 |       }
 65 |       out.push({ type: 'str', value: str });
 66 |       i = j;
 67 |       continue;
 68 |     }
 69 |     // identifier (vars or true/false)
 70 |     if (isAlpha(c)) {
 71 |       let j = i + 1;
 72 |       while (j < s.length && isIdChar(s[j])) j++;
 73 |       let id = s.slice(i, j);
 74 |       // dotted path
 75 |       while (s[j] === '.' && isAlpha(s[j + 1] || '')) {
 76 |         let k = j + 1;
 77 |         while (k < s.length && isIdChar(s[k])) k++;
 78 |         id += s.slice(j, k);
 79 |         j = k;
 80 |       }
 81 |       out.push({ type: 'id', value: id });
 82 |       i = j;
 83 |       continue;
 84 |     }
 85 |     // unknown token, skip to avoid crash
 86 |     i++;
 87 |   }
 88 |   return out;
 89 | }
 90 | 
 91 | // Recursive descent parser
 92 | export function evalExpression(expr: string, scope: { vars: Record<string, any> }): any {
 93 |   const tokens = tokenize(expr);
 94 |   let i = 0;
 95 |   const peek = () => tokens[i];
 96 |   const consume = () => tokens[i++];
 97 | 
 98 |   function parsePrimary(): any {
 99 |     const t = peek();
100 |     if (!t) return undefined;
101 |     if (t.type === 'num') {
102 |       consume();
103 |       return t.value;
104 |     }
105 |     if (t.type === 'str') {
106 |       consume();
107 |       return t.value;
108 |     }
109 |     if (t.type === 'id') {
110 |       consume();
111 |       const id = String(t.value);
112 |       if (id === 'true') return true;
113 |       if (id === 'false') return false;
114 |       // Only allow vars.* lookups
115 |       if (!id.startsWith('vars')) return undefined;
116 |       try {
117 |         const parts = id.split('.').slice(1);
118 |         let cur: any = scope.vars;
119 |         for (const p of parts) {
120 |           if (cur == null) return undefined;
121 |           cur = cur[p];
122 |         }
123 |         return cur;
124 |       } catch {
125 |         return undefined;
126 |       }
127 |     }
128 |     if (t.type === 'op' && t.value === '(') {
129 |       consume();
130 |       const v = parseOr();
131 |       if (peek()?.type === 'op' && peek()?.value === ')') consume();
132 |       return v;
133 |     }
134 |     return undefined;
135 |   }
136 | 
137 |   function parseUnary(): any {
138 |     const t = peek();
139 |     if (t && t.type === 'op' && (t.value === '!' || t.value === '-')) {
140 |       consume();
141 |       const v = parseUnary();
142 |       return t.value === '!' ? !truthy(v) : -Number(v || 0);
143 |     }
144 |     return parsePrimary();
145 |   }
146 | 
147 |   function parseMulDiv(): any {
148 |     let v = parseUnary();
149 |     while (peek() && peek().type === 'op' && (peek().value === '*' || peek().value === '/')) {
150 |       const op = consume().value;
151 |       const r = parseUnary();
152 |       v = op === '*' ? Number(v || 0) * Number(r || 0) : Number(v || 0) / Number(r || 0);
153 |     }
154 |     return v;
155 |   }
156 | 
157 |   function parseAddSub(): any {
158 |     let v = parseMulDiv();
159 |     while (peek() && peek().type === 'op' && (peek().value === '+' || peek().value === '-')) {
160 |       const op = consume().value;
161 |       const r = parseMulDiv();
162 |       v = op === '+' ? Number(v || 0) + Number(r || 0) : Number(v || 0) - Number(r || 0);
163 |     }
164 |     return v;
165 |   }
166 | 
167 |   function parseRel(): any {
168 |     let v = parseAddSub();
169 |     while (peek() && peek().type === 'op' && ['>', '>=', '<', '<='].includes(peek().value)) {
170 |       const op = consume().value as string;
171 |       const r = parseAddSub();
172 |       const a = toComparable(v);
173 |       const b = toComparable(r);
174 |       if (op === '>') v = (a as any) > (b as any);
175 |       else if (op === '>=') v = (a as any) >= (b as any);
176 |       else if (op === '<') v = (a as any) < (b as any);
177 |       else v = (a as any) <= (b as any);
178 |     }
179 |     return v;
180 |   }
181 | 
182 |   function parseEq(): any {
183 |     let v = parseRel();
184 |     while (peek() && peek().type === 'op' && (peek().value === '==' || peek().value === '!=')) {
185 |       const op = consume().value as string;
186 |       const r = parseRel();
187 |       const a = toComparable(v);
188 |       const b = toComparable(r);
189 |       v = op === '==' ? a === b : a !== b;
190 |     }
191 |     return v;
192 |   }
193 | 
194 |   function parseAnd(): any {
195 |     let v = parseEq();
196 |     while (peek() && peek().type === 'op' && peek().value === '&&') {
197 |       consume();
198 |       const r = parseEq();
199 |       v = truthy(v) && truthy(r);
200 |     }
201 |     return v;
202 |   }
203 | 
204 |   function parseOr(): any {
205 |     let v = parseAnd();
206 |     while (peek() && peek().type === 'op' && peek().value === '||') {
207 |       consume();
208 |       const r = parseAnd();
209 |       v = truthy(v) || truthy(r);
210 |     }
211 |     return v;
212 |   }
213 | 
214 |   function truthy(v: any) {
215 |     return !!v;
216 |   }
217 |   function toComparable(v: any) {
218 |     return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : String(v);
219 |   }
220 | 
221 |   try {
222 |     const res = parseOr();
223 |     return res;
224 |   } catch {
225 |     return false;
226 |   }
227 | }
228 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/fill.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Fill Action Handler
  3 |  *
  4 |  * Handles form input actions:
  5 |  * - Text input
  6 |  * - File upload
  7 |  * - Auto-scroll and focus
  8 |  * - Selector fallback with logging
  9 |  */
 10 | 
 11 | import { handleCallTool } from '@/entrypoints/background/tools';
 12 | import { TOOL_NAMES } from 'chrome-mcp-shared';
 13 | import { failed, invalid, ok } from '../registry';
 14 | import type { ActionHandler } from '../types';
 15 | import {
 16 |   ensureElementVisible,
 17 |   logSelectorFallback,
 18 |   resolveString,
 19 |   selectorLocator,
 20 |   sendMessageToTab,
 21 |   toSelectorTarget,
 22 | } from './common';
 23 | 
 24 | export const fillHandler: ActionHandler<'fill'> = {
 25 |   type: 'fill',
 26 | 
 27 |   validate: (action) => {
 28 |     const target = action.params.target as { ref?: string; candidates?: unknown[] };
 29 |     const hasRef = typeof target?.ref === 'string' && target.ref.trim().length > 0;
 30 |     const hasCandidates = Array.isArray(target?.candidates) && target.candidates.length > 0;
 31 |     const hasValue = action.params.value !== undefined;
 32 | 
 33 |     if (!hasValue) {
 34 |       return invalid('Missing value parameter');
 35 |     }
 36 |     if (!hasRef && !hasCandidates) {
 37 |       return invalid('Missing target selector or ref');
 38 |     }
 39 |     return ok();
 40 |   },
 41 | 
 42 |   describe: (action) => {
 43 |     const value = typeof action.params.value === 'string' ? action.params.value : '(dynamic)';
 44 |     const displayValue = value.length > 20 ? value.slice(0, 20) + '...' : value;
 45 |     return `Fill "${displayValue}"`;
 46 |   },
 47 | 
 48 |   run: async (ctx, action) => {
 49 |     const vars = ctx.vars;
 50 |     const tabId = ctx.tabId;
 51 | 
 52 |     if (typeof tabId !== 'number') {
 53 |       return failed('TAB_NOT_FOUND', 'No active tab found');
 54 |     }
 55 | 
 56 |     // Ensure page is read before locating element
 57 |     await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
 58 | 
 59 |     // Resolve fill value
 60 |     const valueResolved = resolveString(action.params.value, vars);
 61 |     if (!valueResolved.ok) {
 62 |       return failed('VALIDATION_ERROR', valueResolved.error);
 63 |     }
 64 |     const value = valueResolved.value;
 65 | 
 66 |     // Locate target element
 67 |     const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(
 68 |       action.params.target,
 69 |       vars,
 70 |     );
 71 | 
 72 |     const located = await selectorLocator.locate(tabId, selectorTarget, {
 73 |       frameId: ctx.frameId,
 74 |       preferRef: false,
 75 |     });
 76 | 
 77 |     const frameId = located?.frameId ?? ctx.frameId;
 78 |     const refToUse = located?.ref ?? selectorTarget.ref;
 79 |     const cssSelector = !located?.ref ? firstCssOrAttr : undefined;
 80 | 
 81 |     if (!refToUse && !cssSelector) {
 82 |       return failed('TARGET_NOT_FOUND', 'Could not locate target element');
 83 |     }
 84 | 
 85 |     // Verify element visibility if we have a ref
 86 |     if (located?.ref) {
 87 |       const isVisible = await ensureElementVisible(tabId, located.ref, frameId);
 88 |       if (!isVisible) {
 89 |         return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');
 90 |       }
 91 |     }
 92 | 
 93 |     // Check for file input and handle file upload
 94 |     // Use firstCssOrAttr to check input type even when ref is available
 95 |     const selectorForTypeCheck = firstCssOrAttr || cssSelector;
 96 |     if (selectorForTypeCheck) {
 97 |       const attrResult = await sendMessageToTab<{ value?: string }>(
 98 |         tabId,
 99 |         { action: 'getAttributeForSelector', selector: selectorForTypeCheck, name: 'type' },
100 |         frameId,
101 |       );
102 |       const inputType = (attrResult.ok ? (attrResult.value?.value ?? '') : '').toLowerCase();
103 | 
104 |       if (inputType === 'file') {
105 |         const uploadResult = await handleCallTool({
106 |           name: TOOL_NAMES.BROWSER.FILE_UPLOAD,
107 |           args: { selector: selectorForTypeCheck, filePath: value, tabId },
108 |         });
109 | 
110 |         if ((uploadResult as { isError?: boolean })?.isError) {
111 |           const errorContent = (uploadResult as { content?: Array<{ text?: string }> })?.content;
112 |           const errorMsg = errorContent?.[0]?.text || 'File upload failed';
113 |           return failed('UNKNOWN', errorMsg);
114 |         }
115 | 
116 |         // Log fallback if used
117 |         const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
118 |         const fallbackUsed =
119 |           resolvedBy &&
120 |           firstCandidateType &&
121 |           resolvedBy !== 'ref' &&
122 |           resolvedBy !== firstCandidateType;
123 |         if (fallbackUsed) {
124 |           logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
125 |         }
126 | 
127 |         return { status: 'success' };
128 |       }
129 |     }
130 | 
131 |     // Scroll element into view (best-effort)
132 |     if (cssSelector) {
133 |       try {
134 |         await handleCallTool({
135 |           name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
136 |           args: {
137 |             type: 'MAIN',
138 |             jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el){el.scrollIntoView({behavior:'instant',block:'center',inline:'nearest'});}}catch(e){}`,
139 |             tabId,
140 |           },
141 |         });
142 |       } catch {
143 |         // Ignore scroll errors
144 |       }
145 |     }
146 | 
147 |     // Focus element (best-effort, ignore errors)
148 |     if (located?.ref) {
149 |       await sendMessageToTab(tabId, { action: 'focusByRef', ref: located.ref }, frameId);
150 |     } else if (cssSelector) {
151 |       await handleCallTool({
152 |         name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
153 |         args: {
154 |           type: 'MAIN',
155 |           jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el&&el.focus){el.focus();}}catch(e){}`,
156 |           tabId,
157 |         },
158 |       });
159 |     }
160 | 
161 |     // Execute fill
162 |     const fillResult = await handleCallTool({
163 |       name: TOOL_NAMES.BROWSER.FILL,
164 |       args: {
165 |         ref: refToUse,
166 |         selector: cssSelector,
167 |         value,
168 |         frameId,
169 |         tabId,
170 |       },
171 |     });
172 | 
173 |     if ((fillResult as { isError?: boolean })?.isError) {
174 |       const errorContent = (fillResult as { content?: Array<{ text?: string }> })?.content;
175 |       const errorMsg = errorContent?.[0]?.text || 'Fill action failed';
176 |       return failed('UNKNOWN', errorMsg);
177 |     }
178 | 
179 |     // Log fallback if used
180 |     const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
181 |     const fallbackUsed =
182 |       resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
183 | 
184 |     if (fallbackUsed) {
185 |       logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
186 |     }
187 | 
188 |     return { status: 'success' };
189 |   },
190 | };
191 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentTopBar.vue:
--------------------------------------------------------------------------------

```vue
  1 | <template>
  2 |   <div class="flex items-center justify-between w-full">
  3 |     <!-- Brand / Context -->
  4 |     <div class="flex items-center gap-2 overflow-hidden -ml-1">
  5 |       <!-- Back Button (when in chat view) -->
  6 |       <button
  7 |         v-if="showBackButton"
  8 |         class="flex items-center justify-center w-8 h-8 flex-shrink-0 ac-btn"
  9 |         :style="{
 10 |           color: 'var(--ac-text-muted)',
 11 |           borderRadius: 'var(--ac-radius-button)',
 12 |         }"
 13 |         title="Back to sessions"
 14 |         @click="$emit('back')"
 15 |       >
 16 |         <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
 17 |           <path
 18 |             stroke-linecap="round"
 19 |             stroke-linejoin="round"
 20 |             stroke-width="2"
 21 |             d="M15 19l-7-7 7-7"
 22 |           />
 23 |         </svg>
 24 |       </button>
 25 | 
 26 |       <!-- Brand -->
 27 |       <h1
 28 |         class="text-lg font-medium tracking-tight flex-shrink-0"
 29 |         :style="{
 30 |           fontFamily: 'var(--ac-font-heading)',
 31 |           color: 'var(--ac-text)',
 32 |         }"
 33 |       >
 34 |         {{ brandLabel || 'Agent' }}
 35 |       </h1>
 36 | 
 37 |       <!-- Divider -->
 38 |       <div
 39 |         class="h-4 w-[1px] flex-shrink-0"
 40 |         :style="{ backgroundColor: 'var(--ac-border-strong)' }"
 41 |       />
 42 | 
 43 |       <!-- Project Breadcrumb -->
 44 |       <button
 45 |         class="flex items-center gap-1.5 text-xs px-2 py-1 truncate group ac-btn"
 46 |         :style="{
 47 |           fontFamily: 'var(--ac-font-mono)',
 48 |           color: 'var(--ac-text-muted)',
 49 |           borderRadius: 'var(--ac-radius-button)',
 50 |         }"
 51 |         @click="$emit('toggle:projectMenu')"
 52 |       >
 53 |         <span class="truncate">{{ projectLabel }}</span>
 54 |         <svg
 55 |           class="w-3 h-3 opacity-50 group-hover:opacity-100 transition-opacity"
 56 |           fill="none"
 57 |           viewBox="0 0 24 24"
 58 |           stroke="currentColor"
 59 |         >
 60 |           <path
 61 |             stroke-linecap="round"
 62 |             stroke-linejoin="round"
 63 |             stroke-width="2"
 64 |             d="M19 9l-7 7-7-7"
 65 |           />
 66 |         </svg>
 67 |       </button>
 68 | 
 69 |       <!-- Session Breadcrumb -->
 70 |       <div class="h-3 w-[1px] flex-shrink-0" :style="{ backgroundColor: 'var(--ac-border)' }" />
 71 |       <button
 72 |         class="flex items-center gap-1.5 text-xs px-2 py-1 truncate group ac-btn"
 73 |         :style="{
 74 |           fontFamily: 'var(--ac-font-mono)',
 75 |           color: 'var(--ac-text-subtle)',
 76 |           borderRadius: 'var(--ac-radius-button)',
 77 |         }"
 78 |         @click="$emit('toggle:sessionMenu')"
 79 |       >
 80 |         <span class="truncate">{{ sessionLabel }}</span>
 81 |         <svg
 82 |           class="w-3 h-3 opacity-50 group-hover:opacity-100 transition-opacity"
 83 |           fill="none"
 84 |           viewBox="0 0 24 24"
 85 |           stroke="currentColor"
 86 |         >
 87 |           <path
 88 |             stroke-linecap="round"
 89 |             stroke-linejoin="round"
 90 |             stroke-width="2"
 91 |             d="M19 9l-7 7-7-7"
 92 |           />
 93 |         </svg>
 94 |       </button>
 95 |     </div>
 96 | 
 97 |     <!-- Connection / Status / Settings -->
 98 |     <div class="flex items-center gap-3">
 99 |       <!-- Connection Indicator -->
100 |       <div class="flex items-center gap-1.5" :title="connectionText">
101 |         <span
102 |           class="w-2 h-2 rounded-full"
103 |           :style="{
104 |             backgroundColor: connectionColor,
105 |             boxShadow: connectionState === 'ready' ? `0 0 8px ${connectionColor}` : 'none',
106 |           }"
107 |         />
108 |       </div>
109 | 
110 |       <!-- Open Project Button -->
111 |       <button
112 |         class="p-1 ac-btn ac-hover-text"
113 |         :style="{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }"
114 |         title="Open project in VS Code or Terminal"
115 |         @click="$emit('toggle:openProjectMenu')"
116 |       >
117 |         <svg
118 |           class="w-5 h-5"
119 |           viewBox="0 0 24 24"
120 |           fill="none"
121 |           stroke="currentColor"
122 |           stroke-width="2"
123 |           stroke-linecap="round"
124 |           stroke-linejoin="round"
125 |         >
126 |           <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
127 |           <line x1="12" y1="11" x2="12" y2="17" />
128 |           <line x1="9" y1="14" x2="15" y2="14" />
129 |         </svg>
130 |       </button>
131 | 
132 |       <!-- Theme & Settings Icon (Color Palette) -->
133 |       <button
134 |         class="p-1 ac-btn ac-hover-text"
135 |         :style="{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }"
136 |         @click="$emit('toggle:settingsMenu')"
137 |       >
138 |         <svg
139 |           class="w-5 h-5"
140 |           viewBox="0 0 24 24"
141 |           fill="none"
142 |           stroke="currentColor"
143 |           stroke-width="2"
144 |           stroke-linecap="round"
145 |           stroke-linejoin="round"
146 |         >
147 |           <circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
148 |           <circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
149 |           <circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
150 |           <circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
151 |           <path
152 |             d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"
153 |           />
154 |         </svg>
155 |       </button>
156 |     </div>
157 |   </div>
158 | </template>
159 | 
160 | <script lang="ts" setup>
161 | import { computed } from 'vue';
162 | 
163 | export type ConnectionState = 'ready' | 'connecting' | 'disconnected';
164 | 
165 | const props = defineProps<{
166 |   projectLabel: string;
167 |   sessionLabel: string;
168 |   connectionState: ConnectionState;
169 |   /** Whether to show back button (for returning to sessions list) */
170 |   showBackButton?: boolean;
171 |   /** Brand label to display (e.g., "Claude Code", "Codex") */
172 |   brandLabel?: string;
173 | }>();
174 | 
175 | defineEmits<{
176 |   'toggle:projectMenu': [];
177 |   'toggle:sessionMenu': [];
178 |   'toggle:settingsMenu': [];
179 |   'toggle:openProjectMenu': [];
180 |   /** Emitted when back button is clicked */
181 |   back: [];
182 | }>();
183 | 
184 | const connectionColor = computed(() => {
185 |   switch (props.connectionState) {
186 |     case 'ready':
187 |       return 'var(--ac-success)';
188 |     case 'connecting':
189 |       return 'var(--ac-warning)';
190 |     default:
191 |       return 'var(--ac-text-subtle)';
192 |   }
193 | });
194 | 
195 | const connectionText = computed(() => {
196 |   switch (props.connectionState) {
197 |     case 'ready':
198 |       return 'Connected';
199 |     case 'connecting':
200 |       return 'Connecting...';
201 |     default:
202 |       return 'Disconnected';
203 |   }
204 | });
205 | </script>
206 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview Interval Trigger Handler (M3.1)
  3 |  * @description
  4 |  * 使用 chrome.alarms 的 periodInMinutes 实现固定间隔触发。
  5 |  *
  6 |  * 策略:
  7 |  * - 每个触发器对应一个重复 alarm
  8 |  * - 使用 delayInMinutes 使首次触发在配置的间隔后
  9 |  */
 10 | 
 11 | import type { TriggerId } from '../../domain/ids';
 12 | import type { TriggerSpecByKind } from '../../domain/triggers';
 13 | import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
 14 | 
 15 | // ==================== Types ====================
 16 | 
 17 | type IntervalTriggerSpec = TriggerSpecByKind<'interval'>;
 18 | 
 19 | export interface IntervalTriggerHandlerDeps {
 20 |   logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
 21 | }
 22 | 
 23 | interface InstalledIntervalTrigger {
 24 |   spec: IntervalTriggerSpec;
 25 |   periodMinutes: number;
 26 |   version: number;
 27 | }
 28 | 
 29 | // ==================== Constants ====================
 30 | 
 31 | const ALARM_PREFIX = 'rr_v3_interval_';
 32 | 
 33 | // ==================== Utilities ====================
 34 | 
 35 | /**
 36 |  * 校验并规范化 periodMinutes
 37 |  */
 38 | function normalizePeriodMinutes(value: unknown): number {
 39 |   if (typeof value !== 'number' || !Number.isFinite(value)) {
 40 |     throw new Error('periodMinutes must be a finite number');
 41 |   }
 42 |   if (value < 1) {
 43 |     throw new Error('periodMinutes must be >= 1');
 44 |   }
 45 |   return value;
 46 | }
 47 | 
 48 | /**
 49 |  * 生成 alarm 名称
 50 |  */
 51 | function alarmNameForTrigger(triggerId: TriggerId): string {
 52 |   return `${ALARM_PREFIX}${triggerId}`;
 53 | }
 54 | 
 55 | /**
 56 |  * 从 alarm 名称解析 triggerId
 57 |  */
 58 | function parseTriggerIdFromAlarmName(name: string): TriggerId | null {
 59 |   if (!name.startsWith(ALARM_PREFIX)) return null;
 60 |   const id = name.slice(ALARM_PREFIX.length);
 61 |   return id ? (id as TriggerId) : null;
 62 | }
 63 | 
 64 | // ==================== Handler Implementation ====================
 65 | 
 66 | /**
 67 |  * 创建 interval 触发器处理器工厂
 68 |  */
 69 | export function createIntervalTriggerHandlerFactory(
 70 |   deps?: IntervalTriggerHandlerDeps,
 71 | ): TriggerHandlerFactory<'interval'> {
 72 |   return (fireCallback) => createIntervalTriggerHandler(fireCallback, deps);
 73 | }
 74 | 
 75 | /**
 76 |  * 创建 interval 触发器处理器
 77 |  */
 78 | export function createIntervalTriggerHandler(
 79 |   fireCallback: TriggerFireCallback,
 80 |   deps?: IntervalTriggerHandlerDeps,
 81 | ): TriggerHandler<'interval'> {
 82 |   const logger = deps?.logger ?? console;
 83 | 
 84 |   const installed = new Map<TriggerId, InstalledIntervalTrigger>();
 85 |   const versions = new Map<TriggerId, number>();
 86 |   let listening = false;
 87 | 
 88 |   /**
 89 |    * 递增版本号以使挂起的操作失效
 90 |    */
 91 |   function bumpVersion(triggerId: TriggerId): number {
 92 |     const next = (versions.get(triggerId) ?? 0) + 1;
 93 |     versions.set(triggerId, next);
 94 |     return next;
 95 |   }
 96 | 
 97 |   /**
 98 |    * 清除指定 alarm
 99 |    */
100 |   async function clearAlarmByName(name: string): Promise<void> {
101 |     if (!chrome.alarms?.clear) return;
102 |     try {
103 |       await Promise.resolve(chrome.alarms.clear(name));
104 |     } catch (e) {
105 |       logger.debug('[IntervalTriggerHandler] alarms.clear failed:', e);
106 |     }
107 |   }
108 | 
109 |   /**
110 |    * 清除所有 interval alarms
111 |    */
112 |   async function clearAllIntervalAlarms(): Promise<void> {
113 |     if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;
114 |     try {
115 |       const alarms = await Promise.resolve(chrome.alarms.getAll());
116 |       const list = Array.isArray(alarms) ? alarms : [];
117 |       await Promise.all(
118 |         list.filter((a) => a?.name?.startsWith(ALARM_PREFIX)).map((a) => clearAlarmByName(a.name)),
119 |       );
120 |     } catch (e) {
121 |       logger.debug('[IntervalTriggerHandler] alarms.getAll failed:', e);
122 |     }
123 |   }
124 | 
125 |   /**
126 |    * 调度 alarm
127 |    */
128 |   async function schedule(triggerId: TriggerId, expectedVersion: number): Promise<void> {
129 |     if (!chrome.alarms?.create) {
130 |       logger.warn('[IntervalTriggerHandler] chrome.alarms.create is unavailable');
131 |       return;
132 |     }
133 | 
134 |     const entry = installed.get(triggerId);
135 |     if (!entry || entry.version !== expectedVersion) return;
136 | 
137 |     const name = alarmNameForTrigger(triggerId);
138 |     const periodInMinutes = entry.periodMinutes;
139 | 
140 |     try {
141 |       // 使用 delayInMinutes 和 periodInMinutes 创建重复 alarm
142 |       // 首次触发在 periodInMinutes 后,之后每隔 periodInMinutes 触发
143 |       await Promise.resolve(
144 |         chrome.alarms.create(name, {
145 |           delayInMinutes: periodInMinutes,
146 |           periodInMinutes,
147 |         }),
148 |       );
149 |     } catch (e) {
150 |       logger.error(`[IntervalTriggerHandler] alarms.create failed for trigger "${triggerId}":`, e);
151 |     }
152 |   }
153 | 
154 |   /**
155 |    * Alarm 事件处理
156 |    */
157 |   const onAlarm = (alarm: chrome.alarms.Alarm): void => {
158 |     const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? '');
159 |     if (!triggerId) return;
160 | 
161 |     const entry = installed.get(triggerId);
162 |     if (!entry) return;
163 | 
164 |     // 触发回调
165 |     Promise.resolve(
166 |       fireCallback.onFire(triggerId, {
167 |         sourceTabId: undefined,
168 |         sourceUrl: undefined,
169 |       }),
170 |     ).catch((e) => {
171 |       logger.error(`[IntervalTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
172 |     });
173 |   };
174 | 
175 |   /**
176 |    * 确保正在监听 alarm 事件
177 |    */
178 |   function ensureListening(): void {
179 |     if (listening) return;
180 |     if (!chrome.alarms?.onAlarm?.addListener) {
181 |       logger.warn('[IntervalTriggerHandler] chrome.alarms.onAlarm is unavailable');
182 |       return;
183 |     }
184 |     chrome.alarms.onAlarm.addListener(onAlarm);
185 |     listening = true;
186 |   }
187 | 
188 |   /**
189 |    * 停止监听 alarm 事件
190 |    */
191 |   function stopListening(): void {
192 |     if (!listening) return;
193 |     try {
194 |       chrome.alarms.onAlarm.removeListener(onAlarm);
195 |     } catch (e) {
196 |       logger.debug('[IntervalTriggerHandler] removeListener failed:', e);
197 |     } finally {
198 |       listening = false;
199 |     }
200 |   }
201 | 
202 |   return {
203 |     kind: 'interval',
204 | 
205 |     async install(trigger: IntervalTriggerSpec): Promise<void> {
206 |       const periodMinutes = normalizePeriodMinutes(trigger.periodMinutes);
207 | 
208 |       const version = bumpVersion(trigger.id);
209 |       installed.set(trigger.id, {
210 |         spec: { ...trigger, periodMinutes },
211 |         periodMinutes,
212 |         version,
213 |       });
214 | 
215 |       ensureListening();
216 |       await schedule(trigger.id, version);
217 |     },
218 | 
219 |     async uninstall(triggerId: string): Promise<void> {
220 |       const id = triggerId as TriggerId;
221 |       bumpVersion(id);
222 |       installed.delete(id);
223 |       await clearAlarmByName(alarmNameForTrigger(id));
224 | 
225 |       if (installed.size === 0) {
226 |         stopListening();
227 |       }
228 |     },
229 | 
230 |     async uninstallAll(): Promise<void> {
231 |       for (const id of installed.keys()) {
232 |         bumpVersion(id);
233 |       }
234 |       installed.clear();
235 |       await clearAllIntervalAlarms();
236 |       stopListening();
237 |     },
238 | 
239 |     getInstalledIds(): string[] {
240 |       return Array.from(installed.keys());
241 |     },
242 |   };
243 | }
244 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/utils/image-utils.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Image processing utility functions
  3 |  */
  4 | 
  5 | /**
  6 |  * Create ImageBitmap from data URL (for OffscreenCanvas)
  7 |  * @param dataUrl Image data URL
  8 |  * @returns Created ImageBitmap object
  9 |  */
 10 | export async function createImageBitmapFromUrl(dataUrl: string): Promise<ImageBitmap> {
 11 |   const response = await fetch(dataUrl);
 12 |   const blob = await response.blob();
 13 |   return await createImageBitmap(blob);
 14 | }
 15 | 
 16 | /**
 17 |  * Stitch multiple image parts (dataURL) onto a single canvas
 18 |  * @param parts Array of image parts, each containing dataUrl and y coordinate
 19 |  * @param totalWidthPx Total width (pixels)
 20 |  * @param totalHeightPx Total height (pixels)
 21 |  * @returns Stitched canvas
 22 |  */
 23 | export async function stitchImages(
 24 |   parts: { dataUrl: string; y: number }[],
 25 |   totalWidthPx: number,
 26 |   totalHeightPx: number,
 27 | ): Promise<OffscreenCanvas> {
 28 |   const canvas = new OffscreenCanvas(totalWidthPx, totalHeightPx);
 29 |   const ctx = canvas.getContext('2d');
 30 | 
 31 |   if (!ctx) {
 32 |     throw new Error('Unable to get canvas context');
 33 |   }
 34 | 
 35 |   ctx.fillStyle = '#FFFFFF';
 36 |   ctx.fillRect(0, 0, canvas.width, canvas.height);
 37 | 
 38 |   for (const part of parts) {
 39 |     try {
 40 |       const img = await createImageBitmapFromUrl(part.dataUrl);
 41 |       const sx = 0;
 42 |       const sy = 0;
 43 |       const sWidth = img.width;
 44 |       let sHeight = img.height;
 45 |       const dy = part.y;
 46 | 
 47 |       if (dy + sHeight > totalHeightPx) {
 48 |         sHeight = totalHeightPx - dy;
 49 |       }
 50 | 
 51 |       if (sHeight <= 0) continue;
 52 | 
 53 |       ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, dy, sWidth, sHeight);
 54 |     } catch (error) {
 55 |       console.error('Error stitching image part:', error, part);
 56 |     }
 57 |   }
 58 |   return canvas;
 59 | }
 60 | 
 61 | /**
 62 |  * Crop image (from dataURL) to specified rectangle and resize
 63 |  * @param originalDataUrl Original image data URL
 64 |  * @param cropRectPx Crop rectangle (physical pixels)
 65 |  * @param dpr Device pixel ratio
 66 |  * @param targetWidthOpt Optional target output width (CSS pixels)
 67 |  * @param targetHeightOpt Optional target output height (CSS pixels)
 68 |  * @returns Cropped canvas
 69 |  */
 70 | export async function cropAndResizeImage(
 71 |   originalDataUrl: string,
 72 |   cropRectPx: { x: number; y: number; width: number; height: number },
 73 |   dpr: number = 1,
 74 |   targetWidthOpt?: number,
 75 |   targetHeightOpt?: number,
 76 | ): Promise<OffscreenCanvas> {
 77 |   const img = await createImageBitmapFromUrl(originalDataUrl);
 78 | 
 79 |   let sx = cropRectPx.x;
 80 |   let sy = cropRectPx.y;
 81 |   let sWidth = cropRectPx.width;
 82 |   let sHeight = cropRectPx.height;
 83 | 
 84 |   // Ensure crop area is within image boundaries
 85 |   if (sx < 0) {
 86 |     sWidth += sx;
 87 |     sx = 0;
 88 |   }
 89 |   if (sy < 0) {
 90 |     sHeight += sy;
 91 |     sy = 0;
 92 |   }
 93 |   if (sx + sWidth > img.width) {
 94 |     sWidth = img.width - sx;
 95 |   }
 96 |   if (sy + sHeight > img.height) {
 97 |     sHeight = img.height - sy;
 98 |   }
 99 | 
100 |   if (sWidth <= 0 || sHeight <= 0) {
101 |     throw new Error(
102 |       'Invalid calculated crop size (<=0). Element may not be visible or fully captured.',
103 |     );
104 |   }
105 | 
106 |   const finalCanvasWidthPx = targetWidthOpt ? targetWidthOpt * dpr : sWidth;
107 |   const finalCanvasHeightPx = targetHeightOpt ? targetHeightOpt * dpr : sHeight;
108 | 
109 |   const canvas = new OffscreenCanvas(finalCanvasWidthPx, finalCanvasHeightPx);
110 |   const ctx = canvas.getContext('2d');
111 | 
112 |   if (!ctx) {
113 |     throw new Error('Unable to get canvas context');
114 |   }
115 | 
116 |   ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, 0, finalCanvasWidthPx, finalCanvasHeightPx);
117 | 
118 |   return canvas;
119 | }
120 | 
121 | /**
122 |  * Convert canvas to data URL
123 |  * @param canvas Canvas
124 |  * @param format Image format
125 |  * @param quality JPEG quality (0-1)
126 |  * @returns Data URL
127 |  */
128 | export async function canvasToDataURL(
129 |   canvas: OffscreenCanvas,
130 |   format: string = 'image/png',
131 |   quality?: number,
132 | ): Promise<string> {
133 |   const blob = await canvas.convertToBlob({
134 |     type: format,
135 |     quality: format === 'image/jpeg' ? quality : undefined,
136 |   });
137 | 
138 |   return new Promise((resolve, reject) => {
139 |     const reader = new FileReader();
140 |     reader.onloadend = () => resolve(reader.result as string);
141 |     reader.onerror = reject;
142 |     reader.readAsDataURL(blob);
143 |   });
144 | }
145 | 
146 | /**
147 |  * Compresses an image by scaling it and converting it to a target format with a specific quality.
148 |  * This is the most effective way to reduce image data size for transport or storage.
149 |  *
150 |  * @param {string} imageDataUrl - The original image data URL (e.g., from captureVisibleTab).
151 |  * @param {object} options - Compression options.
152 |  * @param {number} [options.scale=1.0] - The scaling factor for dimensions (e.g., 0.7 for 70%).
153 |  * @param {number} [options.quality=0.8] - The quality for lossy formats like JPEG (0.0 to 1.0).
154 |  * @param {string} [options.format='image/jpeg'] - The target image format.
155 |  * @returns {Promise<{dataUrl: string, mimeType: string}>} A promise that resolves to the compressed image data URL and its MIME type.
156 |  */
157 | export async function compressImage(
158 |   imageDataUrl: string,
159 |   options: { scale?: number; quality?: number; format?: 'image/jpeg' | 'image/webp' },
160 | ): Promise<{ dataUrl: string; mimeType: string }> {
161 |   const { scale = 1.0, quality = 0.8, format = 'image/jpeg' } = options;
162 | 
163 |   // 1. Create an ImageBitmap from the original data URL for efficient drawing.
164 |   const imageBitmap = await createImageBitmapFromUrl(imageDataUrl);
165 | 
166 |   // 2. Calculate the new dimensions based on the scale factor.
167 |   const newWidth = Math.round(imageBitmap.width * scale);
168 |   const newHeight = Math.round(imageBitmap.height * scale);
169 | 
170 |   // 3. Use OffscreenCanvas for performance, as it doesn't need to be in the DOM.
171 |   const canvas = new OffscreenCanvas(newWidth, newHeight);
172 |   const ctx = canvas.getContext('2d');
173 | 
174 |   if (!ctx) {
175 |     throw new Error('Failed to get 2D context from OffscreenCanvas');
176 |   }
177 | 
178 |   // 4. Draw the original image onto the smaller canvas, effectively resizing it.
179 |   ctx.drawImage(imageBitmap, 0, 0, newWidth, newHeight);
180 | 
181 |   // 5. Export the canvas content to the target format with the specified quality.
182 |   // This is the step that performs the data compression.
183 |   const compressedDataUrl = await canvas.convertToBlob({ type: format, quality: quality });
184 | 
185 |   // A helper to convert blob to data URL since OffscreenCanvas.toDataURL is not standard yet
186 |   // on all execution contexts (like service workers).
187 |   const dataUrl = await new Promise<string>((resolve) => {
188 |     const reader = new FileReader();
189 |     reader.onloadend = () => resolve(reader.result as string);
190 |     reader.readAsDataURL(compressedDataUrl);
191 |   });
192 | 
193 |   return { dataUrl, mimeType: format };
194 | }
195 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/key.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Key Action Handler
  3 |  *
  4 |  * Handles keyboard input:
  5 |  * - Resolves key sequences via variables/templates
  6 |  * - Optionally focuses a target element before sending keys
  7 |  * - Dispatches keyboard events via the keyboard tool
  8 |  */
  9 | 
 10 | import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
 11 | import { handleCallTool } from '@/entrypoints/background/tools';
 12 | import { TOOL_NAMES } from 'chrome-mcp-shared';
 13 | import { failed, invalid, ok } from '../registry';
 14 | import type { ActionHandler, ElementTarget } from '../types';
 15 | import {
 16 |   ensureElementVisible,
 17 |   logSelectorFallback,
 18 |   resolveString,
 19 |   selectorLocator,
 20 |   sendMessageToTab,
 21 |   toSelectorTarget,
 22 | } from './common';
 23 | 
 24 | /** Extract error text from tool result */
 25 | function extractToolError(result: unknown, fallback: string): string {
 26 |   const content = (result as { content?: Array<{ text?: string }> })?.content;
 27 |   return content?.find((c) => typeof c?.text === 'string')?.text || fallback;
 28 | }
 29 | 
 30 | /** Check if target has valid selector specification */
 31 | function hasTargetSpec(target: unknown): boolean {
 32 |   if (!target || typeof target !== 'object') return false;
 33 |   const t = target as { ref?: unknown; candidates?: unknown };
 34 |   const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0;
 35 |   const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0;
 36 |   return hasRef || hasCandidates;
 37 | }
 38 | 
 39 | /** Strip frame prefix from composite selector */
 40 | function stripCompositeSelector(selector: string): string {
 41 |   const raw = String(selector || '').trim();
 42 |   if (!raw || !raw.includes('|>')) return raw;
 43 |   const parts = raw
 44 |     .split('|>')
 45 |     .map((p) => p.trim())
 46 |     .filter(Boolean);
 47 |   return parts.length > 0 ? parts[parts.length - 1] : raw;
 48 | }
 49 | 
 50 | export const keyHandler: ActionHandler<'key'> = {
 51 |   type: 'key',
 52 | 
 53 |   validate: (action) => {
 54 |     if (action.params.keys === undefined) {
 55 |       return invalid('Missing keys parameter');
 56 |     }
 57 | 
 58 |     if (action.params.target !== undefined && !hasTargetSpec(action.params.target)) {
 59 |       return invalid('Target must include a non-empty ref or selector candidates');
 60 |     }
 61 | 
 62 |     return ok();
 63 |   },
 64 | 
 65 |   describe: (action) => {
 66 |     const keys = typeof action.params.keys === 'string' ? action.params.keys : '(dynamic)';
 67 |     const display = keys.length > 30 ? keys.slice(0, 30) + '...' : keys;
 68 |     return `Keys "${display}"`;
 69 |   },
 70 | 
 71 |   run: async (ctx, action) => {
 72 |     const vars = ctx.vars;
 73 |     const tabId = ctx.tabId;
 74 | 
 75 |     if (typeof tabId !== 'number') {
 76 |       return failed('TAB_NOT_FOUND', 'No active tab found for key action');
 77 |     }
 78 | 
 79 |     // Resolve keys string
 80 |     const keysResolved = resolveString(action.params.keys, vars);
 81 |     if (!keysResolved.ok) {
 82 |       return failed('VALIDATION_ERROR', keysResolved.error);
 83 |     }
 84 | 
 85 |     const keys = keysResolved.value.trim();
 86 |     if (!keys) {
 87 |       return failed('VALIDATION_ERROR', 'Keys string is empty');
 88 |     }
 89 | 
 90 |     let frameId = ctx.frameId;
 91 |     let selectorForTool: string | undefined;
 92 |     let firstCandidateType: string | undefined;
 93 |     let resolvedBy: string | undefined;
 94 | 
 95 |     // Handle optional target focusing
 96 |     const target = action.params.target as ElementTarget | undefined;
 97 |     if (target) {
 98 |       await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });
 99 | 
100 |       const {
101 |         selectorTarget,
102 |         firstCandidateType: firstType,
103 |         firstCssOrAttr,
104 |       } = toSelectorTarget(target, vars);
105 |       firstCandidateType = firstType;
106 | 
107 |       const located = await selectorLocator.locate(tabId, selectorTarget, {
108 |         frameId: ctx.frameId,
109 |         preferRef: false,
110 |       });
111 | 
112 |       frameId = located?.frameId ?? ctx.frameId;
113 |       const refToUse = located?.ref ?? selectorTarget.ref;
114 | 
115 |       if (!refToUse && !firstCssOrAttr) {
116 |         return failed('TARGET_NOT_FOUND', 'Could not locate target element for key action');
117 |       }
118 | 
119 |       resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
120 | 
121 |       // Only verify visibility for freshly located refs (not stale refs from payload)
122 |       if (located?.ref) {
123 |         const visible = await ensureElementVisible(tabId, located.ref, frameId);
124 |         if (!visible) {
125 |           return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');
126 |         }
127 | 
128 |         const focusResult = await sendMessageToTab<{ success?: boolean; error?: string }>(
129 |           tabId,
130 |           { action: 'focusByRef', ref: located.ref },
131 |           frameId,
132 |         );
133 | 
134 |         if (!focusResult.ok || focusResult.value?.success !== true) {
135 |           const focusErr = focusResult.ok ? focusResult.value?.error : focusResult.error;
136 | 
137 |           if (!firstCssOrAttr) {
138 |             return failed(
139 |               'TARGET_NOT_FOUND',
140 |               `Failed to focus target element: ${focusErr || 'ref may be stale'}`,
141 |             );
142 |           }
143 | 
144 |           ctx.log(`focusByRef failed; falling back to selector: ${focusErr}`, 'warn');
145 |         }
146 | 
147 |         // Try to resolve ref to CSS selector for tool
148 |         const resolved = await sendMessageToTab<{
149 |           success?: boolean;
150 |           selector?: string;
151 |           error?: string;
152 |         }>(tabId, { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: located.ref }, frameId);
153 | 
154 |         if (
155 |           resolved.ok &&
156 |           resolved.value?.success !== false &&
157 |           typeof resolved.value?.selector === 'string'
158 |         ) {
159 |           const sel = resolved.value.selector.trim();
160 |           if (sel) selectorForTool = sel;
161 |         }
162 |       }
163 | 
164 |       // Fallback to CSS/attr selector
165 |       if (!selectorForTool && firstCssOrAttr) {
166 |         const stripped = stripCompositeSelector(firstCssOrAttr);
167 |         if (stripped) selectorForTool = stripped;
168 |       }
169 |     }
170 | 
171 |     // Execute keyboard input
172 |     const keyboardResult = await handleCallTool({
173 |       name: TOOL_NAMES.BROWSER.KEYBOARD,
174 |       args: {
175 |         keys,
176 |         selector: selectorForTool,
177 |         selectorType: selectorForTool ? 'css' : undefined,
178 |         tabId,
179 |         frameId,
180 |       },
181 |     });
182 | 
183 |     if ((keyboardResult as { isError?: boolean })?.isError) {
184 |       return failed('UNKNOWN', extractToolError(keyboardResult, 'Keyboard input failed'));
185 |     }
186 | 
187 |     // Log fallback after successful execution
188 |     const fallbackUsed =
189 |       resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
190 |     if (fallbackUsed) {
191 |       logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
192 |     }
193 | 
194 |     return { status: 'success' };
195 |   },
196 | };
197 | 
```
Page 6/60FirstPrevNextLast