#
tokens: 48933/50000 35/574 files (page 3/60)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 3 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/recording/browser-event-listener.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { addNavigationStep } from './flow-builder';
 2 | import { STEP_TYPES } from '@/common/step-types';
 3 | import { ensureRecorderInjected, broadcastControlToTab, REC_CMD } from './content-injection';
 4 | import type { RecordingSessionManager } from './session-manager';
 5 | import type { Step } from '../types';
 6 | 
 7 | export function initBrowserEventListeners(session: RecordingSessionManager): void {
 8 |   chrome.tabs.onActivated.addListener(async (activeInfo) => {
 9 |     try {
10 |       if (session.getStatus() !== 'recording') return;
11 |       const tabId = activeInfo.tabId;
12 |       await ensureRecorderInjected(tabId);
13 |       await broadcastControlToTab(tabId, REC_CMD.START);
14 |       // Track active tab for targeted STOP later
15 |       session.addActiveTab(tabId);
16 | 
17 |       const flow = session.getFlow();
18 |       if (!flow) return;
19 |       const tab = await chrome.tabs.get(tabId);
20 |       const url = tab.url;
21 |       const step: Step = {
22 |         id: '',
23 |         type: STEP_TYPES.SWITCH_TAB,
24 |         ...(url ? { urlContains: url } : {}),
25 |       };
26 |       session.appendSteps([step]);
27 |     } catch (e) {
28 |       console.warn('onActivated handler failed', e);
29 |     }
30 |   });
31 | 
32 |   chrome.webNavigation.onCommitted.addListener(async (details) => {
33 |     try {
34 |       if (session.getStatus() !== 'recording') return;
35 |       if (details.frameId !== 0) return;
36 |       const tabId = details.tabId;
37 |       const t = details.transitionType;
38 |       const link = t === 'link';
39 |       if (!link) {
40 |         const shouldRecord =
41 |           t === 'reload' ||
42 |           t === 'typed' ||
43 |           t === 'generated' ||
44 |           t === 'auto_bookmark' ||
45 |           t === 'keyword' ||
46 |           // include form_submit to better capture Enter-to-search navigations
47 |           t === 'form_submit';
48 |         if (shouldRecord) {
49 |           const tab = await chrome.tabs.get(tabId);
50 |           const url = tab.url || details.url;
51 |           const flow = session.getFlow();
52 |           if (flow && url) addNavigationStep(flow, url);
53 |         }
54 |       }
55 |       await ensureRecorderInjected(tabId);
56 |       await broadcastControlToTab(tabId, REC_CMD.START);
57 |       // Track active tab for targeted STOP later
58 |       session.addActiveTab(tabId);
59 |       if (session.getFlow()) {
60 |         session.broadcastTimelineUpdate();
61 |       }
62 |     } catch (e) {
63 |       console.warn('onCommitted handler failed', e);
64 |     }
65 |   });
66 | 
67 |   // Remove closed tabs from the active set to avoid stale broadcasts
68 |   chrome.tabs.onRemoved.addListener((tabId) => {
69 |     try {
70 |       // Even if not recording, removing is harmless; keep guard for clarity
71 |       if (session.getStatus() !== 'recording') return;
72 |       session.removeActiveTab(tabId);
73 |     } catch (e) {
74 |       console.warn('onRemoved handler failed', e);
75 |     }
76 |   });
77 | }
78 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/recording/content-injection.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
 2 | 
 3 | // Avoid magic strings for recorder control commands
 4 | export type RecorderCmd = 'start' | 'stop' | 'pause' | 'resume';
 5 | export const REC_CMD = {
 6 |   START: 'start',
 7 |   STOP: 'stop',
 8 |   PAUSE: 'pause',
 9 |   RESUME: 'resume',
10 | } as const satisfies Record<string, RecorderCmd>;
11 | 
12 | const RECORDER_JS_SCRIPT = 'inject-scripts/recorder.js';
13 | 
14 | export async function ensureRecorderInjected(tabId: number): Promise<void> {
15 |   // Discover frames (top + subframes)
16 |   let frames: Array<{ frameId: number } & Record<string, any>> = [];
17 |   try {
18 |     const res = (await chrome.webNavigation.getAllFrames({ tabId })) as
19 |       | Array<{ frameId: number } & Record<string, any>>
20 |       | null
21 |       | undefined;
22 |     frames = Array.isArray(res) ? res : [];
23 |   } catch {
24 |     // ignore and fallback to top frame only
25 |   }
26 |   if (frames.length === 0) frames = [{ frameId: 0 }];
27 | 
28 |   const needRecorder: number[] = [];
29 |   await Promise.all(
30 |     frames.map(async (f) => {
31 |       const frameId = f.frameId ?? 0;
32 |       try {
33 |         const res = await chrome.tabs.sendMessage(
34 |           tabId,
35 |           { action: 'rr_recorder_ping' },
36 |           { frameId },
37 |         );
38 |         const pong = res?.status === 'pong';
39 |         if (!pong) needRecorder.push(frameId);
40 |       } catch {
41 |         needRecorder.push(frameId);
42 |       }
43 |     }),
44 |   );
45 | 
46 |   if (needRecorder.length > 0) {
47 |     try {
48 |       await chrome.scripting.executeScript({
49 |         target: { tabId, frameIds: needRecorder },
50 |         files: [RECORDER_JS_SCRIPT],
51 |         world: 'ISOLATED',
52 |       });
53 |     } catch {
54 |       // Fallback: try allFrames to cover dynamic/subframe changes; safe due to idempotent guard in recorder.js
55 |       try {
56 |         await chrome.scripting.executeScript({
57 |           target: { tabId, allFrames: true },
58 |           files: [RECORDER_JS_SCRIPT],
59 |           world: 'ISOLATED',
60 |         });
61 |       } catch {
62 |         // ignore injection failures per-tab
63 |       }
64 |     }
65 |   }
66 | }
67 | 
68 | export async function broadcastControlToTab(
69 |   tabId: number,
70 |   cmd: RecorderCmd,
71 |   meta?: unknown,
72 | ): Promise<void> {
73 |   try {
74 |     const res = (await chrome.webNavigation.getAllFrames({ tabId })) as
75 |       | Array<{ frameId: number } & Record<string, any>>
76 |       | null
77 |       | undefined;
78 |     const targets = Array.isArray(res) && res.length ? res : [{ frameId: 0 }];
79 |     await Promise.all(
80 |       targets.map(async (f) => {
81 |         try {
82 |           await chrome.tabs.sendMessage(
83 |             tabId,
84 |             { action: TOOL_MESSAGE_TYPES.RR_RECORDER_CONTROL, cmd, meta },
85 |             { frameId: f.frameId },
86 |           );
87 |         } catch {
88 |           // ignore per-frame send failure
89 |         }
90 |       }),
91 |     );
92 |   } catch {
93 |     // ignore
94 |   }
95 | }
96 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldSelector.vue:
--------------------------------------------------------------------------------

```vue
 1 | <template>
 2 |   <div class="selector">
 3 |     <div class="row">
 4 |       <input class="form-input" :placeholder="placeholder" :value="text" @input="onInput" />
 5 |       <button class="btn-mini" type="button" title="从页面拾取" @click="onPick">拾取</button>
 6 |     </div>
 7 |     <div class="help">可输入 CSS 选择器,或点击“拾取”在页面中选择元素</div>
 8 |     <div v-if="err" class="error-item">{{ err }}</div>
 9 |   </div>
10 | </template>
11 | 
12 | <script lang="ts" setup>
13 | import { ref, watchEffect } from 'vue';
14 | const props = defineProps<{ modelValue?: string; field?: any }>();
15 | const emit = defineEmits<{ (e: 'update:modelValue', v?: string): void }>();
16 | const text = ref<string>(props.modelValue ?? '');
17 | const placeholder = props.field?.placeholder || '.btn.primary';
18 | function onInput(ev: any) {
19 |   const v = String(ev?.target?.value ?? '');
20 |   text.value = v;
21 |   emit('update:modelValue', v);
22 | }
23 | watchEffect(() => (text.value = props.modelValue ?? ''));
24 | 
25 | const err = ref<string>('');
26 | async function ensurePickerInjected(tabId: number) {
27 |   try {
28 |     const pong = await chrome.tabs.sendMessage(tabId, { action: 'chrome_read_page_ping' } as any);
29 |     if (pong && pong.status === 'pong') return;
30 |   } catch {}
31 |   try {
32 |     await chrome.scripting.executeScript({
33 |       target: { tabId },
34 |       files: ['inject-scripts/accessibility-tree-helper.js'],
35 |       world: 'ISOLATED',
36 |     } as any);
37 |   } catch (e) {
38 |     console.warn('inject picker helper failed:', e);
39 |   }
40 | }
41 | 
42 | async function onPick() {
43 |   try {
44 |     err.value = '';
45 |     const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
46 |     const tabId = tabs?.[0]?.id;
47 |     if (!tabId) throw new Error('未找到活动页签');
48 |     await ensurePickerInjected(tabId);
49 |     const res: any = await chrome.tabs.sendMessage(tabId, { action: 'rr_picker_start' } as any);
50 |     if (!res || !res.success) {
51 |       if (res?.cancelled) return;
52 |       throw new Error(res?.error || '拾取失败');
53 |     }
54 |     const candidates = Array.isArray(res.candidates) ? res.candidates : [];
55 |     const prefer = ['css', 'attr', 'aria', 'text'];
56 |     let sel = '';
57 |     for (const t of prefer) {
58 |       const c = candidates.find((x: any) => x.type === t && x.value);
59 |       if (c) {
60 |         sel = String(c.value);
61 |         break;
62 |       }
63 |     }
64 |     if (!sel && candidates[0]?.value) sel = String(candidates[0].value);
65 |     if (sel) {
66 |       text.value = sel;
67 |       emit('update:modelValue', sel);
68 |     } else {
69 |       err.value = '未生成有效选择器,请手动输入';
70 |     }
71 |   } catch (e: any) {
72 |     err.value = e?.message || String(e);
73 |   }
74 | }
75 | </script>
76 | 
77 | <style scoped>
78 | .row {
79 |   display: flex;
80 |   gap: 8px;
81 |   align-items: center;
82 | }
83 | .btn-mini {
84 |   font-size: 12px;
85 |   padding: 2px 6px;
86 |   border: 1px solid var(--rr-border);
87 |   border-radius: 6px;
88 | }
89 | .error-item {
90 |   font-size: 12px;
91 |   color: #ff6666;
92 |   margin-top: 6px;
93 | }
94 | </style>
95 | 
```

--------------------------------------------------------------------------------
/docs/mcp-cli-config.md:
--------------------------------------------------------------------------------

```markdown
  1 | # CLI MCP Configuration Guide
  2 | 
  3 | This guide explains how to configure Codex CLI and Claude Code to connect to the Chrome MCP Server.
  4 | 
  5 | ## Overview
  6 | 
  7 | The Chrome MCP Server exposes its MCP interface at `http://127.0.0.1:12306/mcp` (default port).
  8 | Both Codex CLI and Claude Code can connect to this endpoint to use Chrome browser control tools.
  9 | 
 10 | ## Codex CLI Configuration
 11 | 
 12 | ### Option 1: HTTP MCP Server (Recommended)
 13 | 
 14 | Add the following to your `~/.codex/config.json`:
 15 | 
 16 | ```json
 17 | {
 18 |   "mcpServers": {
 19 |     "chrome-mcp": {
 20 |       "url": "http://127.0.0.1:12306/mcp"
 21 |     }
 22 |   }
 23 | }
 24 | ```
 25 | 
 26 | ### Option 2: Via Environment Variable
 27 | 
 28 | Set the MCP URL via environment variable before running codex:
 29 | 
 30 | ```bash
 31 | export MCP_HTTP_PORT=12306
 32 | ```
 33 | 
 34 | ## Claude Code Configuration
 35 | 
 36 | ### Option 1: HTTP MCP Server
 37 | 
 38 | Add the following to your `~/.claude/claude_desktop_config.json`:
 39 | 
 40 | ```json
 41 | {
 42 |   "mcpServers": {
 43 |     "chrome-mcp": {
 44 |       "url": "http://127.0.0.1:12306/mcp"
 45 |     }
 46 |   }
 47 | }
 48 | ```
 49 | 
 50 | ### Option 2: Stdio Server (Alternative)
 51 | 
 52 | If you prefer stdio-based MCP communication:
 53 | 
 54 | ```json
 55 | {
 56 |   "mcpServers": {
 57 |     "chrome-mcp": {
 58 |       "command": "node",
 59 |       "args": ["/path/to/mcp-chrome/dist/mcp/mcp-server-stdio.js"]
 60 |     }
 61 |   }
 62 | }
 63 | ```
 64 | 
 65 | ## Verifying Connection
 66 | 
 67 | After configuration, the CLI tools should be able to see and use Chrome MCP tools such as:
 68 | 
 69 | - `chrome_get_windows_and_tabs` - Get browser window and tab information
 70 | - `chrome_navigate` - Navigate to a URL
 71 | - `chrome_click_element` - Click on page elements
 72 | - `chrome_get_page_content` - Get page content
 73 | - And more...
 74 | 
 75 | ## Troubleshooting
 76 | 
 77 | ### Connection Refused
 78 | 
 79 | If you get "connection refused" errors:
 80 | 
 81 | 1. Ensure the Chrome extension is installed and the native server is running
 82 | 2. Check that the port matches (default: 12306)
 83 | 3. Verify no firewall is blocking localhost connections
 84 | 4. Run `mcp-chrome-bridge doctor` to diagnose issues
 85 | 
 86 | ### Tools Not Appearing
 87 | 
 88 | If MCP tools don't appear in the CLI:
 89 | 
 90 | 1. Restart the CLI tool after configuration changes
 91 | 2. Check the configuration file syntax (valid JSON)
 92 | 3. Ensure the MCP server URL is accessible
 93 | 
 94 | ### Port Conflicts
 95 | 
 96 | If port 12306 is already in use:
 97 | 
 98 | 1. Set a custom port in the extension settings
 99 | 2. Update the CLI configuration to match the new port
100 | 3. Run `mcp-chrome-bridge update-port <new-port>` to update the stdio config
101 | 
102 | ## Environment Variables
103 | 
104 | | Variable                     | Description                            | Default |
105 | | ---------------------------- | -------------------------------------- | ------- |
106 | | `MCP_HTTP_PORT`              | HTTP port for MCP server               | 12306   |
107 | | `MCP_ALLOWED_WORKSPACE_BASE` | Additional allowed workspace directory | (none)  |
108 | | `CHROME_MCP_NODE_PATH`       | Override Node.js executable path       | (auto)  |
109 | 
```

--------------------------------------------------------------------------------
/app/native-server/src/constant/index.ts:
--------------------------------------------------------------------------------

```typescript
 1 | export enum NATIVE_MESSAGE_TYPE {
 2 |   START = 'start',
 3 |   STARTED = 'started',
 4 |   STOP = 'stop',
 5 |   STOPPED = 'stopped',
 6 |   PING = 'ping',
 7 |   PONG = 'pong',
 8 |   ERROR = 'error',
 9 | }
10 | 
11 | export const NATIVE_SERVER_PORT = 12306;
12 | 
13 | // Timeout constants (in milliseconds)
14 | export const TIMEOUTS = {
15 |   DEFAULT_REQUEST_TIMEOUT: 15000,
16 |   EXTENSION_REQUEST_TIMEOUT: 20000,
17 |   PROCESS_DATA_TIMEOUT: 20000,
18 | } as const;
19 | 
20 | // Server configuration
21 | export const SERVER_CONFIG = {
22 |   HOST: '127.0.0.1',
23 |   /**
24 |    * CORS origin whitelist - only allow Chrome/Firefox extensions and local debugging.
25 |    * Use RegExp patterns for extension origins, string for exact match.
26 |    */
27 |   CORS_ORIGIN: [/^chrome-extension:\/\//, /^moz-extension:\/\//, 'http://127.0.0.1'] as const,
28 |   LOGGER_ENABLED: false,
29 | } as const;
30 | 
31 | // HTTP Status codes
32 | export const HTTP_STATUS = {
33 |   OK: 200,
34 |   CREATED: 201,
35 |   NO_CONTENT: 204,
36 |   BAD_REQUEST: 400,
37 |   NOT_FOUND: 404,
38 |   INTERNAL_SERVER_ERROR: 500,
39 |   GATEWAY_TIMEOUT: 504,
40 | } as const;
41 | 
42 | // Error messages
43 | export const ERROR_MESSAGES = {
44 |   NATIVE_HOST_NOT_AVAILABLE: 'Native host connection not established.',
45 |   SERVER_NOT_RUNNING: 'Server is not actively running.',
46 |   REQUEST_TIMEOUT: 'Request to extension timed out.',
47 |   INVALID_MCP_REQUEST: 'Invalid MCP request or session.',
48 |   INVALID_SESSION_ID: 'Invalid or missing MCP session ID.',
49 |   INTERNAL_SERVER_ERROR: 'Internal Server Error',
50 |   MCP_SESSION_DELETION_ERROR: 'Internal server error during MCP session deletion.',
51 |   MCP_REQUEST_PROCESSING_ERROR: 'Internal server error during MCP request processing.',
52 |   INVALID_SSE_SESSION: 'Invalid or missing MCP session ID for SSE.',
53 | } as const;
54 | 
55 | // ============================================================
56 | // Chrome MCP Server Configuration
57 | // ============================================================
58 | 
59 | /**
60 |  * Environment variables for dynamically resolving the local MCP HTTP endpoint.
61 |  * CHROME_MCP_PORT is the preferred source; MCP_HTTP_PORT is kept for backward compatibility.
62 |  */
63 | export const CHROME_MCP_PORT_ENV = 'CHROME_MCP_PORT';
64 | export const MCP_HTTP_PORT_ENV = 'MCP_HTTP_PORT';
65 | 
66 | /**
67 |  * Get the actual port the Chrome MCP server is listening on.
68 |  * Priority: CHROME_MCP_PORT env > MCP_HTTP_PORT env > NATIVE_SERVER_PORT default
69 |  */
70 | export function getChromeMcpPort(): number {
71 |   const raw = process.env[CHROME_MCP_PORT_ENV] || process.env[MCP_HTTP_PORT_ENV];
72 |   const port = raw ? Number.parseInt(String(raw), 10) : NaN;
73 |   return Number.isFinite(port) && port > 0 && port <= 65535 ? port : NATIVE_SERVER_PORT;
74 | }
75 | 
76 | /**
77 |  * Get the full URL to the local Chrome MCP HTTP endpoint.
78 |  * This URL is used by Claude/Codex agents to connect to the MCP server.
79 |  */
80 | export function getChromeMcpUrl(): string {
81 |   return `http://${SERVER_CONFIG.HOST}:${getChromeMcpPort()}/mcp`;
82 | }
83 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/composables/useAgentInputPreferences.ts:
--------------------------------------------------------------------------------

```typescript
 1 | /**
 2 |  * Composable for user-facing input preferences in AgentChat.
 3 |  * Preferences are persisted in chrome.storage.local.
 4 |  */
 5 | import { ref, type Ref } from 'vue';
 6 | 
 7 | // =============================================================================
 8 | // Constants
 9 | // =============================================================================
10 | 
11 | const STORAGE_KEY_FAKE_CARET = 'agent-chat-fake-caret-enabled';
12 | 
13 | // =============================================================================
14 | // Types
15 | // =============================================================================
16 | 
17 | export interface UseAgentInputPreferences {
18 |   /** Whether the fake caret + comet trail is enabled (opt-in). Default: false */
19 |   fakeCaretEnabled: Ref<boolean>;
20 |   /** Whether preferences have been loaded from storage */
21 |   ready: Ref<boolean>;
22 |   /** Load preferences from chrome.storage.local (call on mount) */
23 |   init: () => Promise<void>;
24 |   /** Persist and update fake caret preference */
25 |   setFakeCaretEnabled: (enabled: boolean) => Promise<void>;
26 | }
27 | 
28 | // =============================================================================
29 | // Composable
30 | // =============================================================================
31 | 
32 | /**
33 |  * Composable for managing user input preferences.
34 |  *
35 |  * Features:
36 |  * - Fake caret toggle (opt-in, default off)
37 |  * - Persistence via chrome.storage.local
38 |  * - Graceful fallback when storage is unavailable
39 |  */
40 | export function useAgentInputPreferences(): UseAgentInputPreferences {
41 |   const fakeCaretEnabled = ref(false);
42 |   const ready = ref(false);
43 | 
44 |   /**
45 |    * Load preferences from chrome.storage.local.
46 |    * Should be called during component mount.
47 |    */
48 |   async function init(): Promise<void> {
49 |     try {
50 |       if (typeof chrome === 'undefined' || !chrome.storage?.local) {
51 |         ready.value = true;
52 |         return;
53 |       }
54 | 
55 |       const result = await chrome.storage.local.get(STORAGE_KEY_FAKE_CARET);
56 |       const stored = result[STORAGE_KEY_FAKE_CARET];
57 | 
58 |       if (typeof stored === 'boolean') {
59 |         fakeCaretEnabled.value = stored;
60 |       }
61 |     } catch (error) {
62 |       console.error('[useAgentInputPreferences] Failed to load preferences:', error);
63 |     } finally {
64 |       ready.value = true;
65 |     }
66 |   }
67 | 
68 |   /**
69 |    * Update and persist the fake caret preference.
70 |    */
71 |   async function setFakeCaretEnabled(enabled: boolean): Promise<void> {
72 |     fakeCaretEnabled.value = enabled;
73 | 
74 |     try {
75 |       if (typeof chrome === 'undefined' || !chrome.storage?.local) return;
76 |       await chrome.storage.local.set({ [STORAGE_KEY_FAKE_CARET]: enabled });
77 |     } catch (error) {
78 |       console.error('[useAgentInputPreferences] Failed to save fake caret preference:', error);
79 |     }
80 |   }
81 | 
82 |   return {
83 |     fakeCaretEnabled,
84 |     ready,
85 |     init,
86 |     setFakeCaretEnabled,
87 |   };
88 | }
89 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/utils/offscreen-manager.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Offscreen Document manager
  3 |  * Ensures only one offscreen document is created across the entire extension to avoid conflicts
  4 |  */
  5 | 
  6 | export class OffscreenManager {
  7 |   private static instance: OffscreenManager | null = null;
  8 |   private isCreated = false;
  9 |   private isCreating = false;
 10 |   private createPromise: Promise<void> | null = null;
 11 | 
 12 |   private constructor() {}
 13 | 
 14 |   /**
 15 |    * Get singleton instance
 16 |    */
 17 |   public static getInstance(): OffscreenManager {
 18 |     if (!OffscreenManager.instance) {
 19 |       OffscreenManager.instance = new OffscreenManager();
 20 |     }
 21 |     return OffscreenManager.instance;
 22 |   }
 23 | 
 24 |   /**
 25 |    * Ensure offscreen document exists
 26 |    */
 27 |   public async ensureOffscreenDocument(): Promise<void> {
 28 |     if (this.isCreated) {
 29 |       return;
 30 |     }
 31 | 
 32 |     if (this.isCreating && this.createPromise) {
 33 |       return this.createPromise;
 34 |     }
 35 | 
 36 |     this.isCreating = true;
 37 |     this.createPromise = this._doCreateOffscreenDocument().finally(() => {
 38 |       this.isCreating = false;
 39 |     });
 40 | 
 41 |     return this.createPromise;
 42 |   }
 43 | 
 44 |   private async _doCreateOffscreenDocument(): Promise<void> {
 45 |     try {
 46 |       if (!chrome.offscreen) {
 47 |         throw new Error('Offscreen API not available. Chrome 109+ required.');
 48 |       }
 49 | 
 50 |       const existingContexts = await (chrome.runtime as any).getContexts({
 51 |         contextTypes: ['OFFSCREEN_DOCUMENT'],
 52 |       });
 53 | 
 54 |       if (existingContexts && existingContexts.length > 0) {
 55 |         console.log('OffscreenManager: Offscreen document already exists');
 56 |         this.isCreated = true;
 57 |         return;
 58 |       }
 59 | 
 60 |       await chrome.offscreen.createDocument({
 61 |         url: 'offscreen.html',
 62 |         reasons: ['WORKERS'],
 63 |         justification: 'Need to run semantic similarity engine with workers',
 64 |       });
 65 | 
 66 |       this.isCreated = true;
 67 |       console.log('OffscreenManager: Offscreen document created successfully');
 68 |     } catch (error) {
 69 |       console.error('OffscreenManager: Failed to create offscreen document:', error);
 70 |       this.isCreated = false;
 71 |       throw error;
 72 |     }
 73 |   }
 74 | 
 75 |   /**
 76 |    * Check if offscreen document is created
 77 |    */
 78 |   public isOffscreenDocumentCreated(): boolean {
 79 |     return this.isCreated;
 80 |   }
 81 | 
 82 |   /**
 83 |    * Close offscreen document
 84 |    */
 85 |   public async closeOffscreenDocument(): Promise<void> {
 86 |     try {
 87 |       if (chrome.offscreen && this.isCreated) {
 88 |         await chrome.offscreen.closeDocument();
 89 |         this.isCreated = false;
 90 |         console.log('OffscreenManager: Offscreen document closed');
 91 |       }
 92 |     } catch (error) {
 93 |       console.error('OffscreenManager: Failed to close offscreen document:', error);
 94 |     }
 95 |   }
 96 | 
 97 |   /**
 98 |    * Reset state (for testing)
 99 |    */
100 |   public reset(): void {
101 |     this.isCreated = false;
102 |     this.isCreating = false;
103 |     this.createPromise = null;
104 |   }
105 | }
106 | 
107 | 
108 | export const offscreenManager = OffscreenManager.getInstance();
109 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/recording/content-message-handler.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { RecordingSessionManager } from './session-manager';
 2 | import type { Step, VariableDef } from '../types';
 3 | import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
 4 | 
 5 | /**
 6 |  * Initialize the content message handler for receiving steps and variables from content scripts.
 7 |  *
 8 |  * Supports the following payload kinds:
 9 |  * - 'steps' | 'step': Append steps to the current flow
10 |  * - 'variables': Append variables to the current flow (for sensitive input handling)
11 |  * - 'finalize': Content script has finished flushing (used during stop barrier)
12 |  */
13 | export function initContentMessageHandler(session: RecordingSessionManager): void {
14 |   chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
15 |     try {
16 |       if (!message || message.type !== TOOL_MESSAGE_TYPES.RR_RECORDER_EVENT) return false;
17 | 
18 |       // Accept messages during 'recording' or 'stopping' states
19 |       // 'stopping' allows final steps to arrive during the drain phase
20 |       if (!session.canAcceptSteps()) {
21 |         sendResponse({ ok: true, ignored: true });
22 |         return true;
23 |       }
24 | 
25 |       const flow = session.getFlow();
26 |       if (!flow) {
27 |         sendResponse({ ok: true, ignored: true });
28 |         return true;
29 |       }
30 | 
31 |       const payload = message?.payload || {};
32 | 
33 |       // Handle steps
34 |       if (payload.kind === 'steps' || payload.kind === 'step') {
35 |         const steps: Step[] = Array.isArray(payload.steps)
36 |           ? (payload.steps as Step[])
37 |           : payload.step
38 |             ? [payload.step as Step]
39 |             : [];
40 |         if (steps.length > 0) {
41 |           session.appendSteps(steps);
42 |         }
43 |       }
44 | 
45 |       // Handle variables (for sensitive input handling)
46 |       if (payload.kind === 'variables') {
47 |         const variables: VariableDef[] = Array.isArray(payload.variables)
48 |           ? (payload.variables as VariableDef[])
49 |           : [];
50 |         if (variables.length > 0) {
51 |           session.appendVariables(variables);
52 |         }
53 |       }
54 | 
55 |       // Handle combined payload (steps + variables in one message)
56 |       if (payload.kind === 'batch') {
57 |         const steps: Step[] = Array.isArray(payload.steps) ? (payload.steps as Step[]) : [];
58 |         const variables: VariableDef[] = Array.isArray(payload.variables)
59 |           ? (payload.variables as VariableDef[])
60 |           : [];
61 |         if (steps.length > 0) {
62 |           session.appendSteps(steps);
63 |         }
64 |         if (variables.length > 0) {
65 |           session.appendVariables(variables);
66 |         }
67 |       }
68 | 
69 |       // payload.kind === 'start'|'stop'|'finalize' are no-ops here (lifecycle handled elsewhere)
70 |       sendResponse({ ok: true });
71 |       return true;
72 |     } catch (e) {
73 |       console.warn('ContentMessageHandler: processing message failed', e);
74 |       sendResponse({ ok: false, error: String((e as Error)?.message || e) });
75 |       return true;
76 |     }
77 |   });
78 | }
79 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts:
--------------------------------------------------------------------------------

```typescript
 1 | /**
 2 |  * @fileoverview Register RR-V2 replay action handlers as RR-V3 nodes
 3 |  * @description
 4 |  * Batch registration of V2 action handlers into the V3 PluginRegistry.
 5 |  * This enables V3 to execute flows that use V2 action types.
 6 |  */
 7 | 
 8 | import { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions/handlers';
 9 | import type {
10 |   ActionHandler,
11 |   ExecutableActionType,
12 | } from '@/entrypoints/background/record-replay/actions/types';
13 | 
14 | import type { PluginRegistry } from './registry';
15 | import {
16 |   adaptV2ActionHandlerToV3NodeDefinition,
17 |   type V2ActionNodeAdapterOptions,
18 | } from './v2-action-adapter';
19 | 
20 | export interface RegisterV2ReplayNodesOptions extends V2ActionNodeAdapterOptions {
21 |   /**
22 |    * Only include these action types. If not specified, all V2 handlers are included.
23 |    */
24 |   include?: ReadonlyArray<string>;
25 | 
26 |   /**
27 |    * Exclude these action types. Applied after include filter.
28 |    */
29 |   exclude?: ReadonlyArray<string>;
30 | }
31 | 
32 | /**
33 |  * Register V2 replay action handlers as V3 node definitions.
34 |  *
35 |  * @param registry The V3 PluginRegistry to register nodes into
36 |  * @param options Configuration options
37 |  * @returns Array of registered node kinds
38 |  *
39 |  * @example
40 |  * ```ts
41 |  * const plugins = new PluginRegistry();
42 |  * const registered = registerV2ReplayNodesAsV3Nodes(plugins, {
43 |  *   // Exclude control flow handlers that V3 runner doesn't support
44 |  *   exclude: ['foreach', 'while'],
45 |  * });
46 |  * console.log('Registered:', registered);
47 |  * ```
48 |  */
49 | export function registerV2ReplayNodesAsV3Nodes(
50 |   registry: PluginRegistry,
51 |   options: RegisterV2ReplayNodesOptions = {},
52 | ): string[] {
53 |   const actionRegistry = createReplayActionRegistry();
54 |   const handlers = actionRegistry.list();
55 | 
56 |   const include = options.include ? new Set(options.include) : null;
57 |   const exclude = options.exclude ? new Set(options.exclude) : null;
58 | 
59 |   const registered: string[] = [];
60 | 
61 |   for (const handler of handlers) {
62 |     if (include && !include.has(handler.type)) continue;
63 |     if (exclude && exclude.has(handler.type)) continue;
64 | 
65 |     // Cast needed because V2 handler types don't perfectly align with V3 NodeKind
66 |     const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(
67 |       handler as ActionHandler<ExecutableActionType>,
68 |       options,
69 |     );
70 |     registry.registerNode(nodeDef as unknown as Parameters<typeof registry.registerNode>[0]);
71 |     registered.push(handler.type);
72 |   }
73 | 
74 |   return registered;
75 | }
76 | 
77 | /**
78 |  * Get list of V2 action types that can be registered.
79 |  * Useful for debugging and documentation.
80 |  */
81 | export function listV2ActionTypes(): string[] {
82 |   const actionRegistry = createReplayActionRegistry();
83 |   return actionRegistry.list().map((h) => h.type);
84 | }
85 | 
86 | /**
87 |  * Default exclude list for V3 registration.
88 |  * These handlers rely on V2 control directives that V3 runner doesn't support.
89 |  */
90 | export const DEFAULT_V2_EXCLUDE_LIST = ['foreach', 'while'] as const;
91 | 
```

--------------------------------------------------------------------------------
/docs/TROUBLESHOOTING.md:
--------------------------------------------------------------------------------

```markdown
 1 | # 🚀 Installation and Connection Issues
 2 | 
 3 | ## Quick Diagnosis
 4 | 
 5 | Run the diagnostic tool to identify common issues:
 6 | 
 7 | ```bash
 8 | mcp-chrome-bridge doctor
 9 | ```
10 | 
11 | To automatically fix common issues:
12 | 
13 | ```bash
14 | mcp-chrome-bridge doctor --fix
15 | ```
16 | 
17 | ## Export Report for GitHub Issues
18 | 
19 | If you need to open an issue, export a diagnostic report:
20 | 
21 | ```bash
22 | # Print Markdown report to terminal (copy/paste into GitHub Issue)
23 | mcp-chrome-bridge report
24 | 
25 | # Write to a file
26 | mcp-chrome-bridge report --output mcp-report.md
27 | 
28 | # Copy directly to clipboard
29 | mcp-chrome-bridge report --copy
30 | ```
31 | 
32 | By default, usernames, paths, and tokens are redacted. Use `--no-redact` if you're comfortable sharing full paths.
33 | 
34 | ## If Connection Fails After Clicking the Connect Button on the Extension
35 | 
36 | 1. **Run the diagnostic tool first**
37 | 
38 | ```bash
39 | mcp-chrome-bridge doctor
40 | ```
41 | 
42 | This will check installation, manifest, permissions, and Node.js path.
43 | 
44 | 2. **Check if mcp-chrome-bridge is installed successfully**, ensure it's globally installed
45 | 
46 | ```bash
47 | mcp-chrome-bridge -V
48 | ```
49 | 
50 | <img width="612" alt="Screenshot 2025-06-11 15 09 57" src="https://github.com/user-attachments/assets/59458532-e6e1-457c-8c82-3756a5dbb28e" />
51 | 
52 | 2. **Check if the manifest file is in the correct directory**
53 | 
54 | Windows path: C:\Users\xxx\AppData\Roaming\Google\Chrome\NativeMessagingHosts
55 | 
56 | Mac path: /Users/xxx/Library/Application\ Support/Google/Chrome/NativeMessagingHosts
57 | 
58 | If the npm package is installed correctly, a file named `com.chromemcp.nativehost.json` should be generated in this directory
59 | 
60 | 3. **Check logs**
61 |    Logs are now stored in user-writable directories:
62 | 
63 | - **macOS**: `~/Library/Logs/mcp-chrome-bridge/`
64 | - **Windows**: `%LOCALAPPDATA%\mcp-chrome-bridge\logs\`
65 | - **Linux**: `~/.local/state/mcp-chrome-bridge/logs/`
66 | 
67 | <img width="804" alt="Screenshot 2025-06-11 15 09 41" src="https://github.com/user-attachments/assets/ce7b7c94-7c84-409a-8210-c9317823aae1" />
68 | 
69 | 4. **Check if you have execution permissions**
70 |    You need to check your installation path (if unclear, open the manifest file in step 2, the path field shows the installation directory). For example, if the Mac installation path is as follows:
71 | 
72 | `xxx/node_modules/mcp-chrome-bridge/dist/run_host.sh`
73 | 
74 | Check if this script has execution permissions. Run to fix:
75 | 
76 | ```bash
77 | mcp-chrome-bridge fix-permissions
78 | ```
79 | 
80 | 5. **Node.js not found**
81 |    If you use a Node version manager (nvm, volta, asdf, fnm), the wrapper script may not find Node.js. Set the `CHROME_MCP_NODE_PATH` environment variable:
82 | 
83 | ```bash
84 | export CHROME_MCP_NODE_PATH=/path/to/your/node
85 | ```
86 | 
87 | Or run `mcp-chrome-bridge doctor --fix` to write the current Node path.
88 | 
89 | ## Log Locations
90 | 
91 | Wrapper logs are now stored in user-writable locations:
92 | 
93 | - **macOS**: `~/Library/Logs/mcp-chrome-bridge/`
94 | - **Windows**: `%LOCALAPPDATA%\mcp-chrome-bridge\logs\`
95 | - **Linux**: `~/.local/state/mcp-chrome-bridge/logs/`
96 | 
```

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

```vue
  1 | <template>
  2 |   <form class="p-3 border-t border-slate-200 bg-white space-y-2" @submit.prevent="handleSubmit">
  3 |     <!-- Attachments preview -->
  4 |     <AttachmentPreview
  5 |       v-if="attachments.length > 0"
  6 |       :attachments="attachments"
  7 |       @remove="$emit('remove-attachment', $event)"
  8 |     />
  9 | 
 10 |     <textarea
 11 |       v-model="inputValue"
 12 |       class="w-full border border-slate-200 rounded-md px-3 py-2 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-slate-400"
 13 |       rows="2"
 14 |       placeholder="Ask the agent to work with your browser via MCP..."
 15 |       @input="$emit('update:modelValue', inputValue)"
 16 |     ></textarea>
 17 | 
 18 |     <!-- Hidden file input -->
 19 |     <input
 20 |       ref="fileInputRef"
 21 |       type="file"
 22 |       class="hidden"
 23 |       accept="image/*"
 24 |       multiple
 25 |       @change="$emit('file-select', $event)"
 26 |     />
 27 | 
 28 |     <div class="flex items-center justify-between gap-2">
 29 |       <div class="flex items-center gap-2">
 30 |         <button
 31 |           type="button"
 32 |           class="text-slate-500 hover:text-slate-700 text-xs px-2 py-1 border border-slate-200 rounded hover:bg-slate-50"
 33 |           title="Attach images"
 34 |           @click="openFilePicker"
 35 |         >
 36 |           Attach
 37 |         </button>
 38 |         <div class="text-[11px] text-slate-500">
 39 |           {{ isStreaming ? 'Agent is thinking...' : 'Ready' }}
 40 |         </div>
 41 |       </div>
 42 |       <div class="flex gap-2">
 43 |         <button
 44 |           v-if="isStreaming && canCancel"
 45 |           type="button"
 46 |           class="btn-secondary !px-3 !py-2 text-xs"
 47 |           :disabled="cancelling"
 48 |           @click="$emit('cancel')"
 49 |         >
 50 |           {{ cancelling ? 'Cancelling...' : 'Stop' }}
 51 |         </button>
 52 |         <button
 53 |           type="submit"
 54 |           class="btn-primary !px-4 !py-2 text-xs"
 55 |           :disabled="!canSend || sending"
 56 |         >
 57 |           {{ sending ? 'Sending...' : 'Send' }}
 58 |         </button>
 59 |       </div>
 60 |     </div>
 61 |   </form>
 62 | </template>
 63 | 
 64 | <script lang="ts" setup>
 65 | import { ref, watch } from 'vue';
 66 | import type { AgentAttachment } from 'chrome-mcp-shared';
 67 | import AttachmentPreview from './AttachmentPreview.vue';
 68 | 
 69 | const props = defineProps<{
 70 |   modelValue: string;
 71 |   attachments: AgentAttachment[];
 72 |   isStreaming: boolean;
 73 |   sending: boolean;
 74 |   cancelling: boolean;
 75 |   canCancel: boolean;
 76 |   canSend: boolean;
 77 | }>();
 78 | 
 79 | const emit = defineEmits<{
 80 |   'update:modelValue': [value: string];
 81 |   submit: [];
 82 |   cancel: [];
 83 |   'file-select': [event: Event];
 84 |   'remove-attachment': [index: number];
 85 | }>();
 86 | 
 87 | const inputValue = ref(props.modelValue);
 88 | const fileInputRef = ref<HTMLInputElement | null>(null);
 89 | 
 90 | // Sync with parent
 91 | watch(
 92 |   () => props.modelValue,
 93 |   (newVal) => {
 94 |     inputValue.value = newVal;
 95 |   },
 96 | );
 97 | 
 98 | function openFilePicker(): void {
 99 |   fileInputRef.value?.click();
100 | }
101 | 
102 | function handleSubmit(): void {
103 |   emit('submit');
104 | }
105 | 
106 | // Expose file input ref for parent
107 | defineExpose({
108 |   fileInputRef,
109 | });
110 | </script>
111 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyHttp.vue:
--------------------------------------------------------------------------------

```vue
  1 | <template>
  2 |   <div class="form-section">
  3 |     <div class="form-group">
  4 |       <label class="form-label">请求方法</label>
  5 |       <select class="form-select" v-model="(node as any).config.method">
  6 |         <option>GET</option>
  7 |         <option>POST</option>
  8 |         <option>PUT</option>
  9 |         <option>PATCH</option>
 10 |         <option>DELETE</option>
 11 |       </select>
 12 |     </div>
 13 |     <div class="form-group" :class="{ invalid: !(node as any).config?.url }" data-field="http.url">
 14 |       <label class="form-label">URL 地址</label>
 15 |       <input
 16 |         class="form-input"
 17 |         v-model="(node as any).config.url"
 18 |         placeholder="https://api.example.com/data"
 19 |       />
 20 |     </div>
 21 |     <div class="form-group">
 22 |       <label class="form-label">Headers (JSON)</label>
 23 |       <textarea
 24 |         class="form-textarea"
 25 |         v-model="headersJson"
 26 |         rows="3"
 27 |         placeholder='{"Content-Type": "application/json"}'
 28 |       ></textarea>
 29 |     </div>
 30 |     <div class="form-group">
 31 |       <label class="form-label">Body (JSON)</label>
 32 |       <textarea
 33 |         class="form-textarea"
 34 |         v-model="bodyJson"
 35 |         rows="3"
 36 |         placeholder='{"key": "value"}'
 37 |       ></textarea>
 38 |     </div>
 39 |     <div class="form-group">
 40 |       <label class="form-label">FormData (JSON,可选,提供时覆盖 Body)</label>
 41 |       <textarea
 42 |         class="form-textarea"
 43 |         v-model="formDataJson"
 44 |         rows="3"
 45 |         placeholder='{"fields":{"k":"v"},"files":[{"name":"file","fileUrl":"https://...","filename":"a.png"}]}'
 46 |       ></textarea>
 47 |       <div class="text-xs text-slate-500" style="margin-top: 6px"
 48 |         >支持简洁数组形式:[["file","url:https://...","a.png"],["metadata","value"]]</div
 49 |       >
 50 |     </div>
 51 |   </div>
 52 | </template>
 53 | 
 54 | <script lang="ts" setup>
 55 | /* eslint-disable vue/no-mutating-props */
 56 | import { computed } from 'vue';
 57 | import type { NodeBase } from '@/entrypoints/background/record-replay/types';
 58 | 
 59 | const props = defineProps<{ node: NodeBase }>();
 60 | 
 61 | const headersJson = computed({
 62 |   get() {
 63 |     try {
 64 |       return JSON.stringify((props.node as any).config?.headers || {}, null, 2);
 65 |     } catch {
 66 |       return '';
 67 |     }
 68 |   },
 69 |   set(v: string) {
 70 |     try {
 71 |       (props.node as any).config.headers = JSON.parse(v || '{}');
 72 |     } catch {}
 73 |   },
 74 | });
 75 | const bodyJson = computed({
 76 |   get() {
 77 |     try {
 78 |       return JSON.stringify((props.node as any).config?.body ?? null, null, 2);
 79 |     } catch {
 80 |       return '';
 81 |     }
 82 |   },
 83 |   set(v: string) {
 84 |     try {
 85 |       (props.node as any).config.body = v ? JSON.parse(v) : null;
 86 |     } catch {}
 87 |   },
 88 | });
 89 | const formDataJson = computed({
 90 |   get() {
 91 |     try {
 92 |       return (props.node as any).config?.formData
 93 |         ? JSON.stringify((props.node as any).config.formData, null, 2)
 94 |         : '';
 95 |     } catch {
 96 |       return '';
 97 |     }
 98 |   },
 99 |   set(v: string) {
100 |     try {
101 |       (props.node as any).config.formData = v ? JSON.parse(v) : undefined;
102 |     } catch {}
103 |   },
104 | });
105 | </script>
106 | 
107 | <style scoped></style>
108 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/index.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { initNativeHostListener } from './native-host';
 2 | import {
 3 |   initSemanticSimilarityListener,
 4 |   initializeSemanticEngineIfCached,
 5 | } from './semantic-similarity';
 6 | import { initStorageManagerListener } from './storage-manager';
 7 | import { cleanupModelCache } from '@/utils/semantic-similarity-engine';
 8 | import { initRecordReplayListeners } from './record-replay';
 9 | import { initElementMarkerListeners } from './element-marker';
10 | import { initWebEditorListeners } from './web-editor';
11 | import { initQuickPanelAgentHandler } from './quick-panel/agent-handler';
12 | import { initQuickPanelCommands } from './quick-panel/commands';
13 | import { initQuickPanelTabsHandler } from './quick-panel/tabs-handler';
14 | 
15 | // Record-Replay V3 (feature flag)
16 | import { bootstrapV3 } from './record-replay-v3/bootstrap';
17 | 
18 | /**
19 |  * Feature flag for RR-V3
20 |  * Set to true to enable the new Record-Replay V3 engine
21 |  */
22 | const ENABLE_RR_V3 = true;
23 | 
24 | /**
25 |  * Background script entry point
26 |  * Initializes all background services and listeners
27 |  */
28 | export default defineBackground(() => {
29 |   // Open welcome page on first install
30 |   chrome.runtime.onInstalled.addListener((details) => {
31 |     if (details.reason === 'install') {
32 |       // Open the welcome/onboarding page for new installations
33 |       chrome.tabs.create({
34 |         url: chrome.runtime.getURL('/welcome.html'),
35 |       });
36 |     }
37 |   });
38 | 
39 |   // Initialize core services
40 |   initNativeHostListener();
41 |   initSemanticSimilarityListener();
42 |   initStorageManagerListener();
43 |   // Record & Replay V1/V2 listeners
44 |   initRecordReplayListeners();
45 | 
46 |   // Record & Replay V3 (new engine)
47 |   if (ENABLE_RR_V3) {
48 |     bootstrapV3()
49 |       .then((runtime) => {
50 |         console.log(`[RR-V3] Bootstrap complete, ownerId: ${runtime.ownerId}`);
51 |       })
52 |       .catch((error) => {
53 |         console.error('[RR-V3] Bootstrap failed:', error);
54 |       });
55 |   }
56 | 
57 |   // Element marker: context menu + CRUD listeners
58 |   initElementMarkerListeners();
59 |   // Web editor: toggle edit-mode overlay
60 |   initWebEditorListeners();
61 |   // Quick Panel: send messages to AgentChat via background-stream bridge
62 |   initQuickPanelAgentHandler();
63 |   // Quick Panel: tabs search bridge for content script UI
64 |   initQuickPanelTabsHandler();
65 |   // Quick Panel: keyboard shortcut handler
66 |   initQuickPanelCommands();
67 | 
68 |   // Conditionally initialize semantic similarity engine if model cache exists
69 |   initializeSemanticEngineIfCached()
70 |     .then((initialized) => {
71 |       if (initialized) {
72 |         console.log('Background: Semantic similarity engine initialized from cache');
73 |       } else {
74 |         console.log(
75 |           'Background: Semantic similarity engine initialization skipped (no cache found)',
76 |         );
77 |       }
78 |     })
79 |     .catch((error) => {
80 |       console.warn('Background: Failed to conditionally initialize semantic engine:', error);
81 |     });
82 | 
83 |   // Initial cleanup on startup
84 |   cleanupModelCache().catch((error) => {
85 |     console.warn('Background: Initial cache cleanup failed:', error);
86 |   });
87 | });
88 | 
```

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

```vue
  1 | <template>
  2 |   <div
  3 |     v-if="open"
  4 |     class="fixed top-12 right-4 z-50 min-w-[160px] py-2"
  5 |     :style="{
  6 |       backgroundColor: 'var(--ac-surface, #ffffff)',
  7 |       border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',
  8 |       borderRadius: 'var(--ac-radius-inner, 8px)',
  9 |       boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0,0,0,0.1))',
 10 |     }"
 11 |   >
 12 |     <!-- Header -->
 13 |     <div
 14 |       class="px-3 py-1 text-[10px] font-bold uppercase tracking-wider"
 15 |       :style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
 16 |     >
 17 |       Open In
 18 |     </div>
 19 | 
 20 |     <!-- VS Code Option -->
 21 |     <button
 22 |       class="w-full px-3 py-2 text-left text-sm flex items-center gap-2 ac-menu-item"
 23 |       :style="{
 24 |         color: defaultTarget === 'vscode' ? 'var(--ac-accent, #c87941)' : 'var(--ac-text, #1a1a1a)',
 25 |       }"
 26 |       @click="handleSelect('vscode')"
 27 |     >
 28 |       <!-- VS Code Icon -->
 29 |       <svg class="w-4 h-4 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
 30 |         <path
 31 |           d="M17.583 2L6.167 11.667 2 8.5v7l4.167-3.167L17.583 22 22 19.75V4.25L17.583 2zm0 3.5v13l-8-6.5 8-6.5z"
 32 |         />
 33 |       </svg>
 34 |       <span class="flex-1">VS Code</span>
 35 |       <!-- Default indicator -->
 36 |       <svg
 37 |         v-if="defaultTarget === 'vscode'"
 38 |         class="w-4 h-4 flex-shrink-0"
 39 |         fill="none"
 40 |         viewBox="0 0 24 24"
 41 |         stroke="currentColor"
 42 |       >
 43 |         <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
 44 |       </svg>
 45 |     </button>
 46 | 
 47 |     <!-- Terminal Option -->
 48 |     <button
 49 |       class="w-full px-3 py-2 text-left text-sm flex items-center gap-2 ac-menu-item"
 50 |       :style="{
 51 |         color:
 52 |           defaultTarget === 'terminal' ? 'var(--ac-accent, #c87941)' : 'var(--ac-text, #1a1a1a)',
 53 |       }"
 54 |       @click="handleSelect('terminal')"
 55 |     >
 56 |       <!-- Terminal Icon -->
 57 |       <svg
 58 |         class="w-4 h-4 flex-shrink-0"
 59 |         fill="none"
 60 |         viewBox="0 0 24 24"
 61 |         stroke="currentColor"
 62 |         stroke-width="2"
 63 |       >
 64 |         <path
 65 |           stroke-linecap="round"
 66 |           stroke-linejoin="round"
 67 |           d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
 68 |         />
 69 |       </svg>
 70 |       <span class="flex-1">Terminal</span>
 71 |       <!-- Default indicator -->
 72 |       <svg
 73 |         v-if="defaultTarget === 'terminal'"
 74 |         class="w-4 h-4 flex-shrink-0"
 75 |         fill="none"
 76 |         viewBox="0 0 24 24"
 77 |         stroke="currentColor"
 78 |       >
 79 |         <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
 80 |       </svg>
 81 |     </button>
 82 |   </div>
 83 | </template>
 84 | 
 85 | <script lang="ts" setup>
 86 | import type { OpenProjectTarget } from 'chrome-mcp-shared';
 87 | 
 88 | defineProps<{
 89 |   open: boolean;
 90 |   defaultTarget: OpenProjectTarget | null;
 91 | }>();
 92 | 
 93 | const emit = defineEmits<{
 94 |   select: [target: OpenProjectTarget];
 95 |   close: [];
 96 | }>();
 97 | 
 98 | function handleSelect(target: OpenProjectTarget): void {
 99 |   emit('select', target);
100 |   emit('close');
101 | }
102 | </script>
103 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/shared/selector/strategies/css-unique.ts:
--------------------------------------------------------------------------------

```typescript
 1 | /**
 2 |  * CSS Unique Strategy - 基于唯一 ID 或 class 组合的选择器策略
 3 |  */
 4 | 
 5 | import type { SelectorCandidate, SelectorStrategy } from '../types';
 6 | 
 7 | const MAX_CLASS_COUNT = 24;
 8 | const MAX_COMBO_CLASSES = 8;
 9 | const MAX_CANDIDATES = 6;
10 | 
11 | function isValidClassToken(token: string): boolean {
12 |   return /^[a-zA-Z0-9_-]+$/.test(token);
13 | }
14 | 
15 | export const cssUniqueStrategy: SelectorStrategy = {
16 |   id: 'css-unique',
17 |   generate(ctx) {
18 |     if (!ctx.options.includeCssUnique) return [];
19 | 
20 |     const { element, helpers } = ctx;
21 |     const out: SelectorCandidate[] = [];
22 | 
23 |     const tag = element.tagName?.toLowerCase?.() ?? '';
24 | 
25 |     // 1) Unique ID selector
26 |     const id = element.id?.trim();
27 |     if (id) {
28 |       const sel = `#${helpers.cssEscape(id)}`;
29 |       if (helpers.isUnique(sel)) {
30 |         out.push({ type: 'css', value: sel, source: 'generated', strategy: 'css-unique' });
31 |       }
32 |     }
33 | 
34 |     if (out.length >= MAX_CANDIDATES) return out;
35 | 
36 |     // 2) Unique class selectors
37 |     const classList = Array.from(element.classList || [])
38 |       .map((c) => String(c).trim())
39 |       .filter((c) => c.length > 0 && isValidClassToken(c))
40 |       .slice(0, MAX_CLASS_COUNT);
41 | 
42 |     for (const cls of classList) {
43 |       if (out.length >= MAX_CANDIDATES) break;
44 |       const sel = `.${helpers.cssEscape(cls)}`;
45 |       if (helpers.isUnique(sel)) {
46 |         out.push({ type: 'css', value: sel, source: 'generated', strategy: 'css-unique' });
47 |       }
48 |     }
49 | 
50 |     if (tag) {
51 |       for (const cls of classList) {
52 |         if (out.length >= MAX_CANDIDATES) break;
53 |         const sel = `${tag}.${helpers.cssEscape(cls)}`;
54 |         if (helpers.isUnique(sel)) {
55 |           out.push({ type: 'css', value: sel, source: 'generated', strategy: 'css-unique' });
56 |         }
57 |       }
58 |     }
59 | 
60 |     if (out.length >= MAX_CANDIDATES) return out;
61 | 
62 |     // 3) Class combinations (depth 2/3), limited to avoid heavy queries.
63 |     const comboSource = classList.slice(0, MAX_COMBO_CLASSES).map((c) => helpers.cssEscape(c));
64 | 
65 |     const tryPush = (selector: string): void => {
66 |       if (out.length >= MAX_CANDIDATES) return;
67 |       if (!helpers.isUnique(selector)) return;
68 |       out.push({ type: 'css', value: selector, source: 'generated', strategy: 'css-unique' });
69 |     };
70 | 
71 |     const tryPushWithTag = (selector: string): void => {
72 |       tryPush(selector);
73 |       if (tag) tryPush(`${tag}${selector}`);
74 |     };
75 | 
76 |     // Depth 2
77 |     for (let i = 0; i < comboSource.length && out.length < MAX_CANDIDATES; i++) {
78 |       for (let j = i + 1; j < comboSource.length && out.length < MAX_CANDIDATES; j++) {
79 |         tryPushWithTag(`.${comboSource[i]}.${comboSource[j]}`);
80 |       }
81 |     }
82 | 
83 |     // Depth 3
84 |     for (let i = 0; i < comboSource.length && out.length < MAX_CANDIDATES; i++) {
85 |       for (let j = i + 1; j < comboSource.length && out.length < MAX_CANDIDATES; j++) {
86 |         for (let k = j + 1; k < comboSource.length && out.length < MAX_CANDIDATES; k++) {
87 |           tryPushWithTag(`.${comboSource[i]}.${comboSource[j]}.${comboSource[k]}`);
88 |         }
89 |       }
90 |     }
91 | 
92 |     return out;
93 |   },
94 | };
95 | 
```

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

```vue
 1 | <template>
 2 |   <div
 3 |     class="px-4 py-2 border-b border-slate-100 flex flex-col gap-2 text-xs text-slate-600 bg-slate-50"
 4 |   >
 5 |     <!-- Project selection & workspace -->
 6 |     <div class="flex items-center gap-2">
 7 |       <span class="whitespace-nowrap">Project</span>
 8 |       <select
 9 |         :value="selectedProjectId"
10 |         class="flex-1 border border-slate-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-slate-400"
11 |         @change="handleProjectChange"
12 |       >
13 |         <option v-for="p in projects" :key="p.id" :value="p.id">
14 |           {{ p.name }}
15 |         </option>
16 |       </select>
17 |       <button
18 |         class="btn-secondary !px-2 !py-1 text-[11px]"
19 |         type="button"
20 |         :disabled="isPicking"
21 |         title="Create new project from a directory"
22 |         @click="$emit('new-project')"
23 |       >
24 |         {{ isPicking ? '...' : 'New' }}
25 |       </button>
26 |     </div>
27 | 
28 |     <!-- Current workspace path -->
29 |     <div v-if="selectedProject" class="flex items-center gap-2 text-[11px] text-slate-500">
30 |       <span class="whitespace-nowrap">Path</span>
31 |       <span class="flex-1 font-mono truncate" :title="selectedProject.rootPath">
32 |         {{ selectedProject.rootPath }}
33 |       </span>
34 |     </div>
35 | 
36 |     <!-- CLI & Model selection -->
37 |     <CliSettings
38 |       :project-root="projectRoot"
39 |       :selected-cli="selectedCli"
40 |       :model="model"
41 |       :use-ccr="useCcr"
42 |       :engines="engines"
43 |       :selected-project="selectedProject"
44 |       :is-saving-root="isSavingProjectRoot"
45 |       :is-saving-preference="isSavingPreference"
46 |       @update:project-root="$emit('update:projectRoot', $event)"
47 |       @update:selected-cli="$emit('update:selectedCli', $event)"
48 |       @update:model="$emit('update:model', $event)"
49 |       @update:use-ccr="$emit('update:useCcr', $event)"
50 |       @save-root="$emit('save-root')"
51 |       @save-preference="$emit('save-preference')"
52 |     />
53 | 
54 |     <!-- Error message -->
55 |     <div v-if="error" class="text-[11px] text-red-600">
56 |       {{ error }}
57 |     </div>
58 |   </div>
59 | </template>
60 | 
61 | <script lang="ts" setup>
62 | import type { AgentProject, AgentEngineInfo } from 'chrome-mcp-shared';
63 | import CliSettings from './CliSettings.vue';
64 | 
65 | defineProps<{
66 |   projects: AgentProject[];
67 |   selectedProjectId: string;
68 |   selectedProject: AgentProject | null;
69 |   isPicking: boolean;
70 |   error: string | null;
71 |   projectRoot: string;
72 |   selectedCli: string;
73 |   model: string;
74 |   useCcr: boolean;
75 |   engines: AgentEngineInfo[];
76 |   isSavingProjectRoot: boolean;
77 |   isSavingPreference: boolean;
78 | }>();
79 | 
80 | const emit = defineEmits<{
81 |   'update:selectedProjectId': [value: string];
82 |   'project-changed': [];
83 |   'new-project': [];
84 |   'update:projectRoot': [value: string];
85 |   'update:selectedCli': [value: string];
86 |   'update:model': [value: string];
87 |   'update:useCcr': [value: boolean];
88 |   'save-root': [];
89 |   'save-preference': [];
90 | }>();
91 | 
92 | function handleProjectChange(event: Event): void {
93 |   const value = (event.target as HTMLSelectElement).value;
94 |   emit('update:selectedProjectId', value);
95 |   emit('project-changed');
96 | }
97 | </script>
98 | 
```

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

```vue
  1 | <template>
  2 |   <div class="py-1">
  3 |     <div
  4 |       class="text-sm leading-relaxed markdown-content"
  5 |       :style="{
  6 |         color: 'var(--ac-text)',
  7 |         fontFamily: 'var(--ac-font-body)',
  8 |       }"
  9 |     >
 10 |       <MarkdownRender
 11 |         :content="item.text ?? ''"
 12 |         :custom-id="AGENTCHAT_MD_SCOPE"
 13 |         :custom-html-tags="CUSTOM_HTML_TAGS"
 14 |         :max-live-nodes="0"
 15 |         :render-batch-size="16"
 16 |         :render-batch-delay="8"
 17 |       />
 18 |     </div>
 19 |     <span
 20 |       v-if="item.isStreaming"
 21 |       class="inline-block w-1.5 h-4 ml-0.5 ac-pulse"
 22 |       :style="{ backgroundColor: 'var(--ac-accent)' }"
 23 |     />
 24 |   </div>
 25 | </template>
 26 | 
 27 | <script lang="ts" setup>
 28 | import type { TimelineItem } from '../../../composables/useAgentThreads';
 29 | import MarkdownRender from 'markstream-vue';
 30 | import 'markstream-vue/index.css';
 31 | // Import to register custom components (side-effect)
 32 | import { AGENTCHAT_MD_SCOPE } from './markstream-thinking';
 33 | 
 34 | /** Custom HTML tags to be rendered by registered custom components */
 35 | const CUSTOM_HTML_TAGS = ['thinking'] as const;
 36 | 
 37 | defineProps<{
 38 |   item: Extract<TimelineItem, { kind: 'assistant_text' }>;
 39 | }>();
 40 | </script>
 41 | 
 42 | <style scoped>
 43 | .markdown-content :deep(pre) {
 44 |   background-color: var(--ac-code-bg);
 45 |   border: var(--ac-border-width) solid var(--ac-code-border);
 46 |   border-radius: var(--ac-radius-inner);
 47 |   padding: 12px;
 48 |   overflow-x: auto;
 49 | }
 50 | 
 51 | .markdown-content :deep(code) {
 52 |   font-family: var(--ac-font-mono);
 53 |   font-size: 0.875em;
 54 |   color: var(--ac-code-text);
 55 | }
 56 | 
 57 | .markdown-content :deep(p) {
 58 |   margin: 0.5em 0;
 59 | }
 60 | 
 61 | .markdown-content :deep(p:first-child) {
 62 |   margin-top: 0;
 63 | }
 64 | 
 65 | .markdown-content :deep(p:last-child) {
 66 |   margin-bottom: 0;
 67 | }
 68 | 
 69 | .markdown-content :deep(ul),
 70 | .markdown-content :deep(ol) {
 71 |   margin: 0.5em 0;
 72 |   padding-left: 1.5em;
 73 | }
 74 | 
 75 | .markdown-content :deep(h1),
 76 | .markdown-content :deep(h2),
 77 | .markdown-content :deep(h3),
 78 | .markdown-content :deep(h4) {
 79 |   margin: 0.75em 0 0.5em;
 80 |   font-weight: 600;
 81 | }
 82 | 
 83 | .markdown-content :deep(h1:first-child),
 84 | .markdown-content :deep(h2:first-child),
 85 | .markdown-content :deep(h3:first-child),
 86 | .markdown-content :deep(h4:first-child) {
 87 |   margin-top: 0;
 88 | }
 89 | 
 90 | .markdown-content :deep(blockquote) {
 91 |   border-left: var(--ac-border-width-strong) solid var(--ac-border);
 92 |   padding-left: 1em;
 93 |   margin: 0.5em 0;
 94 |   color: var(--ac-text-muted);
 95 | }
 96 | 
 97 | .markdown-content :deep(a) {
 98 |   color: var(--ac-link);
 99 |   text-decoration: underline;
100 | }
101 | 
102 | .markdown-content :deep(a:hover) {
103 |   color: var(--ac-link-hover);
104 | }
105 | 
106 | .markdown-content :deep(table) {
107 |   border-collapse: collapse;
108 |   margin: 0.5em 0;
109 |   width: 100%;
110 | }
111 | 
112 | .markdown-content :deep(th),
113 | .markdown-content :deep(td) {
114 |   border: var(--ac-border-width) solid var(--ac-border);
115 |   padding: 0.5em;
116 |   text-align: left;
117 | }
118 | 
119 | .markdown-content :deep(th) {
120 |   background-color: var(--ac-surface-muted);
121 | }
122 | 
123 | .markdown-content :deep(hr) {
124 |   border: none;
125 |   border-top: var(--ac-border-width) solid var(--ac-border);
126 |   margin: 1em 0;
127 | }
128 | 
129 | .markdown-content :deep(img) {
130 |   max-width: 100%;
131 |   height: auto;
132 |   border-radius: var(--ac-radius-inner);
133 | }
134 | </style>
135 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/nodes/wait.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { StepWait } from '../types';
 2 | import { waitForNetworkIdle, waitForNavigation } from '../rr-utils';
 3 | import { expandTemplatesDeep } from '../rr-utils';
 4 | import type { ExecCtx, ExecResult, NodeRuntime } from './types';
 5 | 
 6 | export const waitNode: NodeRuntime<StepWait> = {
 7 |   validate: (step) => {
 8 |     const ok = !!(step as any).condition;
 9 |     return ok ? { ok } : { ok, errors: ['缺少等待条件'] };
10 |   },
11 |   run: async (ctx: ExecCtx, step: StepWait) => {
12 |     const s = expandTemplatesDeep(step as StepWait, ctx.vars);
13 |     const cond = (s as StepWait).condition as
14 |       | { selector: string; visible?: boolean }
15 |       | { text: string; appear?: boolean }
16 |       | { navigation: true }
17 |       | { networkIdle: true }
18 |       | { sleep: number };
19 |     if ('text' in cond) {
20 |       const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
21 |       const tabId = tabs?.[0]?.id;
22 |       if (typeof tabId !== 'number') throw new Error('Active tab not found');
23 |       const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined;
24 |       await chrome.scripting.executeScript({
25 |         target: { tabId, frameIds },
26 |         files: ['inject-scripts/wait-helper.js'],
27 |         world: 'ISOLATED',
28 |       } as any);
29 |       const resp: any = (await chrome.tabs.sendMessage(
30 |         tabId,
31 |         {
32 |           action: 'waitForText',
33 |           text: cond.text,
34 |           appear: (cond as any).appear !== false,
35 |           timeout: Math.max(0, Math.min((s as any).timeoutMs || 10000, 120000)),
36 |         } as any,
37 |         { frameId: ctx.frameId } as any,
38 |       )) as any;
39 |       if (!resp || resp.success !== true) throw new Error('wait text failed');
40 |     } else if ('networkIdle' in cond) {
41 |       const total = Math.min(Math.max(1000, (s as any).timeoutMs || 5000), 120000);
42 |       const idle = Math.min(1500, Math.max(500, Math.floor(total / 3)));
43 |       await waitForNetworkIdle(total, idle);
44 |     } else if ('navigation' in cond) {
45 |       await waitForNavigation((s as any).timeoutMs);
46 |     } else if ('sleep' in cond) {
47 |       const ms = Math.max(0, Number(cond.sleep ?? 0));
48 |       await new Promise((r) => setTimeout(r, ms));
49 |     } else if ('selector' in cond) {
50 |       const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
51 |       const tabId = tabs?.[0]?.id;
52 |       if (typeof tabId !== 'number') throw new Error('Active tab not found');
53 |       const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined;
54 |       await chrome.scripting.executeScript({
55 |         target: { tabId, frameIds },
56 |         files: ['inject-scripts/wait-helper.js'],
57 |         world: 'ISOLATED',
58 |       } as any);
59 |       const resp: any = (await chrome.tabs.sendMessage(
60 |         tabId,
61 |         {
62 |           action: 'waitForSelector',
63 |           selector: (cond as any).selector,
64 |           visible: (cond as any).visible !== false,
65 |           timeout: Math.max(0, Math.min((s as any).timeoutMs || 10000, 120000)),
66 |         } as any,
67 |         { frameId: ctx.frameId } as any,
68 |       )) as any;
69 |       if (!resp || resp.success !== true) throw new Error('wait selector failed');
70 |     }
71 |     return {} as ExecResult;
72 |   },
73 | };
74 | 
```

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

```typescript
 1 | import { createErrorResponse, ToolResult } from '@/common/tool-handler';
 2 | import { BaseBrowserToolExecutor } from '../base-browser';
 3 | import { TOOL_NAMES } from 'chrome-mcp-shared';
 4 | import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
 5 | 
 6 | const DEFAULT_NETWORK_REQUEST_TIMEOUT = 30000; // For sending a single request via content script
 7 | 
 8 | interface NetworkRequestToolParams {
 9 |   url: string; // URL is always required
10 |   method?: string; // Defaults to GET
11 |   headers?: Record<string, string>; // User-provided headers
12 |   body?: any; // User-provided body
13 |   timeout?: number; // Timeout for the network request itself
14 |   // Optional multipart/form-data descriptor. When provided, overrides body and lets the helper build FormData.
15 |   // Shape: { fields?: Record<string, string|number|boolean>, files?: Array<{ name: string, fileUrl?: string, filePath?: string, base64Data?: string, filename?: string, contentType?: string }> }
16 |   // Or a compact array: [ [name, fileSpec, filename?], ... ] where fileSpec can be 'url:...', 'file:/abs/path', 'base64:...'
17 |   formData?: any;
18 | }
19 | 
20 | /**
21 |  * NetworkRequestTool - Sends network requests based on provided parameters.
22 |  */
23 | class NetworkRequestTool extends BaseBrowserToolExecutor {
24 |   name = TOOL_NAMES.BROWSER.NETWORK_REQUEST;
25 | 
26 |   async execute(args: NetworkRequestToolParams): Promise<ToolResult> {
27 |     const {
28 |       url,
29 |       method = 'GET',
30 |       headers = {},
31 |       body,
32 |       timeout = DEFAULT_NETWORK_REQUEST_TIMEOUT,
33 |     } = args;
34 | 
35 |     console.log(`NetworkRequestTool: Executing with options:`, args);
36 | 
37 |     if (!url) {
38 |       return createErrorResponse('URL parameter is required.');
39 |     }
40 | 
41 |     try {
42 |       const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
43 |       if (!tabs[0]?.id) {
44 |         return createErrorResponse('No active tab found or tab has no ID.');
45 |       }
46 |       const activeTabId = tabs[0].id;
47 | 
48 |       // Ensure content script is available in the target tab
49 |       await this.injectContentScript(activeTabId, ['inject-scripts/network-helper.js']);
50 | 
51 |       console.log(
52 |         `NetworkRequestTool: Sending to content script: URL=${url}, Method=${method}, Headers=${Object.keys(headers).join(',')}, BodyType=${typeof body}`,
53 |       );
54 | 
55 |       const resultFromContentScript = await this.sendMessageToTab(activeTabId, {
56 |         action: TOOL_MESSAGE_TYPES.NETWORK_SEND_REQUEST,
57 |         url: url,
58 |         method: method,
59 |         headers: headers,
60 |         body: body,
61 |         formData: args.formData || null,
62 |         timeout: timeout,
63 |       });
64 | 
65 |       console.log(`NetworkRequestTool: Response from content script:`, resultFromContentScript);
66 | 
67 |       return {
68 |         content: [
69 |           {
70 |             type: 'text',
71 |             text: JSON.stringify(resultFromContentScript),
72 |           },
73 |         ],
74 |         isError: !resultFromContentScript?.success,
75 |       };
76 |     } catch (error: any) {
77 |       console.error('NetworkRequestTool: Error sending network request:', error);
78 |       return createErrorResponse(
79 |         `Error sending network request: ${error.message || String(error)}`,
80 |       );
81 |     }
82 |   }
83 | }
84 | 
85 | export const networkRequestTool = new NetworkRequestTool();
86 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import type { Edge, Flow, NodeBase, Step } from '../types';
  2 | import { STEP_TYPES } from '@/common/step-types';
  3 | import { recordingSession } from './session-manager';
  4 | import { mapStepToNodeConfig, EDGE_LABELS } from 'chrome-mcp-shared';
  5 | 
  6 | const WORKFLOW_VERSION = 1;
  7 | 
  8 | /**
  9 |  * Creates an initial flow structure for recording.
 10 |  * Initializes with nodes/edges (DAG) instead of steps.
 11 |  */
 12 | export function createInitialFlow(meta?: Partial<Flow>): Flow {
 13 |   const timeStamp = new Date().toISOString();
 14 |   const flow: Flow = {
 15 |     id: meta?.id || `flow_${Date.now()}`,
 16 |     name: meta?.name || 'new_workflow',
 17 |     version: WORKFLOW_VERSION,
 18 |     nodes: [],
 19 |     edges: [],
 20 |     variables: [],
 21 |     meta: {
 22 |       createdAt: timeStamp,
 23 |       updatedAt: timeStamp,
 24 |       ...meta?.meta,
 25 |     },
 26 |   };
 27 |   return flow;
 28 | }
 29 | 
 30 | export function generateStepId(): string {
 31 |   return `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
 32 | }
 33 | 
 34 | /**
 35 |  * Appends a navigation step to the flow.
 36 |  * Prefers centralized session append when recording is active.
 37 |  * Falls back to direct DAG mutation (does NOT write flow.steps).
 38 |  */
 39 | export function addNavigationStep(flow: Flow, url: string): void {
 40 |   const step: Step = { id: generateStepId(), type: STEP_TYPES.NAVIGATE, url } as Step;
 41 | 
 42 |   // Prefer centralized session append (single broadcast path) when active and matching flow
 43 |   const sessFlow = recordingSession.getFlow?.();
 44 |   if (recordingSession.getStatus?.() === 'recording' && sessFlow === flow) {
 45 |     recordingSession.appendSteps([step]);
 46 |     return;
 47 |   }
 48 | 
 49 |   // Fallback: mutate DAG directly (do not write flow.steps)
 50 |   appendNodeToFlow(flow, step);
 51 | }
 52 | 
 53 | /**
 54 |  * Appends a step as a node to the flow's DAG structure.
 55 |  * Creates node and edge from the previous node if exists.
 56 |  *
 57 |  * Internal helper - rarely invoked in practice. During active recording,
 58 |  * addNavigationStep() routes to session.appendSteps() which handles DAG
 59 |  * maintenance, caching, and timeline broadcast. This fallback only runs
 60 |  * when session is not active or flow reference doesn't match.
 61 |  */
 62 | function appendNodeToFlow(flow: Flow, step: Step): void {
 63 |   // Ensure DAG arrays exist
 64 |   if (!Array.isArray(flow.nodes)) flow.nodes = [];
 65 |   if (!Array.isArray(flow.edges)) flow.edges = [];
 66 | 
 67 |   const prevNodeId = flow.nodes.length > 0 ? flow.nodes[flow.nodes.length - 1]?.id : undefined;
 68 | 
 69 |   // Create new node
 70 |   const newNode: NodeBase = {
 71 |     id: step.id,
 72 |     type: step.type as NodeBase['type'],
 73 |     config: mapStepToNodeConfig(step),
 74 |   };
 75 |   flow.nodes.push(newNode);
 76 | 
 77 |   // Create edge from previous node if exists
 78 |   if (prevNodeId) {
 79 |     const edgeId = `e_${flow.edges.length}_${prevNodeId}_${step.id}`;
 80 |     const edge: Edge = {
 81 |       id: edgeId,
 82 |       from: prevNodeId,
 83 |       to: step.id,
 84 |       label: EDGE_LABELS.DEFAULT,
 85 |     };
 86 |     flow.edges.push(edge);
 87 |   }
 88 | 
 89 |   // Update meta timestamp (with error tolerance like session-manager)
 90 |   try {
 91 |     const timeStamp = new Date().toISOString();
 92 |     if (!flow.meta) {
 93 |       flow.meta = { createdAt: timeStamp, updatedAt: timeStamp };
 94 |     } else {
 95 |       flow.meta.updatedAt = timeStamp;
 96 |     }
 97 |   } catch {
 98 |     // ignore meta update errors to not block recording
 99 |   }
100 | }
101 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/utils/lru-cache.ts:
--------------------------------------------------------------------------------

```typescript
  1 | class LRUNode<K, V> {
  2 |   constructor(
  3 |     public key: K,
  4 |     public value: V,
  5 |     public prev: LRUNode<K, V> | null = null,
  6 |     public next: LRUNode<K, V> | null = null,
  7 |     public frequency: number = 1,
  8 |     public lastAccessed: number = Date.now(),
  9 |   ) {}
 10 | }
 11 | 
 12 | class LRUCache<K = string, V = any> {
 13 |   private capacity: number;
 14 |   private cache: Map<K, LRUNode<K, V>>;
 15 |   private head: LRUNode<K, V>;
 16 |   private tail: LRUNode<K, V>;
 17 | 
 18 |   constructor(capacity: number) {
 19 |     this.capacity = capacity > 0 ? capacity : 100;
 20 |     this.cache = new Map<K, LRUNode<K, V>>();
 21 | 
 22 |     this.head = new LRUNode<K, V>(null as any, null as any);
 23 |     this.tail = new LRUNode<K, V>(null as any, null as any);
 24 |     this.head.next = this.tail;
 25 |     this.tail.prev = this.head;
 26 |   }
 27 | 
 28 |   private addToHead(node: LRUNode<K, V>): void {
 29 |     node.prev = this.head;
 30 |     node.next = this.head.next;
 31 |     this.head.next!.prev = node;
 32 |     this.head.next = node;
 33 |   }
 34 | 
 35 |   private removeNode(node: LRUNode<K, V>): void {
 36 |     node.prev!.next = node.next;
 37 |     node.next!.prev = node.prev;
 38 |   }
 39 | 
 40 |   private moveToHead(node: LRUNode<K, V>): void {
 41 |     this.removeNode(node);
 42 |     this.addToHead(node);
 43 |   }
 44 | 
 45 |   private findVictimNode(): LRUNode<K, V> {
 46 |     let victim = this.tail.prev!;
 47 |     let minScore = this.calculateEvictionScore(victim);
 48 | 
 49 |     let current = this.tail.prev;
 50 |     let count = 0;
 51 |     const maxCheck = Math.min(5, this.cache.size);
 52 | 
 53 |     while (current && current !== this.head && count < maxCheck) {
 54 |       const score = this.calculateEvictionScore(current);
 55 |       if (score < minScore) {
 56 |         minScore = score;
 57 |         victim = current;
 58 |       }
 59 |       current = current.prev;
 60 |       count++;
 61 |     }
 62 | 
 63 |     return victim;
 64 |   }
 65 | 
 66 |   private calculateEvictionScore(node: LRUNode<K, V>): number {
 67 |     const now = Date.now();
 68 |     const timeSinceAccess = now - node.lastAccessed;
 69 |     const timeWeight = 1 / (1 + timeSinceAccess / (1000 * 60));
 70 |     const frequencyWeight = Math.log(node.frequency + 1);
 71 | 
 72 |     return frequencyWeight * timeWeight;
 73 |   }
 74 | 
 75 |   get(key: K): V | null {
 76 |     const node = this.cache.get(key);
 77 |     if (node) {
 78 |       node.frequency++;
 79 |       node.lastAccessed = Date.now();
 80 |       this.moveToHead(node);
 81 |       return node.value;
 82 |     }
 83 |     return null;
 84 |   }
 85 | 
 86 |   set(key: K, value: V): void {
 87 |     const existingNode = this.cache.get(key);
 88 | 
 89 |     if (existingNode) {
 90 |       existingNode.value = value;
 91 |       this.moveToHead(existingNode);
 92 |     } else {
 93 |       const newNode = new LRUNode(key, value);
 94 | 
 95 |       if (this.cache.size >= this.capacity) {
 96 |         const victimNode = this.findVictimNode();
 97 |         this.removeNode(victimNode);
 98 |         this.cache.delete(victimNode.key);
 99 |       }
100 | 
101 |       this.cache.set(key, newNode);
102 |       this.addToHead(newNode);
103 |     }
104 |   }
105 | 
106 |   has(key: K): boolean {
107 |     return this.cache.has(key);
108 |   }
109 | 
110 |   clear(): void {
111 |     this.cache.clear();
112 |     this.head.next = this.tail;
113 |     this.tail.prev = this.head;
114 |   }
115 | 
116 |   get size(): number {
117 |     return this.cache.size;
118 |   }
119 | 
120 |   /**
121 |    * Get cache statistics
122 |    */
123 |   getStats(): { size: number; capacity: number; usage: number } {
124 |     return {
125 |       size: this.cache.size,
126 |       capacity: this.capacity,
127 |       usage: this.cache.size / this.capacity,
128 |     };
129 |   }
130 | }
131 | 
132 | export default LRUCache;
133 | 
```

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

```typescript
 1 | /**
 2 |  * @fileoverview 持久化变量存储
 3 |  * @description 实现 $ 前缀变量的持久化,使用 LWW(Last-Write-Wins)策略
 4 |  */
 5 | 
 6 | import type { PersistentVarRecord, PersistentVariableName } from '../domain/variables';
 7 | import type { JsonValue } from '../domain/json';
 8 | import type { PersistentVarsStore } from '../engine/storage/storage-port';
 9 | import { RR_V3_STORES, withTransaction } from './db';
10 | 
11 | /**
12 |  * 创建 PersistentVarsStore 实现
13 |  */
14 | export function createPersistentVarsStore(): PersistentVarsStore {
15 |   return {
16 |     async get(key: PersistentVariableName): Promise<PersistentVarRecord | undefined> {
17 |       return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readonly', async (stores) => {
18 |         const store = stores[RR_V3_STORES.PERSISTENT_VARS];
19 |         return new Promise<PersistentVarRecord | undefined>((resolve, reject) => {
20 |           const request = store.get(key);
21 |           request.onsuccess = () => resolve(request.result as PersistentVarRecord | undefined);
22 |           request.onerror = () => reject(request.error);
23 |         });
24 |       });
25 |     },
26 | 
27 |     async set(key: PersistentVariableName, value: JsonValue): Promise<PersistentVarRecord> {
28 |       return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readwrite', async (stores) => {
29 |         const store = stores[RR_V3_STORES.PERSISTENT_VARS];
30 | 
31 |         // 先读取现有记录(用于 version 递增)
32 |         const existing = await new Promise<PersistentVarRecord | undefined>((resolve, reject) => {
33 |           const request = store.get(key);
34 |           request.onsuccess = () => resolve(request.result as PersistentVarRecord | undefined);
35 |           request.onerror = () => reject(request.error);
36 |         });
37 | 
38 |         const now = Date.now();
39 |         const record: PersistentVarRecord = {
40 |           key,
41 |           value,
42 |           updatedAt: now,
43 |           version: (existing?.version ?? 0) + 1,
44 |         };
45 | 
46 |         await new Promise<void>((resolve, reject) => {
47 |           const request = store.put(record);
48 |           request.onsuccess = () => resolve();
49 |           request.onerror = () => reject(request.error);
50 |         });
51 | 
52 |         return record;
53 |       });
54 |     },
55 | 
56 |     async delete(key: PersistentVariableName): Promise<void> {
57 |       return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readwrite', async (stores) => {
58 |         const store = stores[RR_V3_STORES.PERSISTENT_VARS];
59 |         return new Promise<void>((resolve, reject) => {
60 |           const request = store.delete(key);
61 |           request.onsuccess = () => resolve();
62 |           request.onerror = () => reject(request.error);
63 |         });
64 |       });
65 |     },
66 | 
67 |     async list(prefix?: PersistentVariableName): Promise<PersistentVarRecord[]> {
68 |       return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readonly', async (stores) => {
69 |         const store = stores[RR_V3_STORES.PERSISTENT_VARS];
70 | 
71 |         return new Promise<PersistentVarRecord[]>((resolve, reject) => {
72 |           const request = store.getAll();
73 |           request.onsuccess = () => {
74 |             let results = request.result as PersistentVarRecord[];
75 | 
76 |             // 如果指定了前缀,过滤结果
77 |             if (prefix) {
78 |               results = results.filter((r) => r.key.startsWith(prefix));
79 |             }
80 | 
81 |             resolve(results);
82 |           };
83 |           request.onerror = () => reject(request.error);
84 |         });
85 |       });
86 |     },
87 |   };
88 | }
89 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/builder/components/nodes/NodeIf.vue:
--------------------------------------------------------------------------------

```vue
  1 | <template>
  2 |   <div
  3 |     :class="['workflow-node', selected ? 'selected' : '', `type-${data.node.type}`]"
  4 |     @click="onSelect()"
  5 |   >
  6 |     <div v-if="hasErrors" class="node-error" :title="errorsTitle">
  7 |       <ILucideShieldX />
  8 |       <div class="tooltip">
  9 |         <div class="item" v-for="e in errList" :key="e">• {{ e }}</div>
 10 |       </div>
 11 |     </div>
 12 |     <div class="node-container">
 13 |       <div :class="['node-icon', `icon-${data.node.type}`]">
 14 |         <component :is="iconComp(data.node.type)" />
 15 |       </div>
 16 |       <div class="node-body">
 17 |         <div class="node-name">{{ data.node.name || getTypeLabel(data.node.type) }}</div>
 18 |         <div class="node-subtitle">{{ subtitle }}</div>
 19 |       </div>
 20 |     </div>
 21 | 
 22 |     <div class="if-cases">
 23 |       <div v-for="(b, idx) in branches" :key="b.id" class="case-row">
 24 |         <div class="case-label">{{ b.name || `条件${idx + 1}` }}</div>
 25 |         <Handle
 26 |           type="source"
 27 |           :position="Position.Right"
 28 |           :id="`case:${b.id}`"
 29 |           :class="['node-handle', hasOutgoingLabel(`case:${b.id}`) ? 'connected' : 'unconnected']"
 30 |         />
 31 |       </div>
 32 |       <div v-if="hasElse" class="case-row else-row">
 33 |         <div class="case-label">Else</div>
 34 |         <Handle
 35 |           type="source"
 36 |           :position="Position.Right"
 37 |           id="case:else"
 38 |           :class="['node-handle', hasOutgoingLabel('case:else') ? 'connected' : 'unconnected']"
 39 |         />
 40 |       </div>
 41 |     </div>
 42 | 
 43 |     <Handle
 44 |       type="target"
 45 |       :position="Position.Left"
 46 |       :class="['node-handle', hasIncoming ? 'connected' : 'unconnected']"
 47 |     />
 48 |   </div>
 49 | </template>
 50 | 
 51 | <script lang="ts" setup>
 52 | import { computed } from 'vue';
 53 | import type { NodeBase, Edge as EdgeV2 } from '@/entrypoints/background/record-replay/types';
 54 | import { Handle, Position } from '@vue-flow/core';
 55 | import { iconComp, getTypeLabel, nodeSubtitle } from './node-util';
 56 | import ILucideShieldX from '~icons/lucide/shield-x';
 57 | 
 58 | const props = defineProps<{
 59 |   id: string;
 60 |   data: { node: NodeBase; edges: EdgeV2[]; onSelect: (id: string) => void; errors?: string[] };
 61 |   selected?: boolean;
 62 | }>();
 63 | 
 64 | const hasIncoming = computed(
 65 |   () => props.data.edges?.some?.((e) => e && e.to === props.data.node.id) || false,
 66 | );
 67 | const branches = computed(() => {
 68 |   try {
 69 |     return Array.isArray((props.data.node as any)?.config?.branches)
 70 |       ? ((props.data.node as any).config.branches as any[]).map((x) => ({
 71 |           id: String(x.id || ''),
 72 |           name: x.name,
 73 |           expr: x.expr,
 74 |         }))
 75 |       : [];
 76 |   } catch {
 77 |     return [];
 78 |   }
 79 | });
 80 | const hasElse = computed(() => {
 81 |   try {
 82 |     return (props.data.node as any)?.config?.else !== false;
 83 |   } catch {
 84 |     return true;
 85 |   }
 86 | });
 87 | const subtitle = computed(() => nodeSubtitle(props.data.node));
 88 | const errList = computed(() => (props.data.errors || []) as string[]);
 89 | const hasErrors = computed(() => errList.value.length > 0);
 90 | const errorsTitle = computed(() => errList.value.join('\n'));
 91 | 
 92 | function hasOutgoingLabel(label: string) {
 93 |   try {
 94 |     return (props.data.edges || []).some(
 95 |       (e: any) => e && e.from === props.data.node.id && String(e.label || '') === String(label),
 96 |     );
 97 |   } catch {
 98 |     return false;
 99 |   }
100 | }
101 | 
102 | function onSelect() {
103 |   try {
104 |     props.data.onSelect(props.id);
105 |   } catch {}
106 | }
107 | </script>
108 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/element-marker/element-marker-storage.ts:
--------------------------------------------------------------------------------

```typescript
 1 | // IndexedDB storage for element markers (URL -> marked selectors)
 2 | // Uses the shared IndexedDbClient for robust transaction handling.
 3 | 
 4 | import { IndexedDbClient } from '@/utils/indexeddb-client';
 5 | import type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';
 6 | 
 7 | const DB_NAME = 'element_marker_storage';
 8 | const DB_VERSION = 1;
 9 | const STORE = 'markers';
10 | 
11 | const idb = new IndexedDbClient(DB_NAME, DB_VERSION, (db, oldVersion) => {
12 |   switch (oldVersion) {
13 |     case 0: {
14 |       const store = db.createObjectStore(STORE, { keyPath: 'id' });
15 |       // Useful indexes for lookups
16 |       store.createIndex('by_host', 'host', { unique: false });
17 |       store.createIndex('by_origin', 'origin', { unique: false });
18 |       store.createIndex('by_path', 'path', { unique: false });
19 |     }
20 |   }
21 | });
22 | 
23 | function normalizeUrl(raw: string): { url: string; origin: string; host: string; path: string } {
24 |   try {
25 |     const u = new URL(raw);
26 |     return { url: raw, origin: u.origin, host: u.hostname, path: u.pathname };
27 |   } catch {
28 |     return { url: raw, origin: '', host: '', path: '' };
29 |   }
30 | }
31 | 
32 | function now(): number {
33 |   return Date.now();
34 | }
35 | 
36 | export async function listAllMarkers(): Promise<ElementMarker[]> {
37 |   return idb.getAll<ElementMarker>(STORE);
38 | }
39 | 
40 | export async function listMarkersForUrl(url: string): Promise<ElementMarker[]> {
41 |   const { origin, path, host } = normalizeUrl(url);
42 |   const all = await idb.getAll<ElementMarker>(STORE);
43 |   // Simple matching policy:
44 |   // - exact: origin + path must match exactly
45 |   // - prefix: origin matches and marker.path is a prefix of current path
46 |   // - host: host matches regardless of path
47 |   return all.filter((m) => {
48 |     if (!m) return false;
49 |     if (m.matchType === 'exact') return m.origin === origin && m.path === path;
50 |     if (m.matchType === 'host') return !!m.host && m.host === host;
51 |     // default 'prefix'
52 |     return m.origin === origin && (m.path ? path.startsWith(m.path) : true);
53 |   });
54 | }
55 | 
56 | export async function saveMarker(req: UpsertMarkerRequest): Promise<ElementMarker> {
57 |   const { url: rawUrl, selector } = req;
58 |   if (!rawUrl || !selector) throw new Error('url and selector are required');
59 |   const { url, origin, host, path } = normalizeUrl(rawUrl);
60 |   const ts = now();
61 |   const marker: ElementMarker = {
62 |     id: req.id || (globalThis.crypto?.randomUUID?.() ?? `${ts}_${Math.random()}`),
63 |     url,
64 |     origin,
65 |     host,
66 |     path,
67 |     matchType: req.matchType || 'prefix',
68 |     name: req.name || selector,
69 |     selector,
70 |     selectorType: req.selectorType || 'css',
71 |     listMode: req.listMode || false,
72 |     action: req.action || 'custom',
73 |     createdAt: ts,
74 |     updatedAt: ts,
75 |   };
76 |   await idb.put<ElementMarker>(STORE, marker);
77 |   return marker;
78 | }
79 | 
80 | export async function updateMarker(marker: ElementMarker): Promise<void> {
81 |   const existing = await idb.get<ElementMarker>(STORE, marker.id);
82 |   if (!existing) throw new Error('marker not found');
83 | 
84 |   // Preserve createdAt from existing record, only update updatedAt
85 |   const updated: ElementMarker = {
86 |     ...marker,
87 |     createdAt: existing.createdAt, // Never overwrite createdAt
88 |     updatedAt: now(),
89 |   };
90 |   await idb.put<ElementMarker>(STORE, updated);
91 | }
92 | 
93 | export async function deleteMarker(id: string): Promise<void> {
94 |   await idb.delete(STORE, id);
95 | }
96 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/engine/policies/wait.ts:
--------------------------------------------------------------------------------

```typescript
 1 | // engine/policies/wait.ts — wrappers around rr-utils navigation/network waits
 2 | // Keep logic centralized to avoid duplication in schedulers and nodes
 3 | 
 4 | import { handleCallTool } from '@/entrypoints/background/tools';
 5 | import { TOOL_NAMES } from 'chrome-mcp-shared';
 6 | import { waitForNavigation as rrWaitForNavigation, waitForNetworkIdle } from '../../rr-utils';
 7 | 
 8 | export async function waitForNavigationDone(prevUrl: string, timeoutMs?: number) {
 9 |   await rrWaitForNavigation(timeoutMs, prevUrl);
10 | }
11 | 
12 | export async function ensureReadPageIfWeb() {
13 |   try {
14 |     const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
15 |     const url = tabs?.[0]?.url || '';
16 |     if (/^(https?:|file:)/i.test(url)) {
17 |       await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
18 |     }
19 |   } catch {}
20 | }
21 | 
22 | export async function maybeQuickWaitForNav(prevUrl: string, timeoutMs?: number) {
23 |   try {
24 |     const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
25 |     const tabId = tabs?.[0]?.id;
26 |     if (typeof tabId !== 'number') return;
27 |     const sniffMs = 350;
28 |     const startedAt = Date.now();
29 |     let seen = false;
30 |     await new Promise<void>((resolve) => {
31 |       let timer: any = null;
32 |       const cleanup = () => {
33 |         try {
34 |           chrome.webNavigation.onCommitted.removeListener(onCommitted);
35 |         } catch {}
36 |         try {
37 |           chrome.webNavigation.onCompleted.removeListener(onCompleted);
38 |         } catch {}
39 |         try {
40 |           (chrome.webNavigation as any).onHistoryStateUpdated?.removeListener?.(
41 |             onHistoryStateUpdated,
42 |           );
43 |         } catch {}
44 |         try {
45 |           chrome.tabs.onUpdated.removeListener(onUpdated);
46 |         } catch {}
47 |         if (timer) {
48 |           try {
49 |             clearTimeout(timer);
50 |           } catch {}
51 |         }
52 |       };
53 |       const finish = async () => {
54 |         cleanup();
55 |         if (seen) {
56 |           try {
57 |             await rrWaitForNavigation(
58 |               prevUrl ? Math.min(timeoutMs || 15000, 30000) : undefined,
59 |               prevUrl,
60 |             );
61 |           } catch {}
62 |         }
63 |         resolve();
64 |       };
65 |       const mark = () => {
66 |         seen = true;
67 |       };
68 |       const onCommitted = (d: any) => {
69 |         if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();
70 |       };
71 |       const onCompleted = (d: any) => {
72 |         if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();
73 |       };
74 |       const onHistoryStateUpdated = (d: any) => {
75 |         if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();
76 |       };
77 |       const onUpdated = (updatedId: number, change: chrome.tabs.TabChangeInfo) => {
78 |         if (updatedId !== tabId) return;
79 |         if (change.status === 'loading') mark();
80 |         if (typeof change.url === 'string' && (!prevUrl || change.url !== prevUrl)) mark();
81 |       };
82 | 
83 |       chrome.webNavigation.onCommitted.addListener(onCommitted);
84 |       chrome.webNavigation.onCompleted.addListener(onCompleted);
85 |       try {
86 |         (chrome.webNavigation as any).onHistoryStateUpdated?.addListener?.(onHistoryStateUpdated);
87 |       } catch {}
88 |       chrome.tabs.onUpdated.addListener(onUpdated);
89 |       timer = setTimeout(finish, sniffMs);
90 |     });
91 |   } catch {}
92 | }
93 | 
94 | export { waitForNetworkIdle };
95 | 
```

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

```typescript
  1 | /**
  2 |  * Screenshot Action Handler
  3 |  *
  4 |  * Captures screenshots and optionally stores base64 data in variables.
  5 |  * Supports full page, selector-based, and viewport screenshots.
  6 |  */
  7 | 
  8 | import { handleCallTool } from '@/entrypoints/background/tools';
  9 | import { TOOL_NAMES } from 'chrome-mcp-shared';
 10 | import { failed, invalid, ok } from '../registry';
 11 | import type { ActionHandler } from '../types';
 12 | import { resolveString } from './common';
 13 | 
 14 | /** Extract text content from tool result */
 15 | function extractToolText(result: unknown): string | undefined {
 16 |   const content = (result as { content?: Array<{ type?: string; text?: string }> })?.content;
 17 |   const text = content?.find((c) => c?.type === 'text' && typeof c.text === 'string')?.text;
 18 |   return typeof text === 'string' && text.trim() ? text : undefined;
 19 | }
 20 | 
 21 | export const screenshotHandler: ActionHandler<'screenshot'> = {
 22 |   type: 'screenshot',
 23 | 
 24 |   validate: (action) => {
 25 |     const saveAs = action.params.saveAs;
 26 |     if (saveAs !== undefined && (!saveAs || String(saveAs).trim().length === 0)) {
 27 |       return invalid('saveAs must be a non-empty variable name when provided');
 28 |     }
 29 |     return ok();
 30 |   },
 31 | 
 32 |   describe: (action) => {
 33 |     if (action.params.fullPage) return 'Screenshot (full page)';
 34 |     if (typeof action.params.selector === 'string') {
 35 |       const sel =
 36 |         action.params.selector.length > 30
 37 |           ? action.params.selector.slice(0, 30) + '...'
 38 |           : action.params.selector;
 39 |       return `Screenshot: ${sel}`;
 40 |     }
 41 |     if (action.params.selector) return 'Screenshot (dynamic selector)';
 42 |     return 'Screenshot';
 43 |   },
 44 | 
 45 |   run: async (ctx, action) => {
 46 |     const tabId = ctx.tabId;
 47 |     if (typeof tabId !== 'number') {
 48 |       return failed('TAB_NOT_FOUND', 'No active tab found for screenshot action');
 49 |     }
 50 | 
 51 |     // Resolve optional selector
 52 |     let selector: string | undefined;
 53 |     if (action.params.selector !== undefined) {
 54 |       const resolved = resolveString(action.params.selector, ctx.vars);
 55 |       if (!resolved.ok) return failed('VALIDATION_ERROR', resolved.error);
 56 |       const s = resolved.value.trim();
 57 |       if (s) selector = s;
 58 |     }
 59 | 
 60 |     // Call screenshot tool
 61 |     const res = await handleCallTool({
 62 |       name: TOOL_NAMES.BROWSER.SCREENSHOT,
 63 |       args: {
 64 |         name: 'workflow',
 65 |         storeBase64: true,
 66 |         fullPage: action.params.fullPage === true,
 67 |         selector,
 68 |         tabId,
 69 |       },
 70 |     });
 71 | 
 72 |     if ((res as { isError?: boolean })?.isError) {
 73 |       return failed('UNKNOWN', extractToolText(res) || 'Screenshot failed');
 74 |     }
 75 | 
 76 |     // Parse response
 77 |     const text = extractToolText(res);
 78 |     if (!text) {
 79 |       return failed('UNKNOWN', 'Screenshot tool returned an empty response');
 80 |     }
 81 | 
 82 |     let payload: unknown;
 83 |     try {
 84 |       payload = JSON.parse(text);
 85 |     } catch {
 86 |       return failed('UNKNOWN', 'Screenshot tool returned invalid JSON');
 87 |     }
 88 | 
 89 |     const base64Data = (payload as { base64Data?: unknown })?.base64Data;
 90 |     if (typeof base64Data !== 'string' || base64Data.length === 0) {
 91 |       return failed('UNKNOWN', 'Screenshot tool returned empty base64Data');
 92 |     }
 93 | 
 94 |     // Store in variables if saveAs specified
 95 |     if (action.params.saveAs) {
 96 |       ctx.vars[action.params.saveAs] = base64Data;
 97 |     }
 98 | 
 99 |     return { status: 'success', output: { base64Data } };
100 |   },
101 | };
102 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/utils/cdp-session-manager.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { TOOL_NAMES } from 'chrome-mcp-shared';
  2 | 
  3 | type OwnerTag = string;
  4 | 
  5 | interface TabSessionState {
  6 |   refCount: number;
  7 |   owners: Set<OwnerTag>;
  8 |   attachedByUs: boolean;
  9 | }
 10 | 
 11 | const DEBUGGER_PROTOCOL_VERSION = '1.3';
 12 | 
 13 | class CDPSessionManager {
 14 |   private sessions = new Map<number, TabSessionState>();
 15 | 
 16 |   private getState(tabId: number): TabSessionState | undefined {
 17 |     return this.sessions.get(tabId);
 18 |   }
 19 | 
 20 |   private setState(tabId: number, state: TabSessionState) {
 21 |     this.sessions.set(tabId, state);
 22 |   }
 23 | 
 24 |   async attach(tabId: number, owner: OwnerTag = 'unknown'): Promise<void> {
 25 |     const state = this.getState(tabId);
 26 |     if (state && state.attachedByUs) {
 27 |       state.refCount += 1;
 28 |       state.owners.add(owner);
 29 |       return;
 30 |     }
 31 | 
 32 |     // Check existing attachments
 33 |     const targets = await chrome.debugger.getTargets();
 34 |     const existing = targets.find((t) => t.tabId === tabId && t.attached);
 35 |     if (existing) {
 36 |       if (existing.extensionId === chrome.runtime.id) {
 37 |         // Already attached by us (e.g., previous tool). Adopt and refcount.
 38 |         this.setState(tabId, {
 39 |           refCount: state ? state.refCount + 1 : 1,
 40 |           owners: new Set([...(state?.owners || []), owner]),
 41 |           attachedByUs: true,
 42 |         });
 43 |         return;
 44 |       }
 45 |       // Another client (DevTools/other extension) is attached
 46 |       throw new Error(
 47 |         `Debugger is already attached to tab ${tabId} by another client (e.g., DevTools/extension)`,
 48 |       );
 49 |     }
 50 | 
 51 |     // Attach freshly
 52 |     await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION);
 53 |     this.setState(tabId, { refCount: 1, owners: new Set([owner]), attachedByUs: true });
 54 |   }
 55 | 
 56 |   async detach(tabId: number, owner: OwnerTag = 'unknown'): Promise<void> {
 57 |     const state = this.getState(tabId);
 58 |     if (!state) return; // Nothing to do
 59 | 
 60 |     // Update ownership/refcount
 61 |     if (state.owners.has(owner)) state.owners.delete(owner);
 62 |     state.refCount = Math.max(0, state.refCount - 1);
 63 | 
 64 |     if (state.refCount > 0) {
 65 |       // Still in use by other owners
 66 |       return;
 67 |     }
 68 | 
 69 |     // We are the last owner
 70 |     try {
 71 |       if (state.attachedByUs) {
 72 |         await chrome.debugger.detach({ tabId });
 73 |       }
 74 |     } catch (e) {
 75 |       // Best-effort detach; ignore
 76 |     } finally {
 77 |       this.sessions.delete(tabId);
 78 |     }
 79 |   }
 80 | 
 81 |   /**
 82 |    * Convenience wrapper: ensures attach before fn, and balanced detach after.
 83 |    */
 84 |   async withSession<T>(tabId: number, owner: OwnerTag, fn: () => Promise<T>): Promise<T> {
 85 |     await this.attach(tabId, owner);
 86 |     try {
 87 |       return await fn();
 88 |     } finally {
 89 |       await this.detach(tabId, owner);
 90 |     }
 91 |   }
 92 | 
 93 |   /**
 94 |    * Send a CDP command. Requires that this manager has attached to the tab.
 95 |    * If not attached by us, will attempt a one-shot attach around the call.
 96 |    */
 97 |   async sendCommand<T = any>(tabId: number, method: string, params?: object): Promise<T> {
 98 |     const state = this.getState(tabId);
 99 |     if (state && state.attachedByUs) {
100 |       return (await chrome.debugger.sendCommand({ tabId }, method, params)) as T;
101 |     }
102 |     // Fallback: temporary session
103 |     return await this.withSession<T>(tabId, `send:${method}`, async () => {
104 |       return (await chrome.debugger.sendCommand({ tabId }, method, params)) as T;
105 |     });
106 |   }
107 | }
108 | 
109 | export const cdpSessionManager = new CDPSessionManager();
110 | 
```

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

```typescript
  1 | /**
  2 |  * Navigate Action Handler
  3 |  *
  4 |  * Handles page navigation actions:
  5 |  * - Navigate to URL
  6 |  * - Page refresh
  7 |  * - Wait for navigation completion
  8 |  */
  9 | 
 10 | import { handleCallTool } from '@/entrypoints/background/tools';
 11 | import { TOOL_NAMES } from 'chrome-mcp-shared';
 12 | import { ENGINE_CONSTANTS } from '../../engine/constants';
 13 | import { ensureReadPageIfWeb, waitForNavigationDone } from '../../engine/policies/wait';
 14 | import { failed, invalid, ok } from '../registry';
 15 | import type { ActionHandler } from '../types';
 16 | import { clampInt, readTabUrl, resolveString } from './common';
 17 | 
 18 | export const navigateHandler: ActionHandler<'navigate'> = {
 19 |   type: 'navigate',
 20 | 
 21 |   validate: (action) => {
 22 |     const hasRefresh = action.params.refresh === true;
 23 |     const hasUrl = action.params.url !== undefined;
 24 |     return hasRefresh || hasUrl ? ok() : invalid('Missing url or refresh parameter');
 25 |   },
 26 | 
 27 |   describe: (action) => {
 28 |     if (action.params.refresh) return 'Refresh page';
 29 |     const url = typeof action.params.url === 'string' ? action.params.url : '(dynamic)';
 30 |     return `Navigate to ${url}`;
 31 |   },
 32 | 
 33 |   run: async (ctx, action) => {
 34 |     const vars = ctx.vars;
 35 |     const tabId = ctx.tabId;
 36 |     // Check if StepRunner owns nav-wait (skip internal nav-wait logic)
 37 |     const skipNavWait = ctx.execution?.skipNavWait === true;
 38 | 
 39 |     if (typeof tabId !== 'number') {
 40 |       return failed('TAB_NOT_FOUND', 'No active tab found');
 41 |     }
 42 | 
 43 |     // Only read beforeUrl and calculate waitMs if we need to do nav-wait
 44 |     const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId);
 45 |     const waitMs = skipNavWait
 46 |       ? 0
 47 |       : clampInt(
 48 |           action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS,
 49 |           0,
 50 |           ENGINE_CONSTANTS.MAX_WAIT_MS,
 51 |         );
 52 | 
 53 |     // Handle page refresh
 54 |     if (action.params.refresh) {
 55 |       const result = await handleCallTool({
 56 |         name: TOOL_NAMES.BROWSER.NAVIGATE,
 57 |         args: { refresh: true, tabId },
 58 |       });
 59 | 
 60 |       if ((result as { isError?: boolean })?.isError) {
 61 |         const errorContent = (result as { content?: Array<{ text?: string }> })?.content;
 62 |         const errorMsg = errorContent?.[0]?.text || 'Page refresh failed';
 63 |         return failed('NAVIGATION_FAILED', errorMsg);
 64 |       }
 65 | 
 66 |       // Skip nav-wait if StepRunner handles it
 67 |       if (!skipNavWait) {
 68 |         await waitForNavigationDone(beforeUrl, waitMs);
 69 |         await ensureReadPageIfWeb();
 70 |       }
 71 |       return { status: 'success' };
 72 |     }
 73 | 
 74 |     // Handle URL navigation
 75 |     const urlResolved = resolveString(action.params.url, vars);
 76 |     if (!urlResolved.ok) {
 77 |       return failed('VALIDATION_ERROR', urlResolved.error);
 78 |     }
 79 | 
 80 |     const url = urlResolved.value.trim();
 81 |     if (!url) {
 82 |       return failed('VALIDATION_ERROR', 'URL is empty');
 83 |     }
 84 | 
 85 |     const result = await handleCallTool({
 86 |       name: TOOL_NAMES.BROWSER.NAVIGATE,
 87 |       args: { url, tabId },
 88 |     });
 89 | 
 90 |     if ((result as { isError?: boolean })?.isError) {
 91 |       const errorContent = (result as { content?: Array<{ text?: string }> })?.content;
 92 |       const errorMsg = errorContent?.[0]?.text || `Navigation to ${url} failed`;
 93 |       return failed('NAVIGATION_FAILED', errorMsg);
 94 |     }
 95 | 
 96 |     // Skip nav-wait if StepRunner handles it
 97 |     if (!skipNavWait) {
 98 |       await waitForNavigationDone(beforeUrl, waitMs);
 99 |       await ensureReadPageIfWeb();
100 |     }
101 | 
102 |     return { status: 'success' };
103 |   },
104 | };
105 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/engine/runners/after-script-queue.ts:
--------------------------------------------------------------------------------

```typescript
 1 | // after-script-queue.ts — queue + executor for deferred after-scripts
 2 | // Notes:
 3 | // - Executes user-provided code in the specified world (ISOLATED by default)
 4 | // - Clears queue before execution to avoid leaks; re-queues remainder on failure
 5 | // - Logs warnings instead of throwing to keep the main engine resilient
 6 | 
 7 | import type { StepScript } from '../../types';
 8 | import type { ExecCtx } from '../../nodes';
 9 | import { RunLogger } from '../logging/run-logger';
10 | import { applyAssign } from '../../rr-utils';
11 | 
12 | export class AfterScriptQueue {
13 |   private queue: StepScript[] = [];
14 | 
15 |   constructor(private logger: RunLogger) {}
16 | 
17 |   enqueue(script: StepScript) {
18 |     this.queue.push(script);
19 |   }
20 | 
21 |   size() {
22 |     return this.queue.length;
23 |   }
24 | 
25 |   async flush(ctx: ExecCtx, vars: Record<string, any>) {
26 |     if (this.queue.length === 0) return;
27 |     const scriptsToFlush = this.queue.splice(0, this.queue.length);
28 |     for (let i = 0; i < scriptsToFlush.length; i++) {
29 |       const s = scriptsToFlush[i]!;
30 |       const tScript = Date.now();
31 |       const world = (s as any).world || 'ISOLATED';
32 |       const code = String((s as any).code || '');
33 |       if (!code.trim()) {
34 |         this.logger.push({ stepId: s.id, status: 'success', tookMs: Date.now() - tScript });
35 |         continue;
36 |       }
37 |       try {
38 |         // Warn on obviously dangerous constructs; not a sandbox, just visibility.
39 |         const dangerous =
40 |           /[;{}]|\b(function|=>|while|for|class|globalThis|window|self|this|constructor|__proto__|prototype|eval|Function|import|require|XMLHttpRequest|fetch|chrome)\b/;
41 |         if (dangerous.test(code)) {
42 |           this.logger.push({
43 |             stepId: s.id,
44 |             status: 'warning',
45 |             message: 'Script contains potentially unsafe tokens; executed in isolated world',
46 |           });
47 |         }
48 |         const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
49 |         const tabId = tabs?.[0]?.id;
50 |         if (typeof tabId !== 'number') throw new Error('Active tab not found');
51 |         const [{ result }] = await chrome.scripting.executeScript({
52 |           target: { tabId },
53 |           func: (userCode: string) => {
54 |             try {
55 |               return (0, eval)(userCode);
56 |             } catch (e) {
57 |               return { __error: true, message: String(e) } as any;
58 |             }
59 |           },
60 |           args: [code],
61 |           world: world as any,
62 |         } as any);
63 |         if ((result as any)?.__error) {
64 |           this.logger.push({
65 |             stepId: s.id,
66 |             status: 'warning',
67 |             message: `After-script error: ${(result as any).message || 'unknown'}`,
68 |           });
69 |         }
70 |         const value = (result as any)?.__error ? null : result;
71 |         if ((s as any).saveAs) (vars as any)[(s as any).saveAs] = value;
72 |         if ((s as any).assign && typeof (s as any).assign === 'object')
73 |           applyAssign(vars, value, (s as any).assign);
74 |       } catch (e: any) {
75 |         // Re-queue remaining and stop flush cycle for now
76 |         const remaining = scriptsToFlush.slice(i + 1);
77 |         if (remaining.length) this.queue.unshift(...remaining);
78 |         this.logger.push({
79 |           stepId: s.id,
80 |           status: 'warning',
81 |           message: `After-script execution failed: ${e?.message || String(e)}`,
82 |         });
83 |         break;
84 |       }
85 |       this.logger.push({ stepId: s.id, status: 'success', tookMs: Date.now() - tScript });
86 |     }
87 |   }
88 | }
89 | 
```

--------------------------------------------------------------------------------
/app/native-server/src/trace-analyzer.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import * as fs from 'fs';
 2 | 
 3 | // Import DevTools trace engine and formatters from chrome-devtools-frontend
 4 | // We intentionally use deep imports to match the package structure.
 5 | // These modules are ESM and require NodeNext module resolution.
 6 | // Types are loosely typed to minimize coupling with DevTools internals.
 7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 8 | // @ts-ignore
 9 | import * as TraceEngine from 'chrome-devtools-frontend/front_end/models/trace/trace.js';
10 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
11 | // @ts-ignore
12 | import { PerformanceTraceFormatter } from 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.js';
13 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
14 | // @ts-ignore
15 | import { PerformanceInsightFormatter } from 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.js';
16 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
17 | // @ts-ignore
18 | import { AgentFocus } from 'chrome-devtools-frontend/front_end/models/ai_assistance/performance/AIContext.js';
19 | 
20 | const engine = TraceEngine.TraceModel.Model.createWithAllHandlers();
21 | 
22 | function readJsonFile(path: string): any {
23 |   const text = fs.readFileSync(path, 'utf-8');
24 |   return JSON.parse(text);
25 | }
26 | 
27 | export async function parseTrace(json: any): Promise<{
28 |   parsedTrace: any;
29 |   insights: any | null;
30 | }> {
31 |   engine.resetProcessor();
32 |   const events = Array.isArray(json) ? json : json.traceEvents;
33 |   if (!events || !Array.isArray(events)) {
34 |     throw new Error('Invalid trace format: expected array or {traceEvents: []}');
35 |   }
36 |   await engine.parse(events);
37 |   const parsedTrace = engine.parsedTrace();
38 |   const insights = parsedTrace?.insights ?? null;
39 |   if (!parsedTrace) throw new Error('No parsed trace returned by engine');
40 |   return { parsedTrace, insights };
41 | }
42 | 
43 | export function getTraceSummary(parsedTrace: any): string {
44 |   const focus = AgentFocus.fromParsedTrace(parsedTrace);
45 |   const formatter = new PerformanceTraceFormatter(focus);
46 |   return formatter.formatTraceSummary();
47 | }
48 | 
49 | export function getInsightText(parsedTrace: any, insights: any, insightName: string): string {
50 |   if (!insights) throw new Error('No insights available for this trace');
51 |   const mainNavId = parsedTrace.data?.Meta?.mainFrameNavigations?.at(0)?.args?.data?.navigationId;
52 |   const NO_NAV = TraceEngine.Types.Events.NO_NAVIGATION;
53 |   const set = insights.get(mainNavId ?? NO_NAV);
54 |   if (!set) throw new Error('No insights for selected navigation');
55 |   const model = set.model || {};
56 |   if (!(insightName in model)) throw new Error(`Insight not found: ${insightName}`);
57 |   const formatter = new PerformanceInsightFormatter(
58 |     AgentFocus.fromParsedTrace(parsedTrace),
59 |     model[insightName],
60 |   );
61 |   return formatter.formatInsight();
62 | }
63 | 
64 | export async function analyzeTraceFile(
65 |   filePath: string,
66 |   insightName?: string,
67 | ): Promise<{
68 |   summary: string;
69 |   insight?: string;
70 | }> {
71 |   const json = readJsonFile(filePath);
72 |   const { parsedTrace, insights } = await parseTrace(json);
73 |   const summary = getTraceSummary(parsedTrace);
74 |   if (insightName) {
75 |     try {
76 |       const insight = getInsightText(parsedTrace, insights, insightName);
77 |       return { summary, insight };
78 |     } catch {
79 |       // If requested insight missing, still return summary
80 |       return { summary };
81 |     }
82 |   }
83 |   return { summary };
84 | }
85 | 
86 | export default { analyzeTraceFile };
87 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/quick-panel.content.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Quick Panel Content Script
  3 |  *
  4 |  * This content script manages the Quick Panel AI Chat feature on web pages.
  5 |  * It responds to:
  6 |  * - Background messages (toggle_quick_panel from keyboard shortcut)
  7 |  * - Direct programmatic calls
  8 |  *
  9 |  * The Quick Panel provides a floating AI chat interface that:
 10 |  * - Uses Shadow DOM for style isolation
 11 |  * - Streams AI responses in real-time
 12 |  * - Supports keyboard shortcuts (Enter to send, Esc to close)
 13 |  * - Collects page context (URL, selection) automatically
 14 |  */
 15 | 
 16 | import { createQuickPanelController, type QuickPanelController } from '@/shared/quick-panel';
 17 | 
 18 | export default defineContentScript({
 19 |   matches: ['<all_urls>'],
 20 |   runAt: 'document_idle',
 21 | 
 22 |   main() {
 23 |     console.log('[QuickPanelContentScript] Content script loaded on:', window.location.href);
 24 |     let controller: QuickPanelController | null = null;
 25 | 
 26 |     /**
 27 |      * Ensure controller is initialized (lazy initialization)
 28 |      */
 29 |     function ensureController(): QuickPanelController {
 30 |       if (!controller) {
 31 |         controller = createQuickPanelController({
 32 |           title: 'Agent',
 33 |           subtitle: 'Quick Panel',
 34 |           placeholder: 'Ask about this page...',
 35 |         });
 36 |       }
 37 |       return controller;
 38 |     }
 39 | 
 40 |     /**
 41 |      * Handle messages from background script
 42 |      */
 43 |     function handleMessage(
 44 |       message: unknown,
 45 |       _sender: chrome.runtime.MessageSender,
 46 |       sendResponse: (response?: unknown) => void,
 47 |     ): boolean | void {
 48 |       const msg = message as { action?: string } | undefined;
 49 | 
 50 |       if (msg?.action === 'toggle_quick_panel') {
 51 |         console.log('[QuickPanelContentScript] Received toggle_quick_panel message');
 52 |         try {
 53 |           const ctrl = ensureController();
 54 |           ctrl.toggle();
 55 |           const visible = ctrl.isVisible();
 56 |           console.log('[QuickPanelContentScript] Toggle completed, visible:', visible);
 57 |           sendResponse({ success: true, visible });
 58 |         } catch (err) {
 59 |           console.error('[QuickPanelContentScript] Toggle error:', err);
 60 |           sendResponse({ success: false, error: String(err) });
 61 |         }
 62 |         return true; // Async response
 63 |       }
 64 | 
 65 |       if (msg?.action === 'show_quick_panel') {
 66 |         try {
 67 |           const ctrl = ensureController();
 68 |           ctrl.show();
 69 |           sendResponse({ success: true });
 70 |         } catch (err) {
 71 |           console.error('[QuickPanelContentScript] Show error:', err);
 72 |           sendResponse({ success: false, error: String(err) });
 73 |         }
 74 |         return true;
 75 |       }
 76 | 
 77 |       if (msg?.action === 'hide_quick_panel') {
 78 |         try {
 79 |           if (controller) {
 80 |             controller.hide();
 81 |           }
 82 |           sendResponse({ success: true });
 83 |         } catch (err) {
 84 |           console.error('[QuickPanelContentScript] Hide error:', err);
 85 |           sendResponse({ success: false, error: String(err) });
 86 |         }
 87 |         return true;
 88 |       }
 89 | 
 90 |       if (msg?.action === 'get_quick_panel_status') {
 91 |         sendResponse({
 92 |           success: true,
 93 |           visible: controller?.isVisible() ?? false,
 94 |           initialized: controller !== null,
 95 |         });
 96 |         return true;
 97 |       }
 98 | 
 99 |       // Not handled
100 |       return false;
101 |     }
102 | 
103 |     // Register message listener
104 |     chrome.runtime.onMessage.addListener(handleMessage);
105 | 
106 |     // Cleanup on page unload
107 |     window.addEventListener('unload', () => {
108 |       chrome.runtime.onMessage.removeListener(handleMessage);
109 |       if (controller) {
110 |         controller.dispose();
111 |         controller = null;
112 |       }
113 |     });
114 |   },
115 | });
116 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/builder/components/nodes/node-util.ts:
--------------------------------------------------------------------------------

```typescript
  1 | // node-util.ts - shared UI helpers for node components
  2 | // Note: comments in English
  3 | 
  4 | import type { NodeBase } from '@/entrypoints/background/record-replay/types';
  5 | import { summarizeNode as summarize } from '../../model/transforms';
  6 | import ILucideMousePointerClick from '~icons/lucide/mouse-pointer-click';
  7 | import ILucideEdit3 from '~icons/lucide/edit-3';
  8 | import ILucideKeyboard from '~icons/lucide/keyboard';
  9 | import ILucideCompass from '~icons/lucide/compass';
 10 | import ILucideGlobe from '~icons/lucide/globe';
 11 | import ILucideFileCode2 from '~icons/lucide/file-code-2';
 12 | import ILucideScan from '~icons/lucide/scan';
 13 | import ILucideHourglass from '~icons/lucide/hourglass';
 14 | import ILucideCheckCircle2 from '~icons/lucide/check-circle-2';
 15 | import ILucideGitBranch from '~icons/lucide/git-branch';
 16 | import ILucideRepeat from '~icons/lucide/repeat';
 17 | import ILucideRefreshCcw from '~icons/lucide/refresh-ccw';
 18 | import ILucideSquare from '~icons/lucide/square';
 19 | import ILucideArrowLeftRight from '~icons/lucide/arrow-left-right';
 20 | import ILucideX from '~icons/lucide/x';
 21 | import ILucideZap from '~icons/lucide/zap';
 22 | import ILucideCamera from '~icons/lucide/camera';
 23 | import ILucideBell from '~icons/lucide/bell';
 24 | import ILucideWrench from '~icons/lucide/wrench';
 25 | import ILucideFrame from '~icons/lucide/frame';
 26 | import ILucideDownload from '~icons/lucide/download';
 27 | import ILucideArrowUpDown from '~icons/lucide/arrow-up-down';
 28 | import ILucideMoveVertical from '~icons/lucide/move-vertical';
 29 | 
 30 | export function iconComp(t?: string) {
 31 |   switch (t) {
 32 |     case 'trigger':
 33 |       return ILucideZap;
 34 |     case 'click':
 35 |     case 'dblclick':
 36 |       return ILucideMousePointerClick;
 37 |     case 'fill':
 38 |       return ILucideEdit3;
 39 |     case 'drag':
 40 |       return ILucideArrowUpDown;
 41 |     case 'scroll':
 42 |       return ILucideMoveVertical;
 43 |     case 'key':
 44 |       return ILucideKeyboard;
 45 |     case 'navigate':
 46 |       return ILucideCompass;
 47 |     case 'http':
 48 |       return ILucideGlobe;
 49 |     case 'script':
 50 |       return ILucideFileCode2;
 51 |     case 'screenshot':
 52 |       return ILucideCamera;
 53 |     case 'triggerEvent':
 54 |       return ILucideBell;
 55 |     case 'setAttribute':
 56 |       return ILucideWrench;
 57 |     case 'loopElements':
 58 |       return ILucideRepeat;
 59 |     case 'switchFrame':
 60 |       return ILucideFrame;
 61 |     case 'handleDownload':
 62 |       return ILucideDownload;
 63 |     case 'extract':
 64 |       return ILucideScan;
 65 |     case 'wait':
 66 |       return ILucideHourglass;
 67 |     case 'assert':
 68 |       return ILucideCheckCircle2;
 69 |     case 'if':
 70 |       return ILucideGitBranch;
 71 |     case 'foreach':
 72 |       return ILucideRepeat;
 73 |     case 'while':
 74 |       return ILucideRefreshCcw;
 75 |     case 'openTab':
 76 |       return ILucideSquare;
 77 |     case 'switchTab':
 78 |       return ILucideArrowLeftRight;
 79 |     case 'closeTab':
 80 |       return ILucideX;
 81 |     case 'delay':
 82 |       return ILucideHourglass;
 83 |     default:
 84 |       return ILucideSquare;
 85 |   }
 86 | }
 87 | 
 88 | export function getTypeLabel(type?: string) {
 89 |   const labels: Record<string, string> = {
 90 |     trigger: '触发器',
 91 |     click: '点击',
 92 |     fill: '填充',
 93 |     navigate: '导航',
 94 |     wait: '等待',
 95 |     extract: '提取',
 96 |     http: 'HTTP',
 97 |     script: '脚本',
 98 |     if: '条件',
 99 |     foreach: '循环',
100 |     assert: '断言',
101 |     key: '键盘',
102 |     drag: '拖拽',
103 |     dblclick: '双击',
104 |     openTab: '打开标签',
105 |     switchTab: '切换标签',
106 |     closeTab: '关闭标签',
107 |     delay: '延迟',
108 |     scroll: '滚动',
109 |     while: '循环',
110 |   };
111 |   return labels[String(type || '')] || type || '';
112 | }
113 | 
114 | export function nodeSubtitle(node?: NodeBase | null): string {
115 |   if (!node) return '';
116 |   const summary = summarize(node);
117 |   if (!summary) return node.type || '';
118 |   return summary.length > 40 ? summary.slice(0, 40) + '...' : summary;
119 | }
120 | 
```

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

```typescript
  1 | /**
  2 |  * @fileoverview FlowV3 持久化
  3 |  * @description 实现 Flow 的 CRUD 操作
  4 |  */
  5 | 
  6 | import type { FlowId } from '../domain/ids';
  7 | import type { FlowV3 } from '../domain/flow';
  8 | import { FLOW_SCHEMA_VERSION } from '../domain/flow';
  9 | import { RR_ERROR_CODES, createRRError } from '../domain/errors';
 10 | import type { FlowsStore } from '../engine/storage/storage-port';
 11 | import { RR_V3_STORES, withTransaction } from './db';
 12 | 
 13 | /**
 14 |  * 校验 Flow 结构
 15 |  */
 16 | function validateFlow(flow: FlowV3): void {
 17 |   // 校验 schema 版本
 18 |   if (flow.schemaVersion !== FLOW_SCHEMA_VERSION) {
 19 |     throw createRRError(
 20 |       RR_ERROR_CODES.VALIDATION_ERROR,
 21 |       `Invalid schema version: expected ${FLOW_SCHEMA_VERSION}, got ${flow.schemaVersion}`,
 22 |     );
 23 |   }
 24 | 
 25 |   // 校验必填字段
 26 |   if (!flow.id) {
 27 |     throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow id is required');
 28 |   }
 29 |   if (!flow.name) {
 30 |     throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow name is required');
 31 |   }
 32 |   if (!flow.entryNodeId) {
 33 |     throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow entryNodeId is required');
 34 |   }
 35 | 
 36 |   // 校验 entryNodeId 存在
 37 |   const nodeIds = new Set(flow.nodes.map((n) => n.id));
 38 |   if (!nodeIds.has(flow.entryNodeId)) {
 39 |     throw createRRError(
 40 |       RR_ERROR_CODES.VALIDATION_ERROR,
 41 |       `Entry node "${flow.entryNodeId}" does not exist in flow`,
 42 |     );
 43 |   }
 44 | 
 45 |   // 校验边引用
 46 |   for (const edge of flow.edges) {
 47 |     if (!nodeIds.has(edge.from)) {
 48 |       throw createRRError(
 49 |         RR_ERROR_CODES.VALIDATION_ERROR,
 50 |         `Edge "${edge.id}" references non-existent source node "${edge.from}"`,
 51 |       );
 52 |     }
 53 |     if (!nodeIds.has(edge.to)) {
 54 |       throw createRRError(
 55 |         RR_ERROR_CODES.VALIDATION_ERROR,
 56 |         `Edge "${edge.id}" references non-existent target node "${edge.to}"`,
 57 |       );
 58 |     }
 59 |   }
 60 | }
 61 | 
 62 | /**
 63 |  * 创建 FlowsStore 实现
 64 |  */
 65 | export function createFlowsStore(): FlowsStore {
 66 |   return {
 67 |     async list(): Promise<FlowV3[]> {
 68 |       return withTransaction(RR_V3_STORES.FLOWS, 'readonly', async (stores) => {
 69 |         const store = stores[RR_V3_STORES.FLOWS];
 70 |         return new Promise<FlowV3[]>((resolve, reject) => {
 71 |           const request = store.getAll();
 72 |           request.onsuccess = () => resolve(request.result as FlowV3[]);
 73 |           request.onerror = () => reject(request.error);
 74 |         });
 75 |       });
 76 |     },
 77 | 
 78 |     async get(id: FlowId): Promise<FlowV3 | null> {
 79 |       return withTransaction(RR_V3_STORES.FLOWS, 'readonly', async (stores) => {
 80 |         const store = stores[RR_V3_STORES.FLOWS];
 81 |         return new Promise<FlowV3 | null>((resolve, reject) => {
 82 |           const request = store.get(id);
 83 |           request.onsuccess = () => resolve((request.result as FlowV3) ?? null);
 84 |           request.onerror = () => reject(request.error);
 85 |         });
 86 |       });
 87 |     },
 88 | 
 89 |     async save(flow: FlowV3): Promise<void> {
 90 |       // 校验
 91 |       validateFlow(flow);
 92 | 
 93 |       return withTransaction(RR_V3_STORES.FLOWS, 'readwrite', async (stores) => {
 94 |         const store = stores[RR_V3_STORES.FLOWS];
 95 |         return new Promise<void>((resolve, reject) => {
 96 |           const request = store.put(flow);
 97 |           request.onsuccess = () => resolve();
 98 |           request.onerror = () => reject(request.error);
 99 |         });
100 |       });
101 |     },
102 | 
103 |     async delete(id: FlowId): Promise<void> {
104 |       return withTransaction(RR_V3_STORES.FLOWS, 'readwrite', async (stores) => {
105 |         const store = stores[RR_V3_STORES.FLOWS];
106 |         return new Promise<void>((resolve, reject) => {
107 |           const request = store.delete(id);
108 |           request.onsuccess = () => resolve();
109 |           request.onerror = () => reject(request.error);
110 |         });
111 |       });
112 |     },
113 |   };
114 | }
115 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/quick-panel/commands.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Quick Panel Commands Handler
  3 |  *
  4 |  * Handles keyboard shortcuts for Quick Panel functionality.
  5 |  * Listens for the 'toggle_quick_panel' command and sends toggle message
  6 |  * to the content script in the active tab.
  7 |  */
  8 | 
  9 | // ============================================================
 10 | // Constants
 11 | // ============================================================
 12 | 
 13 | const COMMAND_KEY = 'toggle_quick_panel';
 14 | const LOG_PREFIX = '[QuickPanelCommands]';
 15 | 
 16 | // ============================================================
 17 | // Helpers
 18 | // ============================================================
 19 | 
 20 | /**
 21 |  * Get the ID of the currently active tab
 22 |  */
 23 | async function getActiveTabId(): Promise<number | null> {
 24 |   try {
 25 |     const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
 26 |     return tab?.id ?? null;
 27 |   } catch (err) {
 28 |     console.warn(`${LOG_PREFIX} Failed to get active tab:`, err);
 29 |     return null;
 30 |   }
 31 | }
 32 | 
 33 | /**
 34 |  * Check if a tab can receive content scripts
 35 |  */
 36 | function isValidTabUrl(url?: string): boolean {
 37 |   if (!url) return false;
 38 | 
 39 |   // Cannot inject into browser internal pages
 40 |   const invalidPrefixes = [
 41 |     'chrome://',
 42 |     'chrome-extension://',
 43 |     'edge://',
 44 |     'about:',
 45 |     'moz-extension://',
 46 |     'devtools://',
 47 |     'view-source:',
 48 |     'data:',
 49 |     // 'file://',
 50 |   ];
 51 | 
 52 |   return !invalidPrefixes.some((prefix) => url.startsWith(prefix));
 53 | }
 54 | 
 55 | // ============================================================
 56 | // Main Handler
 57 | // ============================================================
 58 | 
 59 | /**
 60 |  * Toggle Quick Panel in the active tab
 61 |  */
 62 | async function toggleQuickPanelInActiveTab(): Promise<void> {
 63 |   const tabId = await getActiveTabId();
 64 |   if (tabId === null) {
 65 |     console.warn(`${LOG_PREFIX} No active tab found`);
 66 |     return;
 67 |   }
 68 | 
 69 |   // Get tab info to check URL validity
 70 |   try {
 71 |     const tab = await chrome.tabs.get(tabId);
 72 |     if (!isValidTabUrl(tab.url)) {
 73 |       console.warn(`${LOG_PREFIX} Cannot inject into tab URL: ${tab.url}`);
 74 |       return;
 75 |     }
 76 |   } catch (err) {
 77 |     console.warn(`${LOG_PREFIX} Failed to get tab info:`, err);
 78 |     return;
 79 |   }
 80 | 
 81 |   // Send toggle message to content script
 82 |   try {
 83 |     const response = await chrome.tabs.sendMessage(tabId, { action: 'toggle_quick_panel' });
 84 |     if (response?.success) {
 85 |       console.log(`${LOG_PREFIX} Quick Panel toggled, visible: ${response.visible}`);
 86 |     } else {
 87 |       console.warn(`${LOG_PREFIX} Toggle failed:`, response?.error);
 88 |     }
 89 |   } catch (err) {
 90 |     // Content script may not be loaded yet; this is expected on some pages
 91 |     console.warn(
 92 |       `${LOG_PREFIX} Failed to send toggle message (content script may not be loaded):`,
 93 |       err,
 94 |     );
 95 |   }
 96 | }
 97 | 
 98 | // ============================================================
 99 | // Initialization
100 | // ============================================================
101 | 
102 | /**
103 |  * Initialize Quick Panel keyboard command listener
104 |  */
105 | export function initQuickPanelCommands(): void {
106 |   console.log(`${LOG_PREFIX} initQuickPanelCommands called`);
107 |   chrome.commands.onCommand.addListener(async (command) => {
108 |     console.log(`${LOG_PREFIX} onCommand received:`, command);
109 |     if (command !== COMMAND_KEY) {
110 |       console.log(`${LOG_PREFIX} Command not matched, expected:`, COMMAND_KEY);
111 |       return;
112 |     }
113 |     console.log(`${LOG_PREFIX} Command matched, calling toggleQuickPanelInActiveTab...`);
114 | 
115 |     try {
116 |       await toggleQuickPanelInActiveTab();
117 |       console.log(`${LOG_PREFIX} toggleQuickPanelInActiveTab completed`);
118 |     } catch (err) {
119 |       console.error(`${LOG_PREFIX} Command handler error:`, err);
120 |     }
121 |   });
122 | 
123 |   console.log(`${LOG_PREFIX} Command listener registered for: ${COMMAND_KEY}`);
124 | }
125 | 
```

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

```typescript
  1 | /**
  2 |  * @fileoverview RunRecordV3 持久化
  3 |  * @description 实现 Run 记录的 CRUD 操作
  4 |  */
  5 | 
  6 | import type { RunId } from '../domain/ids';
  7 | import type { RunRecordV3 } from '../domain/events';
  8 | import { RUN_SCHEMA_VERSION } from '../domain/events';
  9 | import { RR_ERROR_CODES, createRRError } from '../domain/errors';
 10 | import type { RunsStore } from '../engine/storage/storage-port';
 11 | import { RR_V3_STORES, withTransaction } from './db';
 12 | 
 13 | /**
 14 |  * 校验 Run 记录结构
 15 |  */
 16 | function validateRunRecord(record: RunRecordV3): void {
 17 |   // 校验 schema 版本
 18 |   if (record.schemaVersion !== RUN_SCHEMA_VERSION) {
 19 |     throw createRRError(
 20 |       RR_ERROR_CODES.VALIDATION_ERROR,
 21 |       `Invalid schema version: expected ${RUN_SCHEMA_VERSION}, got ${record.schemaVersion}`,
 22 |     );
 23 |   }
 24 | 
 25 |   // 校验必填字段
 26 |   if (!record.id) {
 27 |     throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Run id is required');
 28 |   }
 29 |   if (!record.flowId) {
 30 |     throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Run flowId is required');
 31 |   }
 32 |   if (!record.status) {
 33 |     throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Run status is required');
 34 |   }
 35 | }
 36 | 
 37 | /**
 38 |  * 创建 RunsStore 实现
 39 |  */
 40 | export function createRunsStore(): RunsStore {
 41 |   return {
 42 |     async list(): Promise<RunRecordV3[]> {
 43 |       return withTransaction(RR_V3_STORES.RUNS, 'readonly', async (stores) => {
 44 |         const store = stores[RR_V3_STORES.RUNS];
 45 |         return new Promise<RunRecordV3[]>((resolve, reject) => {
 46 |           const request = store.getAll();
 47 |           request.onsuccess = () => resolve(request.result as RunRecordV3[]);
 48 |           request.onerror = () => reject(request.error);
 49 |         });
 50 |       });
 51 |     },
 52 | 
 53 |     async get(id: RunId): Promise<RunRecordV3 | null> {
 54 |       return withTransaction(RR_V3_STORES.RUNS, 'readonly', async (stores) => {
 55 |         const store = stores[RR_V3_STORES.RUNS];
 56 |         return new Promise<RunRecordV3 | null>((resolve, reject) => {
 57 |           const request = store.get(id);
 58 |           request.onsuccess = () => resolve((request.result as RunRecordV3) ?? null);
 59 |           request.onerror = () => reject(request.error);
 60 |         });
 61 |       });
 62 |     },
 63 | 
 64 |     async save(record: RunRecordV3): Promise<void> {
 65 |       // 校验
 66 |       validateRunRecord(record);
 67 | 
 68 |       return withTransaction(RR_V3_STORES.RUNS, 'readwrite', async (stores) => {
 69 |         const store = stores[RR_V3_STORES.RUNS];
 70 |         return new Promise<void>((resolve, reject) => {
 71 |           const request = store.put(record);
 72 |           request.onsuccess = () => resolve();
 73 |           request.onerror = () => reject(request.error);
 74 |         });
 75 |       });
 76 |     },
 77 | 
 78 |     async patch(id: RunId, patch: Partial<RunRecordV3>): Promise<void> {
 79 |       return withTransaction(RR_V3_STORES.RUNS, 'readwrite', async (stores) => {
 80 |         const store = stores[RR_V3_STORES.RUNS];
 81 | 
 82 |         // 先读取现有记录
 83 |         const existing = await new Promise<RunRecordV3 | null>((resolve, reject) => {
 84 |           const request = store.get(id);
 85 |           request.onsuccess = () => resolve((request.result as RunRecordV3) ?? null);
 86 |           request.onerror = () => reject(request.error);
 87 |         });
 88 | 
 89 |         if (!existing) {
 90 |           throw createRRError(RR_ERROR_CODES.INTERNAL, `Run "${id}" not found`);
 91 |         }
 92 | 
 93 |         // 合并并更新
 94 |         const updated: RunRecordV3 = {
 95 |           ...existing,
 96 |           ...patch,
 97 |           id: existing.id, // 确保 id 不变
 98 |           schemaVersion: existing.schemaVersion, // 确保版本不变
 99 |           updatedAt: Date.now(),
100 |         };
101 | 
102 |         return new Promise<void>((resolve, reject) => {
103 |           const request = store.put(updated);
104 |           request.onsuccess = () => resolve();
105 |           request.onerror = () => reject(request.error);
106 |         });
107 |       });
108 |     },
109 |   };
110 | }
111 | 
```
Page 3/60FirstPrevNextLast