Repository: hangwin/mcp-chrome
Branch: master
Commit: f48e71751e00
Files: 652
Total size: 37.5 MB
Directory structure:
gitextract_0pcja33u/
├── .gitattributes
├── .github/
│ └── workflows/
│ └── build-release.yml
├── .gitignore
├── .husky/
│ ├── commit-msg
│ └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── .vscode/
│ └── extensions.json
├── LICENSE
├── README.md
├── README_zh.md
├── app/
│ ├── chrome-extension/
│ │ ├── LICENSE
│ │ ├── README.md
│ │ ├── _locales/
│ │ │ ├── de/
│ │ │ │ └── messages.json
│ │ │ ├── en/
│ │ │ │ └── messages.json
│ │ │ ├── ja/
│ │ │ │ └── messages.json
│ │ │ ├── ko/
│ │ │ │ └── messages.json
│ │ │ ├── zh_CN/
│ │ │ │ └── messages.json
│ │ │ └── zh_TW/
│ │ │ └── messages.json
│ │ ├── 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/
│ │ │ │ │ ├── ConfirmDialog.vue
│ │ │ │ │ ├── ElementMarkerManagement.vue
│ │ │ │ │ ├── LocalModelPage.vue
│ │ │ │ │ ├── ModelCacheManagement.vue
│ │ │ │ │ ├── ProgressIndicator.vue
│ │ │ │ │ ├── ScheduleDialog.vue
│ │ │ │ │ ├── builder/
│ │ │ │ │ │ ├── components/
│ │ │ │ │ │ │ ├── Canvas.vue
│ │ │ │ │ │ │ ├── EdgePropertyPanel.vue
│ │ │ │ │ │ │ ├── KeyValueEditor.vue
│ │ │ │ │ │ │ ├── PropertyPanel.vue
│ │ │ │ │ │ │ ├── Sidebar.vue
│ │ │ │ │ │ │ ├── TriggerPanel.vue
│ │ │ │ │ │ │ ├── nodes/
│ │ │ │ │ │ │ │ ├── NodeCard.vue
│ │ │ │ │ │ │ │ ├── NodeIf.vue
│ │ │ │ │ │ │ │ └── node-util.ts
│ │ │ │ │ │ │ └── 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
│ │ │ │ │ │ ├── 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
│ │ │ │ │ └── icons/
│ │ │ │ │ ├── BoltIcon.vue
│ │ │ │ │ ├── CheckIcon.vue
│ │ │ │ │ ├── DatabaseIcon.vue
│ │ │ │ │ ├── DocumentIcon.vue
│ │ │ │ │ ├── EditIcon.vue
│ │ │ │ │ ├── MarkerIcon.vue
│ │ │ │ │ ├── RecordIcon.vue
│ │ │ │ │ ├── RefreshIcon.vue
│ │ │ │ │ ├── StopIcon.vue
│ │ │ │ │ ├── TabIcon.vue
│ │ │ │ │ ├── TrashIcon.vue
│ │ │ │ │ ├── VectorIcon.vue
│ │ │ │ │ ├── WorkflowIcon.vue
│ │ │ │ │ └── index.ts
│ │ │ │ ├── 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/
│ │ │ │ │ ├── AgentChat.vue
│ │ │ │ │ ├── SidepanelNavigator.vue
│ │ │ │ │ ├── agent/
│ │ │ │ │ │ ├── AttachmentPreview.vue
│ │ │ │ │ │ ├── ChatInput.vue
│ │ │ │ │ │ ├── CliSettings.vue
│ │ │ │ │ │ ├── ConnectionStatus.vue
│ │ │ │ │ │ ├── MessageItem.vue
│ │ │ │ │ │ ├── MessageList.vue
│ │ │ │ │ │ ├── ProjectCreateForm.vue
│ │ │ │ │ │ ├── ProjectSelector.vue
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── 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
│ │ │ │ │ │ ├── SelectionChip.vue
│ │ │ │ │ │ ├── WebEditorChanges.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── timeline/
│ │ │ │ │ │ ├── ThinkingNode.vue
│ │ │ │ │ │ ├── TimelineNarrativeStep.vue
│ │ │ │ │ │ ├── TimelineStatusStep.vue
│ │ │ │ │ │ ├── TimelineToolCallStep.vue
│ │ │ │ │ │ ├── TimelineToolResultCardStep.vue
│ │ │ │ │ │ ├── TimelineUserPromptStep.vue
│ │ │ │ │ │ └── markstream-thinking.ts
│ │ │ │ │ ├── rr-v3/
│ │ │ │ │ │ └── DebuggerPanel.vue
│ │ │ │ │ └── workflows/
│ │ │ │ │ ├── WorkflowListItem.vue
│ │ │ │ │ ├── WorkflowsView.vue
│ │ │ │ │ └── index.ts
│ │ │ │ ├── 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
│ │ ├── package.json
│ │ ├── 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.js
│ │ │ ├── simd_math_bg.wasm
│ │ │ └── similarity.worker.js
│ │ └── wxt.config.ts
│ └── native-server/
│ ├── .npmignore
│ ├── README.md
│ ├── debug.sh
│ ├── install.md
│ ├── jest.config.js
│ ├── package.json
│ ├── 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.md
│ ├── ARCHITECTURE_zh.md
│ ├── CHANGELOG.md
│ ├── CONTRIBUTING.md
│ ├── CONTRIBUTING_zh.md
│ ├── ISSUE.md
│ ├── TOOLS.md
│ ├── TOOLS_zh.md
│ ├── TROUBLESHOOTING.md
│ ├── TROUBLESHOOTING_zh.md
│ ├── VisualEditor.md
│ ├── VisualEditor_zh.md
│ ├── WINDOWS_INSTALL_zh.md
│ └── mcp-cli-config.md
├── eslint.config.js
├── 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
│ ├── README.md
│ ├── package.json
│ └── src/
│ └── lib.rs
├── pnpm-workspace.yaml
├── prompt/
│ ├── content-analize.md
│ ├── excalidraw-prompt.md
│ └── modify-web.md
└── releases/
└── README.md
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitattributes
================================================
*.onnx filter=lfs diff=lfs merge=lfs -text
================================================
FILE: .github/workflows/build-release.yml
================================================
# name: Build and Release Chrome Extension
# on:
# push:
# branches: [ master, develop ]
# paths:
# - 'app/chrome-extension/**'
# pull_request:
# branches: [ master ]
# paths:
# - 'app/chrome-extension/**'
# workflow_dispatch:
# jobs:
# build-extension:
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v4
# - name: Setup Node.js
# uses: actions/setup-node@v4
# with:
# node-version: '18'
# cache: 'npm'
# cache-dependency-path: 'app/chrome-extension/package-lock.json'
# - name: Install dependencies
# run: |
# cd app/chrome-extension
# npm ci
# - name: Build extension
# run: |
# cd app/chrome-extension
# npm run build
# - name: Create zip package
# run: |
# cd app/chrome-extension
# npm run zip
# - name: Prepare release directory
# run: |
# mkdir -p releases/chrome-extension/latest
# mkdir -p releases/chrome-extension/$(date +%Y%m%d-%H%M%S)
# - name: Copy release files
# run: |
# # Copy to latest
# cp app/chrome-extension/.output/chrome-mv3-prod.zip releases/chrome-extension/latest/chrome-mcp-server-latest.zip
# # Copy to timestamped version
# TIMESTAMP=$(date +%Y%m%d-%H%M%S)
# cp app/chrome-extension/.output/chrome-mv3-prod.zip releases/chrome-extension/$TIMESTAMP/chrome-mcp-server-$TIMESTAMP.zip
# - name: Upload build artifacts
# uses: actions/upload-artifact@v4
# with:
# name: chrome-extension-build
# path: releases/chrome-extension/
# retention-days: 30
# - name: Commit and push releases (if on main branch)
# if: github.ref == 'refs/heads/main'
# run: |
# git config --local user.email "action@github.com"
# git config --local user.name "GitHub Action"
# git add releases/
# git diff --staged --quiet || git commit -m "Auto-build: Update Chrome extension release [skip ci]"
# git push
================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.output
stats.html
stats-*.json
.wxt
web-ext.config.ts
dist
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.onnx
# Environment variables
.env
.env.local
.env.*.local
# Prevent npm metadata pollution
false/
metadata-v1.3/
registry.npmmirror.com/
registry.npmjs.com/
other/
tools_optimize.md
Agents.md
CLAUDE.md
**/*/coverage/*
.docs/
.claude/
================================================
FILE: .husky/commit-msg
================================================
npx --no -- commitlint --edit "$1"
================================================
FILE: .husky/pre-commit
================================================
npx lint-staged
================================================
FILE: .prettierignore
================================================
# 构建输出目录
dist
.output
.wxt
# 依赖
node_modules
# 日志
logs
*.log
# 缓存
.cache
.temp
# 编辑器配置
.vscode
!.vscode/extensions.json
.idea
# 系统文件
.DS_Store
Thumbs.db
# 打包文件
*.zip
*.tar.gz
# 统计文件
stats.html
stats-*.json
# 锁文件
pnpm-lock.yaml
================================================
FILE: .prettierrc.json
================================================
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"printWidth": 100,
"endOfLine": "auto",
"proseWrap": "preserve",
"htmlWhitespaceSensitivity": "strict"
}
================================================
FILE: .vscode/extensions.json
================================================
{
"recommendations": ["Vue.volar"]
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2024 hangye
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Chrome MCP Server 🚀
[](https://img.shields.io/github/stars/hangwin/mcp-chrome)
[](https://opensource.org/licenses/MIT)
[](https://www.typescriptlang.org/)
[](https://developer.chrome.com/docs/extensions/)
[](https://img.shields.io/github/v/release/hangwin/mcp-chrome.svg)
> 🌟 **Turn your Chrome browser into your intelligent assistant** - Let AI take control of your browser, transforming it into a powerful AI-controlled automation tool.
**📖 Documentation**: [English](README.md) | [中文](README_zh.md)
> The project is still in its early stages and is under intensive development. More features, stability improvements, and other enhancements will follow.
---
## 🎯 What is Chrome MCP Server?
Chrome MCP Server is a Chrome extension-based **Model Context Protocol (MCP) server** that exposes your Chrome browser functionality to AI assistants like Claude, enabling complex browser automation, content analysis, and semantic search. Unlike traditional browser automation tools (like Playwright), **Chrome MCP Server** directly uses your daily Chrome browser, leveraging existing user habits, configurations, and login states, allowing various large models or chatbots to take control of your browser and truly become your everyday assistant.
## ✨ New Features(2025/12/30)
- **A New Visual Editor for Claude Code & Codex**, for more detail here: [VisualEditor](docs/VisualEditor.md)
## ✨ Core Features
- 😁 **Chatbot/Model Agnostic**: Let any LLM or chatbot client or agent you prefer automate your browser
- ⭐️ **Use Your Original Browser**: Seamlessly integrate with your existing browser environment (your configurations, login states, etc.)
- 💻 **Fully Local**: Pure local MCP server ensuring user privacy
- 🚄 **Streamable HTTP**: Streamable HTTP connection method
- 🏎 **Cross-Tab**: Cross-tab context
- 🧠 **Semantic Search**: Built-in vector database for intelligent browser tab content discovery
- 🔍 **Smart Content Analysis**: AI-powered text extraction and similarity matching
- 🌐 **20+ Tools**: Support for screenshots, network monitoring, interactive operations, bookmark management, browsing history, and 20+ other tools
- 🚀 **SIMD-Accelerated AI**: Custom WebAssembly SIMD optimization for 4-8x faster vector operations
## 🆚 Comparison with Similar Projects
| Comparison Dimension | Playwright-based MCP Server | Chrome Extension-based MCP Server |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| **Resource Usage** | ❌ Requires launching independent browser process, installing Playwright dependencies, downloading browser binaries, etc. | ✅ No need to launch independent browser process, directly utilizes user's already open Chrome browser |
| **User Session Reuse** | ❌ Requires re-login | ✅ Automatically uses existing login state |
| **Browser Environment** | ❌ Clean environment lacks user settings | ✅ Fully preserves user environment |
| **API Access** | ⚠️ Limited to Playwright API | ✅ Full access to Chrome native APIs |
| **Startup Speed** | ❌ Requires launching browser process | ✅ Only needs to activate extension |
| **Response Speed** | 50-200ms inter-process communication | ✅ Faster |
## 🚀 Quick Start
### Prerequisites
- Node.js >= 20.0.0 and pnpm/npm
- Chrome/Chromium browser
### Installation Steps
1. **Download the latest Chrome extension from GitHub**
Download link: https://github.com/hangwin/mcp-chrome/releases
2. **Install mcp-chrome-bridge globally**
npm
```bash
npm install -g mcp-chrome-bridge
```
pnpm
```bash
# Method 1: Enable scripts globally (recommended)
pnpm config set enable-pre-post-scripts true
pnpm install -g mcp-chrome-bridge
# Method 2: Manual registration (if postinstall doesn't run)
pnpm install -g mcp-chrome-bridge
mcp-chrome-bridge register
```
> Note: pnpm v7+ disables postinstall scripts by default for security. The `enable-pre-post-scripts` setting controls whether pre/post install scripts run. If automatic registration fails, use the manual registration command above.
3. **Load Chrome Extension**
- Open Chrome and go to `chrome://extensions/`
- Enable "Developer mode"
- Click "Load unpacked" and select `your/dowloaded/extension/folder`
- Click the extension icon to open the plugin, then click connect to see the MCP configuration
### Usage with MCP Protocol Clients
#### Using Streamable HTTP Connection (👍🏻 Recommended)
Add the following configuration to your MCP client configuration (using CherryStudio as an example):
> Streamable HTTP connection method is recommended
```json
{
"mcpServers": {
"chrome-mcp-server": {
"type": "streamableHttp",
"url": "http://127.0.0.1:12306/mcp"
}
}
}
```
#### Using STDIO Connection (Alternative)
If your client only supports stdio connection method, please use the following approach:
1. First, check the installation location of the npm package you just installed
```sh
# npm check method
npm list -g mcp-chrome-bridge
# pnpm check method
pnpm list -g mcp-chrome-bridge
```
Assuming the command above outputs the path: /Users/xxx/Library/pnpm/global/5
Then your final path would be: /Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js
2. Replace the configuration below with the final path you just obtained
```json
{
"mcpServers": {
"chrome-mcp-stdio": {
"command": "npx",
"args": [
"node",
"/Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js"
]
}
}
}
```
eg:config in augment:
## 🛠️ Available Tools
Complete tool list: [Complete Tool List](docs/TOOLS.md)
📊 Browser Management (6 tools)
- `get_windows_and_tabs` - List all browser windows and tabs
- `chrome_navigate` - Navigate to URLs and control viewport
- `chrome_switch_tab` - Switch the current active tab
- `chrome_close_tabs` - Close specific tabs or windows
- `chrome_go_back_or_forward` - Browser navigation control
- `chrome_inject_script` - Inject content scripts into web pages
- `chrome_send_command_to_inject_script` - Send commands to injected content scripts
📸 Screenshots & Visual (1 tool)
- `chrome_screenshot` - Advanced screenshot capture with element targeting, full-page support, and custom dimensions
🌐 Network Monitoring (4 tools)
- `chrome_network_capture_start/stop` - webRequest API network capture
- `chrome_network_debugger_start/stop` - Debugger API with response bodies
- `chrome_network_request` - Send custom HTTP requests
🔍 Content Analysis (4 tools)
- `search_tabs_content` - AI-powered semantic search across browser tabs
- `chrome_get_web_content` - Extract HTML/text content from pages
- `chrome_get_interactive_elements` - Find clickable elements
- `chrome_console` - Capture and retrieve console output from browser tabs
🎯 Interaction (3 tools)
- `chrome_click_element` - Click elements using CSS selectors
- `chrome_fill_or_select` - Fill forms and select options
- `chrome_keyboard` - Simulate keyboard input and shortcuts
📚 Data Management (5 tools)
- `chrome_history` - Search browser history with time filters
- `chrome_bookmark_search` - Find bookmarks by keywords
- `chrome_bookmark_add` - Add new bookmarks with folder support
- `chrome_bookmark_delete` - Delete bookmarks
## 🧪 Usage Examples
### AI helps you summarize webpage content and automatically control Excalidraw for drawing
prompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)
Instruction: Help me summarize the current page content, then draw a diagram to aid my understanding.
https://www.youtube.com/watch?v=3fBPdUBWVz0
https://github.com/user-attachments/assets/fd17209b-303d-48db-9e5e-3717141df183
### After analyzing the content of the image, the LLM automatically controls Excalidraw to replicate the image
prompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)|[content-analize](prompt/content-analize.md)
Instruction: First, analyze the content of the image, and then replicate the image by combining the analysis with the content of the image.
https://www.youtube.com/watch?v=tEPdHZBzbZk
https://github.com/user-attachments/assets/60d12b1a-9b74-40f4-994c-95e8fa1fc8d3
### AI automatically injects scripts and modifies webpage styles
prompt: [modify-web-prompt](prompt/modify-web.md)
Instruction: Help me modify the current page's style and remove advertisements.
https://youtu.be/twI6apRKHsk
https://github.com/user-attachments/assets/69cb561c-2e1e-4665-9411-4a3185f9643e
### AI automatically captures network requests for you
query: I want to know what the search API for Xiaohongshu is and what the response structure looks like
https://youtu.be/1hHKr7XKqnQ
https://github.com/user-attachments/assets/dc7e5cab-b9af-4b9a-97ce-18e4837318d9
### AI helps analyze your browsing history
query: Analyze my browsing history from the past month
https://youtu.be/jf2UZfrR2Vk
https://github.com/user-attachments/assets/31b2e064-88c6-4adb-96d7-50748b826eae
### Web page conversation
query: Translate and summarize the current web page
https://youtu.be/FlJKS9UQyC8
https://github.com/user-attachments/assets/aa8ef2a1-2310-47e6-897a-769d85489396
### AI automatically takes screenshots for you (web page screenshots)
query: Take a screenshot of Hugging Face's homepage
https://youtu.be/7ycK6iksWi4
https://github.com/user-attachments/assets/65c6eee2-6366-493d-a3bd-2b27529ff5b3
### AI automatically takes screenshots for you (element screenshots)
query: Capture the icon from Hugging Face's homepage
https://youtu.be/ev8VivANIrk
https://github.com/user-attachments/assets/d0cf9785-c2fe-4729-a3c5-7f2b8b96fe0c
### AI helps manage bookmarks
query: Add the current page to bookmarks and put it in an appropriate folder
https://youtu.be/R_83arKmFTo
https://github.com/user-attachments/assets/15a7d04c-0196-4b40-84c2-bafb5c26dfe0
### Automatically close web pages
query: Close all shadcn-related web pages
https://youtu.be/2wzUT6eNVg4
https://github.com/user-attachments/assets/83de4008-bb7e-494d-9b0f-98325cfea592
## 🤝 Contributing
We welcome contributions! Please see [CONTRIBUTING.md](docs/CONTRIBUTING.md) for detailed guidelines.
## 🚧 Future Roadmap
We have exciting plans for the future development of Chrome MCP Server:
- [ ] Authentication
- [ ] Recording and Playback
- [ ] Workflow Automation
- [ ] Enhanced Browser Support (Firefox Extension)
---
**Want to contribute to any of these features?** Check out our [Contributing Guide](docs/CONTRIBUTING.md) and join our development community!
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 📚 More Documentation
- [Architecture Design](docs/ARCHITECTURE.md) - Detailed technical architecture documentation
- [TOOLS API](docs/TOOLS.md) - Complete tool API documentation
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issue solutions
================================================
FILE: README_zh.md
================================================
# Chrome MCP Server 🚀
[](https://opensource.org/licenses/MIT)
[](https://www.typescriptlang.org/)
[](https://developer.chrome.com/docs/extensions/)
> 🌟 **让chrome浏览器变成你的智能助手** - 让AI接管你的浏览器,将您的浏览器转变为强大的 AI 控制自动化工具。
**📖 文档**: [English](README.md) | [中文](README_zh.md)
> 项目仍处于早期阶段,正在紧锣密鼓开发中,后续将有更多新功能,以及稳定性等的提升,如遇bug,请轻喷
---
## 🎯 什么是 Chrome MCP Server?
Chrome MCP Server 是一个基于chrome插件的 **模型上下文协议 (MCP) 服务器**,它将您的 Chrome 浏览器功能暴露给 Claude 等 AI 助手,实现复杂的浏览器自动化、内容分析和语义搜索等。与传统的浏览器自动化工具(如playwright)不同,**Chrome MCP server**直接使用您日常使用的chrome浏览器,基于现有的用户习惯和配置、登录态,让各种大模型或者各种chatbot都可以接管你的浏览器,真正成为你的日常助手
## ✨ 船新的功能(2025/12/30)
- **让Claude Code/Codex也能使用的可视化编辑器**, 更多详情请看: [VisualEditor](docs/VisualEditor_zh.md)
## ✨ 核心特性
- 😁 **chatbot/模型无关**:让任意你喜欢的llm或chatbot客户端或agent来自动化操作你的浏览器
- ⭐️ **使用你原本的浏览器**:无缝集成用户本身的浏览器环境(你的配置、登录态等)
- 💻 **完全本地运行**:纯本地运行的mcp server,保证用户隐私
- 🚄 **Streamable http**:Streamable http的连接方式
- 🏎 **跨标签页** 跨标签页的上下文
- 🧠 **语义搜索**:内置向量数据库和本地小模型,智能发现浏览器标签页内容
- 🔍 **智能内容分析**:AI 驱动的文本提取和相似度匹配
- 🌐 **20+ 工具**:支持截图、网络监控、交互操作、书签管理、浏览历史等20多种工具
- 🚀 **SIMD 加速 AI**:自定义 WebAssembly SIMD 优化,向量运算速度提升 4-8 倍
## 🆚 与同类项目对比
| 对比维度 | 基于Playwright的MCP Server | 基于Chrome插件的MCP Server |
| ------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------- |
| **资源占用** | ❌ 需启动独立浏览器进程,需要安装Playwright依赖,下载浏览器二进制等 | ✅ 无需启动独立的浏览器进程,直接利用用户已打开的Chrome浏览器 |
| **用户会话复用** | ❌ 需重新登录 | ✅ 自动使用已登录状态 |
| **浏览器环境保持** | ❌ 干净环境缺少用户设置 | ✅ 完整保留用户环境 |
| **API访问权限** | ⚠️ 受限于Playwright API | ✅ Chrome原生API全访问 |
| **启动速度** | ❌ 需启动浏览器进程 | ✅ 只需激活插件 |
| **响应速度** | 50-200ms进程间通信 | ✅ 更快 |
## 🚀 快速开始
### 环境要求
- Node.js >= 20.0.0 和 (npm 或 pnpm)
- Chrome/Chromium 浏览器
### 安装步骤
1. **从github上下载最新的chrome扩展**
下载地址:https://github.com/hangwin/mcp-chrome/releases
2. **全局安装mcp-chrome-bridge**
npm
```bash
npm install -g mcp-chrome-bridge
```
pnpm
```bash
# 方法1:全局启用脚本(推荐)
pnpm config set enable-pre-post-scripts true
pnpm install -g mcp-chrome-bridge
# 方法2:如果 postinstall 没有运行,手动注册
pnpm install -g mcp-chrome-bridge
mcp-chrome-bridge register
```
> 注意:pnpm v7+ 默认禁用 postinstall 脚本以提高安全性。`enable-pre-post-scripts` 设置控制是否运行 pre/post 安装脚本。如果自动注册失败,请使用上述手动注册命令。
3. **加载 Chrome 扩展**
- 打开 Chrome 并访问 `chrome://extensions/`
- 启用"开发者模式"
- 点击"加载已解压的扩展程序",选择 `your/dowloaded/extension/folder`
- 点击插件图标打开插件,点击连接即可看到mcp的配置
### 在支持MCP协议的客户端中使用
#### 使用streamable http的方式连接(👍🏻推荐)
将以下配置添加到客户端的 MCP 配置中以cherryStudio为例:
> 推荐用streamable http的连接方式
```json
{
"mcpServers": {
"chrome-mcp-server": {
"type": "streamableHttp",
"url": "http://127.0.0.1:12306/mcp"
}
}
}
```
#### 使用stdio的方式连接(备选)
假设你的客户端仅支持stdio的连接方式,那么请使用下面的方法:
1. 先查看你刚刚安装的npm包的安装位置
```sh
# npm 查看方式
npm list -g mcp-chrome-bridge
# pnpm 查看方式
pnpm list -g mcp-chrome-bridge
```
假设上面的命令输出的路径是:/Users/xxx/Library/pnpm/global/5
那么你的最终路径就是:/Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js
2. 把下面的配置替换成你刚刚得到的最终路径
```json
{
"mcpServers": {
"chrome-mcp-stdio": {
"command": "npx",
"args": [
"node",
"/Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js"
]
}
}
}
```
比如:在augment中的配置如下:
## 🛠️ 可用工具
完整工具列表:[完整工具列表](docs/TOOLS_zh.md)
📊 浏览器管理 (6个工具)
- `get_windows_and_tabs` - 列出所有浏览器窗口和标签页
- `chrome_navigate` - 导航到 URL 并控制视口
- `chrome_switch_tab` - 切换当前显示的标签页
- `chrome_close_tabs` - 关闭特定标签页或窗口
- `chrome_go_back_or_forward` - 浏览器导航控制
- `chrome_inject_script` - 向网页注入内容脚本
- `chrome_send_command_to_inject_script` - 向已注入的内容脚本发送指令
📸 截图和视觉 (1个工具)
- `chrome_screenshot` - 高级截图捕获,支持元素定位、全页面和自定义尺寸
🌐 网络监控 (4个工具)
- `chrome_network_capture_start/stop` - webRequest API 网络捕获
- `chrome_network_debugger_start/stop` - Debugger API 包含响应体
- `chrome_network_request` - 发送自定义 HTTP 请求
🔍 内容分析 (4个工具)
- `search_tabs_content` - AI 驱动的浏览器标签页语义搜索
- `chrome_get_web_content` - 从页面提取 HTML/文本内容
- `chrome_get_interactive_elements` - 查找可点击元素
- `chrome_console` - 捕获和获取浏览器标签页的控制台输出
🎯 交互操作 (3个工具)
- `chrome_click_element` - 使用 CSS 选择器点击元素
- `chrome_fill_or_select` - 填充表单和选择选项
- `chrome_keyboard` - 模拟键盘输入和快捷键
📚 数据管理 (5个工具)
- `chrome_history` - 搜索浏览器历史记录,支持时间过滤
- `chrome_bookmark_search` - 按关键词查找书签
- `chrome_bookmark_add` - 添加新书签,支持文件夹
- `chrome_bookmark_delete` - 删除书签
## 🧪 使用示例
### ai帮你总结网页内容然后自动控制excalidraw画图
prompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)
指令:帮我总结当前页面内容,然后画个图帮我理解
https://www.youtube.com/watch?v=3fBPdUBWVz0
https://github.com/user-attachments/assets/f14f79a6-9390-4821-8296-06d020bcfc07
### ai先分析图片的内容元素,然后再自动控制excalidraw把图片模仿出来
prompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)|[content-analize](prompt/content-analize.md)
指令:先看下图片是否能用excalidraw画出来,如果则列出所需的步骤和元素,然后画出来
https://www.youtube.com/watch?v=tEPdHZBzbZk
https://github.com/user-attachments/assets/4f0600c1-bb1e-4b57-85ab-36c8bdf71c68
### ai自动帮你注入脚本并修改网页的样式
prompt: [modify-web-prompt](prompt/modify-web.md)
指令:帮我修改当前页面的样式,去掉广告
https://youtu.be/twI6apRKHsk
https://github.com/user-attachments/assets/aedbe98d-e90c-4a58-a4a5-d888f7293d8e
### ai自动帮你捕获网络请求
指令:我想知道小红书的搜索接口是哪个,响应体结构是什么样的
https://youtu.be/1hHKr7XKqnQ
https://github.com/user-attachments/assets/dc7e5cab-b9af-4b9a-97ce-18e4837318d9
### ai帮你分析你的浏览记录
指令:分析一下我近一个月的浏览记录
https://youtu.be/jf2UZfrR2Vk
https://github.com/user-attachments/assets/31b2e064-88c6-4adb-96d7-50748b826eae
### 网页对话
指令:翻译并总结当前网页
https://youtu.be/FlJKS9UQyC8
https://github.com/user-attachments/assets/aa8ef2a1-2310-47e6-897a-769d85489396
### ai帮你自动截图(网页截图)
指令:把huggingface的首页截个图
https://youtu.be/7ycK6iksWi4
https://github.com/user-attachments/assets/65c6eee2-6366-493d-a3bd-2b27529ff5b3
### ai帮你自动截图(元素截图)
指令:把huggingface首页的图标截取下来
https://youtu.be/ev8VivANIrk
https://github.com/user-attachments/assets/d0cf9785-c2fe-4729-a3c5-7f2b8b96fe0c
### ai帮你管理书签
指令:将当前页面添加到书签中,放到合适的文件夹
https://youtu.be/R_83arKmFTo
https://github.com/user-attachments/assets/15a7d04c-0196-4b40-84c2-bafb5c26dfe0
### 自动关闭网页
指令:关闭所有shadcn相关的网页
https://youtu.be/2wzUT6eNVg4
https://github.com/user-attachments/assets/83de4008-bb7e-494d-9b0f-98325cfea592
## 🤝 贡献指南
我们欢迎贡献!请查看 [CONTRIBUTING_zh.md](docs/CONTRIBUTING_zh.md) 了解详细指南。
## 🚧 未来发展路线图
我们对 Chrome MCP Server 的未来发展有着激动人心的计划:
- [ ] 身份认证
- [ ] 录制与回放
- [ ] 工作流自动化
- [ ] 增强浏览器支持(Firefox 扩展)
---
**想要为这些功能中的任何一个做贡献?** 查看我们的[贡献指南](docs/CONTRIBUTING_zh.md)并加入我们的开发社区!
## 📄 许可证
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件。
## 📚 更多文档
- [架构设计](docs/ARCHITECTURE_zh.md) - 详细的技术架构说明
- [工具列表](docs/TOOLS_zh.md) - 完整的工具 API 文档
- [故障排除](docs/TROUBLESHOOTING_zh.md) - 常见问题解决方案
## 微信交流群
拉群的目的是让踩过坑的大佬们互相帮忙解答问题,因本人平时要忙着搬砖,不一定能及时解答

================================================
FILE: app/chrome-extension/LICENSE
================================================
MIT License
Copyright (c) 2025 hangwin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: app/chrome-extension/README.md
================================================
# WXT + Vue 3
This template should help get you started developing with Vue 3 in WXT.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar).
================================================
FILE: app/chrome-extension/_locales/de/messages.json
================================================
{
"extensionName": {
"message": "chrome-mcp-server",
"description": "Erweiterungsname"
},
"extensionDescription": {
"message": "Stellt Browser-Funktionen mit Ihrem eigenen Chrome zur Verfügung",
"description": "Erweiterungsbeschreibung"
},
"nativeServerConfigLabel": {
"message": "Native Server-Konfiguration",
"description": "Hauptabschnittstitel für Native Server-Einstellungen"
},
"semanticEngineLabel": {
"message": "Semantische Engine",
"description": "Hauptabschnittstitel für semantische Engine"
},
"embeddingModelLabel": {
"message": "Embedding-Modell",
"description": "Hauptabschnittstitel für Modellauswahl"
},
"indexDataManagementLabel": {
"message": "Index-Datenverwaltung",
"description": "Hauptabschnittstitel für Datenverwaltung"
},
"modelCacheManagementLabel": {
"message": "Modell-Cache-Verwaltung",
"description": "Hauptabschnittstitel für Cache-Verwaltung"
},
"statusLabel": {
"message": "Status",
"description": "Allgemeines Statuslabel"
},
"runningStatusLabel": {
"message": "Betriebsstatus",
"description": "Server-Betriebsstatuslabel"
},
"connectionStatusLabel": {
"message": "Verbindungsstatus",
"description": "Verbindungsstatuslabel"
},
"lastUpdatedLabel": {
"message": "Zuletzt aktualisiert:",
"description": "Zeitstempel der letzten Aktualisierung"
},
"connectButton": {
"message": "Verbinden",
"description": "Verbinden-Schaltflächentext"
},
"disconnectButton": {
"message": "Trennen",
"description": "Trennen-Schaltflächentext"
},
"connectingStatus": {
"message": "Verbindung wird hergestellt...",
"description": "Verbindungsstatusmeldung"
},
"connectedStatus": {
"message": "Verbunden",
"description": "Verbunden-Statusmeldung"
},
"disconnectedStatus": {
"message": "Getrennt",
"description": "Getrennt-Statusmeldung"
},
"detectingStatus": {
"message": "Erkennung läuft...",
"description": "Erkennungsstatusmeldung"
},
"serviceRunningStatus": {
"message": "Service läuft (Port: $PORT$)",
"description": "Service läuft mit Portnummer",
"placeholders": {
"port": {
"content": "$1",
"example": "12306"
}
}
},
"serviceNotConnectedStatus": {
"message": "Service nicht verbunden",
"description": "Service nicht verbunden Status"
},
"connectedServiceNotStartedStatus": {
"message": "Verbunden, Service nicht gestartet",
"description": "Verbunden aber Service nicht gestartet Status"
},
"mcpServerConfigLabel": {
"message": "MCP Server-Konfiguration",
"description": "MCP Server-Konfigurationsabschnittslabel"
},
"connectionPortLabel": {
"message": "Verbindungsport",
"description": "Verbindungsport-Eingabelabel"
},
"refreshStatusButton": {
"message": "Status aktualisieren",
"description": "Status aktualisieren Schaltflächen-Tooltip"
},
"copyConfigButton": {
"message": "Konfiguration kopieren",
"description": "Konfiguration kopieren Schaltflächentext"
},
"retryButton": {
"message": "Wiederholen",
"description": "Wiederholen-Schaltflächentext"
},
"cancelButton": {
"message": "Abbrechen",
"description": "Abbrechen-Schaltflächentext"
},
"confirmButton": {
"message": "Bestätigen",
"description": "Bestätigen-Schaltflächentext"
},
"saveButton": {
"message": "Speichern",
"description": "Speichern-Schaltflächentext"
},
"closeButton": {
"message": "Schließen",
"description": "Schließen-Schaltflächentext"
},
"resetButton": {
"message": "Zurücksetzen",
"description": "Zurücksetzen-Schaltflächentext"
},
"initializingStatus": {
"message": "Initialisierung...",
"description": "Initialisierung-Fortschrittsmeldung"
},
"processingStatus": {
"message": "Verarbeitung...",
"description": "Verarbeitung-Fortschrittsmeldung"
},
"loadingStatus": {
"message": "Wird geladen...",
"description": "Ladefortschrittsmeldung"
},
"clearingStatus": {
"message": "Wird geleert...",
"description": "Leerungsfortschrittsmeldung"
},
"cleaningStatus": {
"message": "Wird bereinigt...",
"description": "Bereinigungsfortschrittsmeldung"
},
"downloadingStatus": {
"message": "Wird heruntergeladen...",
"description": "Download-Fortschrittsmeldung"
},
"semanticEngineReadyStatus": {
"message": "Semantische Engine bereit",
"description": "Semantische Engine bereit Status"
},
"semanticEngineInitializingStatus": {
"message": "Semantische Engine wird initialisiert...",
"description": "Semantische Engine Initialisierungsstatus"
},
"semanticEngineInitFailedStatus": {
"message": "Initialisierung der semantischen Engine fehlgeschlagen",
"description": "Semantische Engine Initialisierung fehlgeschlagen Status"
},
"semanticEngineNotInitStatus": {
"message": "Semantische Engine nicht initialisiert",
"description": "Semantische Engine nicht initialisiert Status"
},
"initSemanticEngineButton": {
"message": "Semantische Engine initialisieren",
"description": "Semantische Engine initialisieren Schaltflächentext"
},
"reinitializeButton": {
"message": "Neu initialisieren",
"description": "Neu initialisieren Schaltflächentext"
},
"downloadingModelStatus": {
"message": "Modell wird heruntergeladen... $PROGRESS$%",
"description": "Modell-Download-Fortschritt mit Prozentsatz",
"placeholders": {
"progress": {
"content": "$1",
"example": "50"
}
}
},
"switchingModelStatus": {
"message": "Modell wird gewechselt...",
"description": "Modellwechsel-Fortschrittsmeldung"
},
"modelLoadedStatus": {
"message": "Modell geladen",
"description": "Modell erfolgreich geladen Status"
},
"modelFailedStatus": {
"message": "Modell konnte nicht geladen werden",
"description": "Modell-Ladefehler Status"
},
"lightweightModelDescription": {
"message": "Leichtgewichtiges mehrsprachiges Modell",
"description": "Beschreibung für leichtgewichtige Modelloption"
},
"betterThanSmallDescription": {
"message": "Etwas größer als e5-small, aber bessere Leistung",
"description": "Beschreibung für mittlere Modelloption"
},
"multilingualModelDescription": {
"message": "Mehrsprachiges semantisches Modell",
"description": "Beschreibung für mehrsprachige Modelloption"
},
"fastPerformance": {
"message": "Schnell",
"description": "Schnelle Leistungsanzeige"
},
"balancedPerformance": {
"message": "Ausgewogen",
"description": "Ausgewogene Leistungsanzeige"
},
"accuratePerformance": {
"message": "Genau",
"description": "Genaue Leistungsanzeige"
},
"networkErrorMessage": {
"message": "Netzwerkverbindungsfehler, bitte Netzwerk prüfen und erneut versuchen",
"description": "Netzwerkverbindungsfehlermeldung"
},
"modelCorruptedErrorMessage": {
"message": "Modelldatei beschädigt oder unvollständig, bitte Download wiederholen",
"description": "Modell-Beschädigungsfehlermeldung"
},
"unknownErrorMessage": {
"message": "Unbekannter Fehler, bitte prüfen Sie, ob Ihr Netzwerk auf HuggingFace zugreifen kann",
"description": "Unbekannte Fehler-Rückfallmeldung"
},
"permissionDeniedErrorMessage": {
"message": "Zugriff verweigert",
"description": "Zugriff verweigert Fehlermeldung"
},
"timeoutErrorMessage": {
"message": "Zeitüberschreitung",
"description": "Zeitüberschreitungsfehlermeldung"
},
"indexedPagesLabel": {
"message": "Indizierte Seiten",
"description": "Anzahl indizierter Seiten Label"
},
"indexSizeLabel": {
"message": "Indexgröße",
"description": "Indexgröße Label"
},
"activeTabsLabel": {
"message": "Aktive Tabs",
"description": "Anzahl aktiver Tabs Label"
},
"vectorDocumentsLabel": {
"message": "Vektordokumente",
"description": "Anzahl Vektordokumente Label"
},
"cacheSizeLabel": {
"message": "Cache-Größe",
"description": "Cache-Größe Label"
},
"cacheEntriesLabel": {
"message": "Cache-Einträge",
"description": "Anzahl Cache-Einträge Label"
},
"clearAllDataButton": {
"message": "Alle Daten löschen",
"description": "Alle Daten löschen Schaltflächentext"
},
"clearAllCacheButton": {
"message": "Gesamten Cache löschen",
"description": "Gesamten Cache löschen Schaltflächentext"
},
"cleanExpiredCacheButton": {
"message": "Abgelaufenen Cache bereinigen",
"description": "Abgelaufenen Cache bereinigen Schaltflächentext"
},
"exportDataButton": {
"message": "Daten exportieren",
"description": "Daten exportieren Schaltflächentext"
},
"importDataButton": {
"message": "Daten importieren",
"description": "Daten importieren Schaltflächentext"
},
"confirmClearDataTitle": {
"message": "Datenlöschung bestätigen",
"description": "Datenlöschung bestätigen Dialogtitel"
},
"settingsTitle": {
"message": "Einstellungen",
"description": "Einstellungen Dialogtitel"
},
"aboutTitle": {
"message": "Über",
"description": "Über Dialogtitel"
},
"helpTitle": {
"message": "Hilfe",
"description": "Hilfe Dialogtitel"
},
"clearDataWarningMessage": {
"message": "Diese Aktion löscht alle indizierten Webseiteninhalte und Vektordaten, einschließlich:",
"description": "Datenlöschung Warnmeldung"
},
"clearDataList1": {
"message": "Alle Webseitentextinhaltsindizes",
"description": "Erster Punkt in Datenlöschungsliste"
},
"clearDataList2": {
"message": "Vektor-Embedding-Daten",
"description": "Zweiter Punkt in Datenlöschungsliste"
},
"clearDataList3": {
"message": "Suchverlauf und Cache",
"description": "Dritter Punkt in Datenlöschungsliste"
},
"clearDataIrreversibleWarning": {
"message": "Diese Aktion ist unwiderruflich! Nach dem Löschen müssen Sie Webseiten erneut durchsuchen, um den Index neu aufzubauen.",
"description": "Unwiderrufliche Aktion Warnung"
},
"confirmClearButton": {
"message": "Löschung bestätigen",
"description": "Löschung bestätigen Aktionsschaltfläche"
},
"cacheDetailsLabel": {
"message": "Cache-Details",
"description": "Cache-Details Abschnittslabel"
},
"noCacheDataMessage": {
"message": "Keine Cache-Daten vorhanden",
"description": "Keine Cache-Daten verfügbar Meldung"
},
"loadingCacheInfoStatus": {
"message": "Cache-Informationen werden geladen...",
"description": "Cache-Informationen laden Status"
},
"processingCacheStatus": {
"message": "Cache wird verarbeitet...",
"description": "Cache verarbeiten Status"
},
"expiredLabel": {
"message": "Abgelaufen",
"description": "Abgelaufenes Element Label"
},
"bookmarksBarLabel": {
"message": "Lesezeichenleiste",
"description": "Lesezeichenleiste Ordnername"
},
"newTabLabel": {
"message": "Neuer Tab",
"description": "Neuer Tab Label"
},
"currentPageLabel": {
"message": "Aktuelle Seite",
"description": "Aktuelle Seite Label"
},
"menuLabel": {
"message": "Menü",
"description": "Menü Barrierefreiheitslabel"
},
"navigationLabel": {
"message": "Navigation",
"description": "Navigation Barrierefreiheitslabel"
},
"mainContentLabel": {
"message": "Hauptinhalt",
"description": "Hauptinhalt Barrierefreiheitslabel"
},
"languageSelectorLabel": {
"message": "Sprache",
"description": "Sprachauswahl Label"
},
"themeLabel": {
"message": "Design",
"description": "Design-Auswahl Label"
},
"lightTheme": {
"message": "Hell",
"description": "Helles Design Option"
},
"darkTheme": {
"message": "Dunkel",
"description": "Dunkles Design Option"
},
"autoTheme": {
"message": "Automatisch",
"description": "Automatisches Design Option"
},
"advancedSettingsLabel": {
"message": "Erweiterte Einstellungen",
"description": "Erweiterte Einstellungen Abschnittslabel"
},
"debugModeLabel": {
"message": "Debug-Modus",
"description": "Debug-Modus Umschalter Label"
},
"verboseLoggingLabel": {
"message": "Ausführliche Protokollierung",
"description": "Ausführliche Protokollierung Umschalter Label"
},
"successNotification": {
"message": "Vorgang erfolgreich abgeschlossen",
"description": "Allgemeine Erfolgsmeldung"
},
"warningNotification": {
"message": "Warnung: Bitte prüfen Sie vor dem Fortfahren",
"description": "Allgemeine Warnmeldung"
},
"infoNotification": {
"message": "Information",
"description": "Allgemeine Informationsmeldung"
},
"configCopiedNotification": {
"message": "Konfiguration in Zwischenablage kopiert",
"description": "Konfiguration kopiert Erfolgsmeldung"
},
"dataClearedNotification": {
"message": "Daten erfolgreich gelöscht",
"description": "Daten gelöscht Erfolgsmeldung"
},
"bytesUnit": {
"message": "Bytes",
"description": "Bytes Einheit"
},
"kilobytesUnit": {
"message": "KB",
"description": "Kilobytes Einheit"
},
"megabytesUnit": {
"message": "MB",
"description": "Megabytes Einheit"
},
"gigabytesUnit": {
"message": "GB",
"description": "Gigabytes Einheit"
},
"itemsUnit": {
"message": "Elemente",
"description": "Elemente Zähleinheit"
},
"pagesUnit": {
"message": "Seiten",
"description": "Seiten Zähleinheit"
}
}
================================================
FILE: app/chrome-extension/_locales/en/messages.json
================================================
{
"extensionName": {
"message": "chrome-mcp-server",
"description": "Extension name"
},
"extensionDescription": {
"message": "Exposes browser capabilities with your own chrome",
"description": "Extension description"
},
"nativeServerConfigLabel": {
"message": "Native Server Configuration",
"description": "Main section header for native server settings"
},
"semanticEngineLabel": {
"message": "Semantic Engine",
"description": "Main section header for semantic engine"
},
"embeddingModelLabel": {
"message": "Embedding Model",
"description": "Main section header for model selection"
},
"indexDataManagementLabel": {
"message": "Index Data Management",
"description": "Main section header for data management"
},
"modelCacheManagementLabel": {
"message": "Model Cache Management",
"description": "Main section header for cache management"
},
"statusLabel": {
"message": "Status",
"description": "Generic status label"
},
"runningStatusLabel": {
"message": "Running Status",
"description": "Server running status label"
},
"connectionStatusLabel": {
"message": "Connection Status",
"description": "Connection status label"
},
"lastUpdatedLabel": {
"message": "Last Updated:",
"description": "Last updated timestamp label"
},
"connectButton": {
"message": "Connect",
"description": "Connect button text"
},
"disconnectButton": {
"message": "Disconnect",
"description": "Disconnect button text"
},
"connectingStatus": {
"message": "Connecting...",
"description": "Connecting status message"
},
"connectedStatus": {
"message": "Connected",
"description": "Connected status message"
},
"disconnectedStatus": {
"message": "Disconnected",
"description": "Disconnected status message"
},
"detectingStatus": {
"message": "Detecting...",
"description": "Detecting status message"
},
"serviceRunningStatus": {
"message": "Service Running (Port: $PORT$)",
"description": "Service running with port number",
"placeholders": {
"port": {
"content": "$1",
"example": "12306"
}
}
},
"serviceNotConnectedStatus": {
"message": "Service Not Connected",
"description": "Service not connected status"
},
"connectedServiceNotStartedStatus": {
"message": "Connected, Service Not Started",
"description": "Connected but service not started status"
},
"mcpServerConfigLabel": {
"message": "MCP Server Configuration",
"description": "MCP server configuration section label"
},
"connectionPortLabel": {
"message": "Connection Port",
"description": "Connection port input label"
},
"refreshStatusButton": {
"message": "Refresh Status",
"description": "Refresh status button tooltip"
},
"copyConfigButton": {
"message": "Copy Configuration",
"description": "Copy configuration button text"
},
"retryButton": {
"message": "Retry",
"description": "Retry button text"
},
"cancelButton": {
"message": "Cancel",
"description": "Cancel button text"
},
"confirmButton": {
"message": "Confirm",
"description": "Confirm button text"
},
"saveButton": {
"message": "Save",
"description": "Save button text"
},
"closeButton": {
"message": "Close",
"description": "Close button text"
},
"resetButton": {
"message": "Reset",
"description": "Reset button text"
},
"initializingStatus": {
"message": "Initializing...",
"description": "Initializing progress message"
},
"processingStatus": {
"message": "Processing...",
"description": "Processing progress message"
},
"loadingStatus": {
"message": "Loading...",
"description": "Loading progress message"
},
"clearingStatus": {
"message": "Clearing...",
"description": "Clearing progress message"
},
"cleaningStatus": {
"message": "Cleaning...",
"description": "Cleaning progress message"
},
"downloadingStatus": {
"message": "Downloading...",
"description": "Downloading progress message"
},
"semanticEngineReadyStatus": {
"message": "Semantic Engine Ready",
"description": "Semantic engine ready status"
},
"semanticEngineInitializingStatus": {
"message": "Semantic Engine Initializing...",
"description": "Semantic engine initializing status"
},
"semanticEngineInitFailedStatus": {
"message": "Semantic Engine Initialization Failed",
"description": "Semantic engine initialization failed status"
},
"semanticEngineNotInitStatus": {
"message": "Semantic Engine Not Initialized",
"description": "Semantic engine not initialized status"
},
"initSemanticEngineButton": {
"message": "Initialize Semantic Engine",
"description": "Initialize semantic engine button text"
},
"reinitializeButton": {
"message": "Reinitialize",
"description": "Reinitialize button text"
},
"downloadingModelStatus": {
"message": "Downloading Model... $PROGRESS$%",
"description": "Model download progress with percentage",
"placeholders": {
"progress": {
"content": "$1",
"example": "50"
}
}
},
"switchingModelStatus": {
"message": "Switching Model...",
"description": "Model switching progress message"
},
"modelLoadedStatus": {
"message": "Model Loaded",
"description": "Model successfully loaded status"
},
"modelFailedStatus": {
"message": "Model Failed to Load",
"description": "Model failed to load status"
},
"lightweightModelDescription": {
"message": "Lightweight Multilingual Model",
"description": "Description for lightweight model option"
},
"betterThanSmallDescription": {
"message": "Slightly larger than e5-small, but better performance",
"description": "Description for medium model option"
},
"multilingualModelDescription": {
"message": "Multilingual Semantic Model",
"description": "Description for multilingual model option"
},
"fastPerformance": {
"message": "Fast",
"description": "Fast performance indicator"
},
"balancedPerformance": {
"message": "Balanced",
"description": "Balanced performance indicator"
},
"accuratePerformance": {
"message": "Accurate",
"description": "Accurate performance indicator"
},
"networkErrorMessage": {
"message": "Network connection error, please check network and retry",
"description": "Network connection error message"
},
"modelCorruptedErrorMessage": {
"message": "Model file corrupted or incomplete, please retry download",
"description": "Model corruption error message"
},
"unknownErrorMessage": {
"message": "Unknown error, please check if your network can access HuggingFace",
"description": "Unknown error fallback message"
},
"permissionDeniedErrorMessage": {
"message": "Permission denied",
"description": "Permission denied error message"
},
"timeoutErrorMessage": {
"message": "Operation timed out",
"description": "Timeout error message"
},
"indexedPagesLabel": {
"message": "Indexed Pages",
"description": "Number of indexed pages label"
},
"indexSizeLabel": {
"message": "Index Size",
"description": "Index size label"
},
"activeTabsLabel": {
"message": "Active Tabs",
"description": "Number of active tabs label"
},
"vectorDocumentsLabel": {
"message": "Vector Documents",
"description": "Number of vector documents label"
},
"cacheSizeLabel": {
"message": "Cache Size",
"description": "Cache size label"
},
"cacheEntriesLabel": {
"message": "Cache Entries",
"description": "Number of cache entries label"
},
"clearAllDataButton": {
"message": "Clear All Data",
"description": "Clear all data button text"
},
"clearAllCacheButton": {
"message": "Clear All Cache",
"description": "Clear all cache button text"
},
"cleanExpiredCacheButton": {
"message": "Clean Expired Cache",
"description": "Clean expired cache button text"
},
"exportDataButton": {
"message": "Export Data",
"description": "Export data button text"
},
"importDataButton": {
"message": "Import Data",
"description": "Import data button text"
},
"confirmClearDataTitle": {
"message": "Confirm Clear Data",
"description": "Clear data confirmation dialog title"
},
"settingsTitle": {
"message": "Settings",
"description": "Settings dialog title"
},
"aboutTitle": {
"message": "About",
"description": "About dialog title"
},
"helpTitle": {
"message": "Help",
"description": "Help dialog title"
},
"clearDataWarningMessage": {
"message": "This operation will clear all indexed webpage content and vector data, including:",
"description": "Clear data warning message"
},
"clearDataList1": {
"message": "All webpage text content index",
"description": "First item in clear data list"
},
"clearDataList2": {
"message": "Vector embedding data",
"description": "Second item in clear data list"
},
"clearDataList3": {
"message": "Search history and cache",
"description": "Third item in clear data list"
},
"clearDataIrreversibleWarning": {
"message": "This operation is irreversible! After clearing, you need to browse webpages again to rebuild the index.",
"description": "Irreversible operation warning"
},
"confirmClearButton": {
"message": "Confirm Clear",
"description": "Confirm clear action button"
},
"cacheDetailsLabel": {
"message": "Cache Details",
"description": "Cache details section label"
},
"noCacheDataMessage": {
"message": "No cache data",
"description": "No cache data available message"
},
"loadingCacheInfoStatus": {
"message": "Loading cache information...",
"description": "Loading cache information status"
},
"processingCacheStatus": {
"message": "Processing cache...",
"description": "Processing cache status"
},
"expiredLabel": {
"message": "Expired",
"description": "Expired item label"
},
"bookmarksBarLabel": {
"message": "Bookmarks Bar",
"description": "Bookmarks bar folder name"
},
"newTabLabel": {
"message": "New Tab",
"description": "New tab label"
},
"currentPageLabel": {
"message": "Current Page",
"description": "Current page label"
},
"menuLabel": {
"message": "Menu",
"description": "Menu accessibility label"
},
"navigationLabel": {
"message": "Navigation",
"description": "Navigation accessibility label"
},
"mainContentLabel": {
"message": "Main Content",
"description": "Main content accessibility label"
},
"languageSelectorLabel": {
"message": "Language",
"description": "Language selector label"
},
"themeLabel": {
"message": "Theme",
"description": "Theme selector label"
},
"lightTheme": {
"message": "Light",
"description": "Light theme option"
},
"darkTheme": {
"message": "Dark",
"description": "Dark theme option"
},
"autoTheme": {
"message": "Auto",
"description": "Auto theme option"
},
"advancedSettingsLabel": {
"message": "Advanced Settings",
"description": "Advanced settings section label"
},
"debugModeLabel": {
"message": "Debug Mode",
"description": "Debug mode toggle label"
},
"verboseLoggingLabel": {
"message": "Verbose Logging",
"description": "Verbose logging toggle label"
},
"successNotification": {
"message": "Operation completed successfully",
"description": "Generic success notification"
},
"warningNotification": {
"message": "Warning: Please review before proceeding",
"description": "Generic warning notification"
},
"infoNotification": {
"message": "Information",
"description": "Generic info notification"
},
"configCopiedNotification": {
"message": "Configuration copied to clipboard",
"description": "Configuration copied success message"
},
"dataClearedNotification": {
"message": "Data cleared successfully",
"description": "Data cleared success message"
},
"bytesUnit": {
"message": "bytes",
"description": "Bytes unit"
},
"kilobytesUnit": {
"message": "KB",
"description": "Kilobytes unit"
},
"megabytesUnit": {
"message": "MB",
"description": "Megabytes unit"
},
"gigabytesUnit": {
"message": "GB",
"description": "Gigabytes unit"
},
"itemsUnit": {
"message": "items",
"description": "Items count unit"
},
"pagesUnit": {
"message": "pages",
"description": "Pages count unit"
},
"userscriptsManagerTitle": {
"message": "Userscripts Manager",
"description": "Options page title"
},
"emergencySwitchLabel": { "message": "Emergency Switch", "description": "Global disable switch" },
"createRunSectionTitle": {
"message": "Create / Run",
"description": "Create & run section title"
},
"nameLabel": { "message": "Name", "description": "Name input label" },
"runAtLabel": { "message": "Run At", "description": "runAt select label" },
"runAtAuto": { "message": "auto", "description": "runAt auto" },
"runAtDocumentStart": { "message": "document_start", "description": "runAt document_start" },
"runAtDocumentEnd": { "message": "document_end", "description": "runAt document_end" },
"runAtDocumentIdle": { "message": "document_idle", "description": "runAt document_idle" },
"worldLabel": { "message": "World", "description": "world select label" },
"worldAuto": { "message": "auto", "description": "world auto" },
"worldIsolated": { "message": "ISOLATED", "description": "ISOLATED world" },
"worldMain": { "message": "MAIN", "description": "MAIN world" },
"modeLabel": { "message": "Mode", "description": "mode select label" },
"modeAuto": { "message": "auto", "description": "mode auto" },
"modePersistent": { "message": "persistent", "description": "mode persistent" },
"modeCss": { "message": "css", "description": "mode css" },
"modeOnce": { "message": "once", "description": "mode once" },
"allFramesLabel": { "message": "All Frames", "description": "allFrames checkbox" },
"persistLabel": { "message": "Persist", "description": "persist checkbox" },
"dnrFallbackLabel": { "message": "DNR Fallback", "description": "dnr fallback checkbox" },
"matchesInputLabel": { "message": "Matches (comma-separated)", "description": "matches input" },
"excludesInputLabel": {
"message": "Excludes (comma-separated)",
"description": "excludes input"
},
"tagsInputLabel": { "message": "Tags (comma-separated)", "description": "tags input" },
"scriptLabel": { "message": "Script", "description": "script textarea label" },
"applyButton": { "message": "Apply", "description": "apply button" },
"runOnceButton": { "message": "Run Once (CDP)", "description": "run once button" },
"listSectionTitle": { "message": "List", "description": "list section title" },
"queryLabel": { "message": "Query", "description": "query input label" },
"statusAll": { "message": "all", "description": "status all" },
"statusEnabled": { "message": "enabled", "description": "status enabled" },
"statusDisabled": { "message": "disabled", "description": "status disabled" },
"domainLabel": { "message": "Domain", "description": "domain filter label" },
"exportAllButton": { "message": "Export All", "description": "export button" },
"tableHeaderName": { "message": "Name", "description": "table header name" },
"tableHeaderWorld": { "message": "World", "description": "table header world" },
"tableHeaderRunAt": { "message": "Run At", "description": "table header runAt" },
"tableHeaderUpdated": { "message": "Updated", "description": "table header updated" },
"deleteButton": { "message": "Delete", "description": "delete button" },
"placeholderOptional": { "message": "optional", "description": "generic optional placeholder" },
"placeholderMatchesExample": {
"message": "e.g. https://*.example.com/*",
"description": "matches example placeholder"
},
"placeholderScriptHint": {
"message": "Paste JS/CSS/TM here",
"description": "script textarea placeholder"
},
"placeholderDomainHint": { "message": "example.com", "description": "domain filter placeholder" }
}
================================================
FILE: app/chrome-extension/_locales/ja/messages.json
================================================
{
"extensionName": {
"message": "Chrome MCPサーバー"
},
"extensionDescription": {
"message": "自身のChromeブラウザの機能を外部に公開します"
},
"nativeServerConfigLabel": {
"message": "ネイティブサーバー設定"
},
"semanticEngineLabel": {
"message": "セマンティックエンジン"
},
"embeddingModelLabel": {
"message": "埋め込みモデル"
},
"indexDataManagementLabel": {
"message": "インデックスデータ管理"
},
"modelCacheManagementLabel": {
"message": "モデルキャッシュ管理"
},
"statusLabel": {
"message": "ステータス"
},
"runningStatusLabel": {
"message": "実行ステータス"
},
"connectionStatusLabel": {
"message": "接続ステータス"
},
"lastUpdatedLabel": {
"message": "最終更新:"
},
"connectButton": {
"message": "接続"
},
"disconnectButton": {
"message": "切断"
},
"connectingStatus": {
"message": "接続中..."
},
"connectedStatus": {
"message": "接続済み"
},
"disconnectedStatus": {
"message": "未接続"
},
"detectingStatus": {
"message": "検出中..."
},
"serviceRunningStatus": {
"message": "サービス実行中 (ポート: $1)",
"placeholders": {
"port": {
"content": "$1",
"example": "12306"
}
}
},
"serviceNotConnectedStatus": {
"message": "サービス未接続"
},
"connectedServiceNotStartedStatus": {
"message": "接続済み、サービス未起動"
},
"mcpServerConfigLabel": {
"message": "MCPサーバー設定"
},
"connectionPortLabel": {
"message": "接続ポート"
},
"refreshStatusButton": {
"message": "ステータス更新"
},
"copyConfigButton": {
"message": "設定をコピー"
},
"retryButton": {
"message": "再試行"
},
"cancelButton": {
"message": "キャンセル"
},
"confirmButton": {
"message": "確認"
},
"saveButton": {
"message": "保存"
},
"closeButton": {
"message": "閉じる"
},
"resetButton": {
"message": "リセット"
},
"initializingStatus": {
"message": "初期化中..."
},
"processingStatus": {
"message": "処理中..."
},
"loadingStatus": {
"message": "読み込み中..."
},
"clearingStatus": {
"message": "クリア中..."
},
"cleaningStatus": {
"message": "クリーンアップ中..."
},
"downloadingStatus": {
"message": "ダウンロード中..."
},
"semanticEngineReadyStatus": {
"message": "セマンティックエンジン準備完了"
},
"semanticEngineInitializingStatus": {
"message": "セマンティックエンジン初期化中..."
},
"semanticEngineInitFailedStatus": {
"message": "セマンティックエンジンの初期化に失敗しました"
},
"semanticEngineNotInitStatus": {
"message": "セマンティックエンジン未初期化"
},
"initSemanticEngineButton": {
"message": "セマンティックエンジンを初期化"
},
"reinitializeButton": {
"message": "再初期化"
},
"downloadingModelStatus": {
"message": "モデルをダウンロード中... $1%",
"placeholders": {
"progress": {
"content": "$1",
"example": "50"
}
}
},
"switchingModelStatus": {
"message": "モデルを切り替え中..."
},
"modelLoadedStatus": {
"message": "モデル読み込み完了"
},
"modelFailedStatus": {
"message": "モデルの読み込みに失敗しました"
},
"lightweightModelDescription": {
"message": "軽量多言語モデル"
},
"betterThanSmallDescription": {
"message": "e5-smallよりわずかに大きいが、性能は向上"
},
"multilingualModelDescription": {
"message": "多言語対応セマンティックモデル"
},
"fastPerformance": {
"message": "高速"
},
"balancedPerformance": {
"message": "バランス"
},
"accuratePerformance": {
"message": "高精度"
},
"networkErrorMessage": {
"message": "ネットワーク接続エラーです。ネットワークを確認して再試行してください"
},
"modelCorruptedErrorMessage": {
"message": "モデルファイルが破損しているか不完全です。再ダウンロードしてください"
},
"unknownErrorMessage": {
"message": "不明なエラーです。ネットワークがHuggingFaceにアクセスできるか確認してください"
},
"permissionDeniedErrorMessage": {
"message": "権限が拒否されました"
},
"timeoutErrorMessage": {
"message": "操作がタイムアウトしました"
},
"indexedPagesLabel": {
"message": "インデックス化されたページ"
},
"indexSizeLabel": {
"message": "インデックスサイズ"
},
"activeTabsLabel": {
"message": "アクティブなタブ"
},
"vectorDocumentsLabel": {
"message": "ベクトルドキュメント"
},
"cacheSizeLabel": {
"message": "キャッシュサイズ"
},
"cacheEntriesLabel": {
"message": "キャッシュエントリ"
},
"clearAllDataButton": {
"message": "全データをクリア"
},
"clearAllCacheButton": {
"message": "全キャッシュをクリア"
},
"cleanExpiredCacheButton": {
"message": "期限切れキャッシュをクリーンアップ"
},
"exportDataButton": {
"message": "データのエクスポート"
},
"importDataButton": {
"message": "データのインポート"
},
"confirmClearDataTitle": {
"message": "データクリアの確認"
},
"settingsTitle": {
"message": "設定"
},
"aboutTitle": {
"message": "情報"
},
"helpTitle": {
"message": "ヘルプ"
},
"clearDataWarningMessage": {
"message": "この操作は、インデックス化されたすべてのウェブページコンテンツとベクトルデータをクリアします。これには以下が含まれます:"
},
"clearDataList1": {
"message": "すべてのウェブページテキストコンテンツインデックス"
},
"clearDataList2": {
"message": "ベクトル埋め込みデータ"
},
"clearDataList3": {
"message": "検索履歴とキャッシュ"
},
"clearDataIrreversibleWarning": {
"message": "この操作は元に戻せません!クリア後、再度ウェブページを閲覧してインデックスを再構築する必要があります。"
},
"confirmClearButton": {
"message": "クリアを確認"
},
"cacheDetailsLabel": {
"message": "キャッシュ詳細"
},
"noCacheDataMessage": {
"message": "キャッシュデータがありません"
},
"loadingCacheInfoStatus": {
"message": "キャッシュ情報を読み込み中..."
},
"processingCacheStatus": {
"message": "キャッシュを処理中..."
},
"expiredLabel": {
"message": "期限切れ"
},
"bookmarksBarLabel": {
"message": "ブックマークバー"
},
"newTabLabel": {
"message": "新しいタブ"
},
"currentPageLabel": {
"message": "現在のページ"
},
"menuLabel": {
"message": "メニュー"
},
"navigationLabel": {
"message": "ナビゲーション"
},
"mainContentLabel": {
"message": "メインコンテンツ"
},
"languageSelectorLabel": {
"message": "言語"
},
"themeLabel": {
"message": "テーマ"
},
"lightTheme": {
"message": "ライト"
},
"darkTheme": {
"message": "ダーク"
},
"autoTheme": {
"message": "自動"
},
"advancedSettingsLabel": {
"message": "詳細設定"
},
"debugModeLabel": {
"message": "デバッグモード"
},
"verboseLoggingLabel": {
"message": "詳細ロギング"
},
"successNotification": {
"message": "操作が正常に完了しました"
},
"warningNotification": {
"message": "警告:続行する前に確認してください"
},
"infoNotification": {
"message": "情報"
},
"configCopiedNotification": {
"message": "設定がクリップボードにコピーされました"
},
"dataClearedNotification": {
"message": "データが正常にクリアされました"
},
"bytesUnit": {
"message": "バイト"
},
"kilobytesUnit": {
"message": "KB"
},
"megabytesUnit": {
"message": "MB"
},
"gigabytesUnit": {
"message": "GB"
},
"itemsUnit": {
"message": "項目"
},
"pagesUnit": {
"message": "ページ"
}
}
================================================
FILE: app/chrome-extension/_locales/ko/messages.json
================================================
{
"extensionName": {
"message": "chrome-mcp-server",
"description": "확장 프로그램 이름"
},
"extensionDescription": {
"message": "크롬 브라우저와 연동하여 브라우저 기능을 제어하는 MCP 서버입니다.",
"description": "확장 프로그램 설명"
},
"nativeServerConfigLabel": {
"message": "네이티브 서버 설정",
"description": "네이티브 서버 설정의 주 섹션 제목"
},
"semanticEngineLabel": {
"message": "시맨틱 엔진",
"description": "시맨틱 엔진의 주 섹션 제목"
},
"embeddingModelLabel": {
"message": "임베딩 모델",
"description": "모델 선택의 주 섹션 제목"
},
"indexDataManagementLabel": {
"message": "인덱스 데이터 관리",
"description": "데이터 관리의 주 섹션 제목"
},
"modelCacheManagementLabel": {
"message": "모델 캐시 관리",
"description": "캐시 관리의 주 섹션 제목"
},
"statusLabel": {
"message": "상태",
"description": "일반 상태 레이블"
},
"runningStatusLabel": {
"message": "실행 상태",
"description": "서버 실행 상태 레이블"
},
"connectionStatusLabel": {
"message": "연결 상태",
"description": "연결 상태 레이블"
},
"lastUpdatedLabel": {
"message": "마지막 업데이트:",
"description": "마지막 업데이트 타임스탬프 레이블"
},
"connectButton": {
"message": "연결",
"description": "연결 버튼 텍스트"
},
"disconnectButton": {
"message": "연결 끊기",
"description": "연결 끊기 버튼 텍스트"
},
"connectingStatus": {
"message": "연결 중...",
"description": "연결 상태 메시지"
},
"connectedStatus": {
"message": "연결됨",
"description": "연결된 상태 메시지"
},
"disconnectedStatus": {
"message": "연결 끊김",
"description": "연결이 끊긴 상태 메시지"
},
"detectingStatus": {
"message": "감지 중...",
"description": "감지 상태 메시지"
},
"serviceRunningStatus": {
"message": "서비스 실행 중 (포트: $PORT$)",
"description": "포트 번호와 함께 서비스 실행 중 상태",
"placeholders": {
"port": {
"content": "$1",
"example": "12306"
}
}
},
"serviceNotConnectedStatus": {
"message": "서비스에 연결되지 않음",
"description": "서비스가 연결되지 않은 상태"
},
"connectedServiceNotStartedStatus": {
"message": "연결됨, 서비스 시작되지 않음",
"description": "연결되었지만 서비스가 시작되지 않은 상태"
},
"mcpServerConfigLabel": {
"message": "MCP 서버 설정",
"description": "MCP 서버 설정 섹션 레이블"
},
"connectionPortLabel": {
"message": "연결 포트",
"description": "연결 포트 입력 레이블"
},
"refreshStatusButton": {
"message": "상태 새로고침",
"description": "상태 새로고침 버튼 툴팁"
},
"copyConfigButton": {
"message": "설정 복사",
"description": "설정 복사 버튼 텍스트"
},
"retryButton": {
"message": "재시도",
"description": "재시도 버튼 텍스트"
},
"cancelButton": {
"message": "취소",
"description": "취소 버튼 텍스트"
},
"confirmButton": {
"message": "확인",
"description": "확인 버튼 텍스트"
},
"saveButton": {
"message": "저장",
"description": "저장 버튼 텍스트"
},
"closeButton": {
"message": "닫기",
"description": "닫기 버튼 텍스트"
},
"resetButton": {
"message": "초기화",
"description": "초기화 버튼 텍스트"
},
"initializingStatus": {
"message": "초기화 중...",
"description": "초기화 진행 메시지"
},
"processingStatus": {
"message": "처리 중...",
"description": "처리 진행 메시지"
},
"loadingStatus": {
"message": "로드 중...",
"description": "로드 진행 메시지"
},
"clearingStatus": {
"message": "삭제 중...",
"description": "삭제 진행 메시지"
},
"cleaningStatus": {
"message": "정리 중...",
"description": "정리 진행 메시지"
},
"downloadingStatus": {
"message": "다운로드 중...",
"description": "다운로드 진행 메시지"
},
"semanticEngineReadyStatus": {
"message": "시맨틱 엔진 준비 완료",
"description": "시맨틱 엔진 준비 완료 상태"
},
"semanticEngineInitializingStatus": {
"message": "시맨틱 엔진 초기화 중...",
"description": "시맨틱 엔진 초기화 상태"
},
"semanticEngineInitFailedStatus": {
"message": "시맨틱 엔진 초기화 실패",
"description": "시맨틱 엔진 초기화 실패 상태"
},
"semanticEngineNotInitStatus": {
"message": "시맨틱 엔진이 초기화되지 않음",
"description": "시맨틱 엔진이 초기화되지 않은 상태"
},
"initSemanticEngineButton": {
"message": "시맨틱 엔진 초기화",
"description": "시맨틱 엔진 초기화 버튼 텍스트"
},
"reinitializeButton": {
"message": "재초기화",
"description": "재초기화 버튼 텍스트"
},
"downloadingModelStatus": {
"message": "모델 다운로드 중... $PROGRESS$%",
"description": "백분율이 포함된 모델 다운로드 진행 상태",
"placeholders": {
"progress": {
"content": "$1",
"example": "50"
}
}
},
"switchingModelStatus": {
"message": "모델 전환 중...",
"description": "모델 전환 진행 메시지"
},
"modelLoadedStatus": {
"message": "모델 로드 완료",
"description": "모델 로드 성공 상태"
},
"modelFailedStatus": {
"message": "모델 로드 실패",
"description": "모델 로드 실패 상태"
},
"lightweightModelDescription": {
"message": "경량 다국어 모델",
"description": "경량 모델 옵션 설명"
},
"betterThanSmallDescription": {
"message": "e5-small보다 약간 크지만 성능이 더 좋습니다",
"description": "중간 모델 옵션 설명"
},
"multilingualModelDescription": {
"message": "다국어 시맨틱 모델",
"description": "다국어 모델 옵션 설명"
},
"fastPerformance": {
"message": "빠름",
"description": "빠른 성능 표시"
},
"balancedPerformance": {
"message": "균형",
"description": "균형 잡힌 성능 표시"
},
"accuratePerformance": {
"message": "정확",
"description": "정확한 성능 표시"
},
"networkErrorMessage": {
"message": "네트워크 연결 오류, 네트워크를 확인하고 다시 시도하세요",
"description": "네트워크 연결 오류 메시지"
},
"modelCorruptedErrorMessage": {
"message": "모델 파일이 손상되었거나 불완전합니다. 다운로드를 다시 시도하세요",
"description": "모델 손상 오류 메시지"
},
"unknownErrorMessage": {
"message": "알 수 없는 오류, 네트워크에서 HuggingFace에 접속할 수 있는지 확인하세요",
"description": "알 수 없는 오류 대체 메시지"
},
"permissionDeniedErrorMessage": {
"message": "권한이 거부되었습니다",
"description": "권한 거부 오류 메시지"
},
"timeoutErrorMessage": {
"message": "작업 시간 초과",
"description": "시간 초과 오류 메시지"
},
"indexedPagesLabel": {
"message": "인덱싱된 페이지",
"description": "인덱싱된 페이지 수 레이블"
},
"indexSizeLabel": {
"message": "인덱스 크기",
"description": "인덱스 크기 레이블"
},
"activeTabsLabel": {
"message": "활성 탭",
"description": "활성 탭 수 레이블"
},
"vectorDocumentsLabel": {
"message": "벡터 문서",
"description": "벡터 문서 수 레이블"
},
"cacheSizeLabel": {
"message": "캐시 크기",
"description": "캐시 크기 레이블"
},
"cacheEntriesLabel": {
"message": "캐시 항목",
"description": "캐시 항목 수 레이블"
},
"clearAllDataButton": {
"message": "모든 데이터 지우기",
"description": "모든 데이터 지우기 버튼 텍스트"
},
"clearAllCacheButton": {
"message": "모든 캐시 지우기",
"description": "모든 캐시 지우기 버튼 텍스트"
},
"cleanExpiredCacheButton": {
"message": "만료된 캐시 정리",
"description": "만료된 캐시 정리 버튼 텍스트"
},
"exportDataButton": {
"message": "데이터 내보내기",
"description": "데이터 내보내기 버튼 텍스트"
},
"importDataButton": {
"message": "데이터 가져오기",
"description": "데이터 가져오기 버튼 텍스트"
},
"confirmClearDataTitle": {
"message": "데이터 지우기 확인",
"description": "데이터 지우기 확인 대화상자 제목"
},
"settingsTitle": {
"message": "설정",
"description": "설정 대화상자 제목"
},
"aboutTitle": {
"message": "정보",
"description": "정보 대화상자 제목"
},
"helpTitle": {
"message": "도움말",
"description": "도움말 대화상자 제목"
},
"clearDataWarningMessage": {
"message": "이 작업은 다음을 포함한 모든 인덱싱된 웹페이지 콘텐츠와 벡터 데이터를 지웁니다:",
"description": "데이터 지우기 경고 메시지"
},
"clearDataList1": {
"message": "모든 웹페이지 텍스트 콘텐츠 인덱스",
"description": "데이터 지우기 목록 첫 번째 항목"
},
"clearDataList2": {
"message": "벡터 임베딩 데이터",
"description": "데이터 지우기 목록 두 번째 항목"
},
"clearDataList3": {
"message": "검색 기록 및 캐시",
"description": "데이터 지우기 목록 세 번째 항목"
},
"clearDataIrreversibleWarning": {
"message": "이 작업은 되돌릴 수 없습니다! 삭제 후에는 인덱스를 다시 생성하기 위해 웹페이지를 다시 방문해야 합니다.",
"description": "되돌릴 수 없는 작업 경고"
},
"confirmClearButton": {
"message": "삭제 확인",
"description": "삭제 작업 확인 버튼"
},
"cacheDetailsLabel": {
"message": "캐시 정보",
"description": "캐시 정보 섹션 레이블"
},
"noCacheDataMessage": {
"message": "캐시 데이터 없음",
"description": "사용 가능한 캐시 데이터 없음 메시지"
},
"loadingCacheInfoStatus": {
"message": "캐시 정보를 불러오는 중...",
"description": "캐시 정보 로드 상태"
},
"processingCacheStatus": {
"message": "캐시 처리 중...",
"description": "캐시 처리 상태"
},
"expiredLabel": {
"message": "만료됨",
"description": "만료된 항목 레이블"
},
"bookmarksBarLabel": {
"message": "북마크바",
"description": "북마크바 폴더 이름"
},
"newTabLabel": {
"message": "새 탭",
"description": "새 탭 레이블"
},
"currentPageLabel": {
"message": "현재 페이지",
"description": "현재 페이지 레이블"
},
"menuLabel": {
"message": "메뉴",
"description": "메뉴 접근성 레이블"
},
"navigationLabel": {
"message": "탐색",
"description": "탐색 접근성 레이블"
},
"mainContentLabel": {
"message": "주요 콘텐츠",
"description": "주요 콘텐츠 접근성 레이블"
},
"languageSelectorLabel": {
"message": "언어",
"description": "언어 선택기 레이블"
},
"themeLabel": {
"message": "테마",
"description": "테마 선택기 레이블"
},
"lightTheme": {
"message": "라이트",
"description": "라이트 테마 옵션"
},
"darkTheme": {
"message": "다크",
"description": "다크 테마 옵션"
},
"autoTheme": {
"message": "자동",
"description": "자동 테마 옵션"
},
"advancedSettingsLabel": {
"message": "고급 설정",
"description": "고급 설정 섹션 레이블"
},
"debugModeLabel": {
"message": "디버그 모드",
"description": "디버그 모드 토글 레이블"
},
"verboseLoggingLabel": {
"message": "상세 로깅",
"description": "상세 로깅 토글 레이블"
},
"successNotification": {
"message": "작업이 성공적으로 완료되었습니다",
"description": "일반 성공 알림"
},
"warningNotification": {
"message": "경고: 계속하기 전에 검토하세요",
"description": "일반 경고 알림"
},
"infoNotification": {
"message": "정보",
"description": "일반 정보 알림"
},
"configCopiedNotification": {
"message": "설정이 클립보드에 복사되었습니다",
"description": "설정 복사 성공 메시지"
},
"dataClearedNotification": {
"message": "데이터가 성공적으로 삭제되었습니다",
"description": "데이터 삭제 성공 메시지"
},
"bytesUnit": {
"message": "바이트",
"description": "바이트 단위"
},
"kilobytesUnit": {
"message": "KB",
"description": "킬로바이트 단위"
},
"megabytesUnit": {
"message": "MB",
"description": "메가바이트 단위"
},
"gigabytesUnit": {
"message": "GB",
"description": "기가바이트 단위"
},
"itemsUnit": {
"message": "개",
"description": "항목 개수 단위"
},
"pagesUnit": {
"message": "페이지",
"description": "페이지 수 단위"
}
}
================================================
FILE: app/chrome-extension/_locales/zh_CN/messages.json
================================================
{
"extensionName": {
"message": "chrome-mcp-server",
"description": "扩展名称"
},
"extensionDescription": {
"message": "使用你自己的 Chrome 浏览器暴露浏览器功能",
"description": "扩展描述"
},
"nativeServerConfigLabel": {
"message": "Native Server 配置",
"description": "本地服务器设置的主要节标题"
},
"semanticEngineLabel": {
"message": "语义引擎",
"description": "语义引擎的主要节标题"
},
"embeddingModelLabel": {
"message": "Embedding模型",
"description": "模型选择的主要节标题"
},
"indexDataManagementLabel": {
"message": "索引数据管理",
"description": "数据管理的主要节标题"
},
"modelCacheManagementLabel": {
"message": "模型缓存管理",
"description": "缓存管理的主要节标题"
},
"statusLabel": {
"message": "状态",
"description": "通用状态标签"
},
"runningStatusLabel": {
"message": "运行状态",
"description": "服务器运行状态标签"
},
"connectionStatusLabel": {
"message": "连接状态",
"description": "连接状态标签"
},
"lastUpdatedLabel": {
"message": "最后更新:",
"description": "最后更新时间戳标签"
},
"connectButton": {
"message": "连接",
"description": "连接按钮文本"
},
"disconnectButton": {
"message": "断开",
"description": "断开连接按钮文本"
},
"connectingStatus": {
"message": "连接中...",
"description": "连接状态消息"
},
"connectedStatus": {
"message": "已连接",
"description": "已连接状态消息"
},
"disconnectedStatus": {
"message": "已断开",
"description": "已断开状态消息"
},
"detectingStatus": {
"message": "检测中...",
"description": "检测状态消息"
},
"serviceRunningStatus": {
"message": "服务运行中 (端口: $PORT$)",
"description": "带端口号的服务运行状态",
"placeholders": {
"port": {
"content": "$1",
"example": "12306"
}
}
},
"serviceNotConnectedStatus": {
"message": "服务未连接",
"description": "服务未连接状态"
},
"connectedServiceNotStartedStatus": {
"message": "已连接,服务未启动",
"description": "已连接但服务未启动状态"
},
"mcpServerConfigLabel": {
"message": "MCP 服务器配置",
"description": "MCP 服务器配置节标签"
},
"connectionPortLabel": {
"message": "连接端口",
"description": "连接端口输入标签"
},
"refreshStatusButton": {
"message": "刷新状态",
"description": "刷新状态按钮提示"
},
"copyConfigButton": {
"message": "复制配置",
"description": "复制配置按钮文本"
},
"retryButton": {
"message": "重试",
"description": "重试按钮文本"
},
"cancelButton": {
"message": "取消",
"description": "取消按钮文本"
},
"confirmButton": {
"message": "确认",
"description": "确认按钮文本"
},
"saveButton": {
"message": "保存",
"description": "保存按钮文本"
},
"closeButton": {
"message": "关闭",
"description": "关闭按钮文本"
},
"resetButton": {
"message": "重置",
"description": "重置按钮文本"
},
"initializingStatus": {
"message": "初始化中...",
"description": "初始化进度消息"
},
"processingStatus": {
"message": "处理中...",
"description": "处理进度消息"
},
"loadingStatus": {
"message": "加载中...",
"description": "加载进度消息"
},
"clearingStatus": {
"message": "清空中...",
"description": "清空进度消息"
},
"cleaningStatus": {
"message": "清理中...",
"description": "清理进度消息"
},
"downloadingStatus": {
"message": "下载中...",
"description": "下载进度消息"
},
"semanticEngineReadyStatus": {
"message": "语义引擎已就绪",
"description": "语义引擎就绪状态"
},
"semanticEngineInitializingStatus": {
"message": "语义引擎初始化中...",
"description": "语义引擎初始化状态"
},
"semanticEngineInitFailedStatus": {
"message": "语义引擎初始化失败",
"description": "语义引擎初始化失败状态"
},
"semanticEngineNotInitStatus": {
"message": "语义引擎未初始化",
"description": "语义引擎未初始化状态"
},
"initSemanticEngineButton": {
"message": "初始化语义引擎",
"description": "初始化语义引擎按钮文本"
},
"reinitializeButton": {
"message": "重新初始化",
"description": "重新初始化按钮文本"
},
"downloadingModelStatus": {
"message": "下载模型中... $PROGRESS$%",
"description": "带百分比的模型下载进度",
"placeholders": {
"progress": {
"content": "$1",
"example": "50"
}
}
},
"switchingModelStatus": {
"message": "切换模型中...",
"description": "模型切换进度消息"
},
"modelLoadedStatus": {
"message": "模型已加载",
"description": "模型成功加载状态"
},
"modelFailedStatus": {
"message": "模型加载失败",
"description": "模型加载失败状态"
},
"lightweightModelDescription": {
"message": "轻量级多语言模型",
"description": "轻量级模型选项的描述"
},
"betterThanSmallDescription": {
"message": "比e5-small稍大,但效果更好",
"description": "中等模型选项的描述"
},
"multilingualModelDescription": {
"message": "多语言语义模型",
"description": "多语言模型选项的描述"
},
"fastPerformance": {
"message": "快速",
"description": "快速性能指示器"
},
"balancedPerformance": {
"message": "平衡",
"description": "平衡性能指示器"
},
"accuratePerformance": {
"message": "精确",
"description": "精确性能指示器"
},
"networkErrorMessage": {
"message": "网络连接错误,请检查网络连接后重试",
"description": "网络连接错误消息"
},
"modelCorruptedErrorMessage": {
"message": "模型文件损坏或不完整,请重试下载",
"description": "模型损坏错误消息"
},
"unknownErrorMessage": {
"message": "未知错误,请检查你的网络是否可以访问HuggingFace",
"description": "未知错误回退消息"
},
"permissionDeniedErrorMessage": {
"message": "权限被拒绝",
"description": "权限被拒绝错误消息"
},
"timeoutErrorMessage": {
"message": "操作超时",
"description": "超时错误消息"
},
"indexedPagesLabel": {
"message": "已索引页面",
"description": "已索引页面数量标签"
},
"indexSizeLabel": {
"message": "索引大小",
"description": "索引大小标签"
},
"activeTabsLabel": {
"message": "活跃标签页",
"description": "活跃标签页数量标签"
},
"vectorDocumentsLabel": {
"message": "向量文档",
"description": "向量文档数量标签"
},
"cacheSizeLabel": {
"message": "缓存大小",
"description": "缓存大小标签"
},
"cacheEntriesLabel": {
"message": "缓存条目",
"description": "缓存条目数量标签"
},
"clearAllDataButton": {
"message": "清空所有数据",
"description": "清空所有数据按钮文本"
},
"clearAllCacheButton": {
"message": "清空所有缓存",
"description": "清空所有缓存按钮文本"
},
"cleanExpiredCacheButton": {
"message": "清理过期缓存",
"description": "清理过期缓存按钮文本"
},
"exportDataButton": {
"message": "导出数据",
"description": "导出数据按钮文本"
},
"importDataButton": {
"message": "导入数据",
"description": "导入数据按钮文本"
},
"confirmClearDataTitle": {
"message": "确认清空数据",
"description": "清空数据确认对话框标题"
},
"settingsTitle": {
"message": "设置",
"description": "设置对话框标题"
},
"aboutTitle": {
"message": "关于",
"description": "关于对话框标题"
},
"helpTitle": {
"message": "帮助",
"description": "帮助对话框标题"
},
"clearDataWarningMessage": {
"message": "此操作将清空所有已索引的网页内容和向量数据,包括:",
"description": "清空数据警告消息"
},
"clearDataList1": {
"message": "所有网页的文本内容索引",
"description": "清空数据列表第一项"
},
"clearDataList2": {
"message": "向量嵌入数据",
"description": "清空数据列表第二项"
},
"clearDataList3": {
"message": "搜索历史和缓存",
"description": "清空数据列表第三项"
},
"clearDataIrreversibleWarning": {
"message": "此操作不可撤销!清空后需要重新浏览网页来重建索引。",
"description": "不可逆操作警告"
},
"confirmClearButton": {
"message": "确认清空",
"description": "确认清空操作按钮"
},
"cacheDetailsLabel": {
"message": "缓存详情",
"description": "缓存详情节标签"
},
"noCacheDataMessage": {
"message": "暂无缓存数据",
"description": "无缓存数据可用消息"
},
"loadingCacheInfoStatus": {
"message": "正在加载缓存信息...",
"description": "加载缓存信息状态"
},
"processingCacheStatus": {
"message": "处理缓存中...",
"description": "处理缓存状态"
},
"expiredLabel": {
"message": "已过期",
"description": "过期项标签"
},
"bookmarksBarLabel": {
"message": "书签栏",
"description": "书签栏文件夹名称"
},
"newTabLabel": {
"message": "新标签页",
"description": "新标签页标签"
},
"currentPageLabel": {
"message": "当前页面",
"description": "当前页面标签"
},
"menuLabel": {
"message": "菜单",
"description": "菜单辅助功能标签"
},
"navigationLabel": {
"message": "导航",
"description": "导航辅助功能标签"
},
"mainContentLabel": {
"message": "主要内容",
"description": "主要内容辅助功能标签"
},
"languageSelectorLabel": {
"message": "语言",
"description": "语言选择器标签"
},
"themeLabel": {
"message": "主题",
"description": "主题选择器标签"
},
"lightTheme": {
"message": "浅色",
"description": "浅色主题选项"
},
"darkTheme": {
"message": "深色",
"description": "深色主题选项"
},
"autoTheme": {
"message": "自动",
"description": "自动主题选项"
},
"advancedSettingsLabel": {
"message": "高级设置",
"description": "高级设置节标签"
},
"debugModeLabel": {
"message": "调试模式",
"description": "调试模式切换标签"
},
"verboseLoggingLabel": {
"message": "详细日志",
"description": "详细日志切换标签"
},
"successNotification": {
"message": "操作成功完成",
"description": "通用成功通知"
},
"warningNotification": {
"message": "警告:请在继续之前检查",
"description": "通用警告通知"
},
"infoNotification": {
"message": "信息",
"description": "通用信息通知"
},
"configCopiedNotification": {
"message": "配置已复制到剪贴板",
"description": "配置复制成功消息"
},
"dataClearedNotification": {
"message": "数据清空成功",
"description": "数据清空成功消息"
},
"bytesUnit": {
"message": "字节",
"description": "字节单位"
},
"kilobytesUnit": {
"message": "KB",
"description": "千字节单位"
},
"megabytesUnit": {
"message": "MB",
"description": "兆字节单位"
},
"gigabytesUnit": {
"message": "GB",
"description": "吉字节单位"
},
"itemsUnit": {
"message": "项",
"description": "项目计数单位"
},
"pagesUnit": {
"message": "页",
"description": "页面计数单位"
},
"userscriptsManagerTitle": { "message": "脚本管理器", "description": "Options 页标题" },
"emergencySwitchLabel": { "message": "紧急开关", "description": "紧急关闭开关" },
"createRunSectionTitle": { "message": "创建 / 运行", "description": "创建与运行分区标题" },
"nameLabel": { "message": "名称", "description": "名称输入标签" },
"runAtLabel": { "message": "运行时机", "description": "runAt 选择标签" },
"runAtAuto": { "message": "自动", "description": "runAt auto" },
"runAtDocumentStart": { "message": "document_start", "description": "runAt document_start" },
"runAtDocumentEnd": { "message": "document_end", "description": "runAt document_end" },
"runAtDocumentIdle": { "message": "document_idle", "description": "runAt document_idle" },
"worldLabel": { "message": "执行上下文", "description": "world 选择标签" },
"worldAuto": { "message": "自动", "description": "world auto" },
"worldIsolated": { "message": "隔离 (ISOLATED)", "description": "ISOLATED world" },
"worldMain": { "message": "页面 (MAIN)", "description": "MAIN world" },
"modeLabel": { "message": "模式", "description": "模式选择标签" },
"modeAuto": { "message": "自动", "description": "mode auto" },
"modePersistent": { "message": "持久", "description": "mode persistent" },
"modeCss": { "message": "仅样式 (CSS)", "description": "mode css" },
"modeOnce": { "message": "一次运行 (CDP)", "description": "mode once" },
"allFramesLabel": { "message": "全部 frame", "description": "allFrames 复选框" },
"persistLabel": { "message": "持久化", "description": "persist 复选框" },
"dnrFallbackLabel": { "message": "DNR 回退", "description": "DNR fallback 复选框" },
"matchesInputLabel": { "message": "匹配(逗号分隔)", "description": "matches 输入" },
"excludesInputLabel": { "message": "排除(逗号分隔)", "description": "excludes 输入" },
"tagsInputLabel": { "message": "标签(逗号分隔)", "description": "tags 输入" },
"scriptLabel": { "message": "脚本", "description": "脚本文本标签" },
"applyButton": { "message": "应用", "description": "应用按钮" },
"runOnceButton": { "message": "一次运行(CDP)", "description": "一次运行按钮" },
"listSectionTitle": { "message": "脚本列表", "description": "列表分区标题" },
"queryLabel": { "message": "搜索", "description": "查询输入标签" },
"statusAll": { "message": "全部", "description": "状态-全部" },
"statusEnabled": { "message": "启用", "description": "状态-启用" },
"statusDisabled": { "message": "禁用", "description": "状态-禁用" },
"domainLabel": { "message": "域名", "description": "域名过滤标签" },
"exportAllButton": { "message": "导出全部", "description": "导出按钮" },
"tableHeaderName": { "message": "名称", "description": "表头-名称" },
"tableHeaderWorld": { "message": "执行上下文", "description": "表头-World" },
"tableHeaderRunAt": { "message": "运行时机", "description": "表头-RunAt" },
"tableHeaderUpdated": { "message": "更新时间", "description": "表头-更新时间" },
"deleteButton": { "message": "删除", "description": "删除按钮" },
"placeholderOptional": { "message": "可选", "description": "通用可选占位符" },
"placeholderMatchesExample": {
"message": "例如:https://*.example.com/*",
"description": "匹配示例占位符"
},
"placeholderScriptHint": { "message": "在此粘贴 JS/CSS/TM", "description": "脚本文本域占位符" },
"placeholderDomainHint": { "message": "example.com", "description": "域名筛选占位符" }
}
================================================
FILE: app/chrome-extension/_locales/zh_TW/messages.json
================================================
{
"extensionName": {
"message": "chrome-mcp-server",
"description": "擴充功能名稱"
},
"extensionDescription": {
"message": "使用您自己的 Chrome 瀏覽器暴露瀏覽器功能",
"description": "擴充功能描述"
},
"nativeServerConfigLabel": {
"message": "原生伺服器設定",
"description": "本機伺服器設定的主要區段標題"
},
"semanticEngineLabel": {
"message": "語意引擎",
"description": "語意引擎的主要區段標題"
},
"embeddingModelLabel": {
"message": "Embedding 模型",
"description": "模型選擇的主要區段標題"
},
"indexDataManagementLabel": {
"message": "索引資料管理",
"description": "資料管理主要區段標題"
},
"modelCacheManagementLabel": {
"message": "模型快取管理",
"description": "快取管理主要區段標題"
},
"statusLabel": {
"message": "狀態",
"description": "通用狀態標籤"
},
"runningStatusLabel": {
"message": "執行狀態",
"description": "伺服器執行狀態標籤"
},
"connectionStatusLabel": {
"message": "連線狀態",
"description": "連線狀態標籤"
},
"lastUpdatedLabel": {
"message": "最後更新:",
"description": "最後更新時間戳標籤"
},
"connectButton": {
"message": "連線",
"description": "連線按鈕文字"
},
"disconnectButton": {
"message": "中斷連線",
"description": "中斷連線按鈕文字"
},
"connectingStatus": {
"message": "連線中...",
"description": "連線狀態訊息"
},
"connectedStatus": {
"message": "已連線",
"description": "已連線狀態訊息"
},
"disconnectedStatus": {
"message": "已中斷",
"description": "已中斷狀態訊息"
},
"detectingStatus": {
"message": "偵測中...",
"description": "偵測狀態訊息"
},
"serviceRunningStatus": {
"message": "服務執行中 (連結埠: $PORT$)",
"description": "含連結埠號的服務執行狀態",
"placeholders": {
"port": {
"content": "$1",
"example": "12306"
}
}
},
"serviceNotConnectedStatus": {
"message": "服務未連線",
"description": "服務未連線狀態"
},
"connectedServiceNotStartedStatus": {
"message": "已連線,服務未啟動",
"description": "已連線但服務未啟動狀態"
},
"mcpServerConfigLabel": {
"message": "MCP 伺服器設定",
"description": "MCP 伺服器設定區段標籤"
},
"connectionPortLabel": {
"message": "連結埠",
"description": "連結埠輸入標籤"
},
"refreshStatusButton": {
"message": "重新整理狀態",
"description": "重新整理狀態按鈕提示"
},
"copyConfigButton": {
"message": "複製設定",
"description": "複製設定按鈕文字"
},
"retryButton": {
"message": "重試",
"description": "重試按鈕文字"
},
"cancelButton": {
"message": "取消",
"description": "取消按鈕文字"
},
"confirmButton": {
"message": "確認",
"description": "確認按鈕文字"
},
"saveButton": {
"message": "儲存",
"description": "儲存按鈕文字"
},
"closeButton": {
"message": "關閉",
"description": "關閉按鈕文字"
},
"resetButton": {
"message": "重設",
"description": "重設按鈕文字"
},
"initializingStatus": {
"message": "初始化中...",
"description": "初始化進度訊息"
},
"processingStatus": {
"message": "處理中...",
"description": "處理進度訊息"
},
"loadingStatus": {
"message": "載入中...",
"description": "載入進度訊息"
},
"clearingStatus": {
"message": "清除中...",
"description": "清除進度訊息"
},
"cleaningStatus": {
"message": "清理中...",
"description": "清理進度訊息"
},
"downloadingStatus": {
"message": "下載中...",
"description": "下載進度訊息"
},
"semanticEngineReadyStatus": {
"message": "語意引擎已就緒",
"description": "語意引擎就緒狀態"
},
"semanticEngineInitializingStatus": {
"message": "語意引擎初始化中...",
"description": "語意引擎初始化狀態"
},
"semanticEngineInitFailedStatus": {
"message": "語意引擎初始化失敗",
"description": "語意引擎初始化失敗狀態"
},
"semanticEngineNotInitStatus": {
"message": "語意引擎未初始化",
"description": "語意引擎未初始化狀態"
},
"initSemanticEngineButton": {
"message": "初始化語意引擎",
"description": "初始化語意引擎按鈕文字"
},
"reinitializeButton": {
"message": "重新初始化",
"description": "重新初始化按鈕文字"
},
"downloadingModelStatus": {
"message": "正在下載模型... $PROGRESS$%",
"description": "含百分比的模型下載進度",
"placeholders": {
"progress": {
"content": "$1",
"example": "50"
}
}
},
"switchingModelStatus": {
"message": "正在切換模型...",
"description": "模型切換進度訊息"
},
"modelLoadedStatus": {
"message": "模型已載入",
"description": "模型成功載入狀態"
},
"modelFailedStatus": {
"message": "模型載入失敗",
"description": "模型載入失敗狀態"
},
"lightweightModelDescription": {
"message": "輕量級多語言模型",
"description": "輕量級模型選項描述"
},
"betterThanSmallDescription": {
"message": "比 e5-small 稍大,但效果更佳",
"description": "中等模型選項描述"
},
"multilingualModelDescription": {
"message": "多語言語意模型",
"description": "多語言模型選項描述"
},
"fastPerformance": {
"message": "快速",
"description": "快速效能指標"
},
"balancedPerformance": {
"message": "平衡",
"description": "平衡效能指標"
},
"accuratePerformance": {
"message": "精確",
"description": "精確效能指標"
},
"networkErrorMessage": {
"message": "網路連線錯誤,請檢查網路後再試一次",
"description": "網路連線錯誤訊息"
},
"modelCorruptedErrorMessage": {
"message": "模型檔案毀損或不完整,請重新下載",
"description": "模型毀損錯誤訊息"
},
"unknownErrorMessage": {
"message": "未知錯誤,請檢查您的網路是否可存取 HuggingFace",
"description": "未知錯誤回退訊息"
},
"permissionDeniedErrorMessage": {
"message": "權限被拒絕",
"description": "權限被拒絕錯誤訊息"
},
"timeoutErrorMessage": {
"message": "操作逾時",
"description": "逾時錯誤訊息"
},
"indexedPagesLabel": {
"message": "已索引頁面",
"description": "已索引頁面數量標籤"
},
"indexSizeLabel": {
"message": "索引大小",
"description": "索引大小標籤"
},
"activeTabsLabel": {
"message": "作用中分頁",
"description": "作用中分頁數量標籤"
},
"vectorDocumentsLabel": {
"message": "向量文件",
"description": "向量文件數量標籤"
},
"cacheSizeLabel": {
"message": "快取大小",
"description": "快取大小標籤"
},
"cacheEntriesLabel": {
"message": "快取項目",
"description": "快取項目數量標籤"
},
"clearAllDataButton": {
"message": "清除所有資料",
"description": "清除所有資料按鈕文字"
},
"clearAllCacheButton": {
"message": "清除所有快取",
"description": "清除所有快取按鈕文字"
},
"cleanExpiredCacheButton": {
"message": "清理過期快取",
"description": "清理過期快取按鈕文字"
},
"exportDataButton": {
"message": "匯出資料",
"description": "匯出資料按鈕文字"
},
"importDataButton": {
"message": "匯入資料",
"description": "匯入資料按鈕文字"
},
"confirmClearDataTitle": {
"message": "確認清除資料",
"description": "清除資料確認對話框標題"
},
"settingsTitle": {
"message": "設定",
"description": "設定對話框標題"
},
"aboutTitle": {
"message": "關於",
"description": "關於對話框標題"
},
"helpTitle": {
"message": "說明",
"description": "說明對話框標題"
},
"clearDataWarningMessage": {
"message": "此操作將清除所有已索引的網頁內容與向量資料,包括:",
"description": "清除資料警告訊息"
},
"clearDataList1": {
"message": "所有網頁的文字內容索引",
"description": "清除資料列表第一項"
},
"clearDataList2": {
"message": "向量嵌入資料",
"description": "清除資料列表第二項"
},
"clearDataList3": {
"message": "搜尋歷史與快取",
"description": "清除資料列表第三項"
},
"clearDataIrreversibleWarning": {
"message": "此操作無法復原!清除後需重新瀏覽網頁以重建索引。",
"description": "不可逆操作警告"
},
"confirmClearButton": {
"message": "確認清除",
"description": "確認清除操作按鈕"
},
"cacheDetailsLabel": {
"message": "快取詳細資訊",
"description": "快取詳細資訊區段標籤"
},
"noCacheDataMessage": {
"message": "尚無快取資料",
"description": "無可用快取資料訊息"
},
"loadingCacheInfoStatus": {
"message": "正在載入快取資訊...",
"description": "載入快取資訊狀態"
},
"processingCacheStatus": {
"message": "正在處理快取...",
"description": "處理快取狀態"
},
"expiredLabel": {
"message": "已過期",
"description": "過期項目標籤"
},
"bookmarksBarLabel": {
"message": "書籤列",
"description": "書籤列資料夾名稱"
},
"newTabLabel": {
"message": "新分頁",
"description": "新分頁標籤"
},
"currentPageLabel": {
"message": "目前頁面",
"description": "目前頁面標籤"
},
"menuLabel": {
"message": "功能表",
"description": "功能表無障礙標籤"
},
"navigationLabel": {
"message": "導覽",
"description": "導覽無障礙標籤"
},
"mainContentLabel": {
"message": "主要內容",
"description": "主要內容無障礙標籤"
},
"languageSelectorLabel": {
"message": "語言",
"description": "語言選擇器標籤"
},
"themeLabel": {
"message": "主題",
"description": "主題選擇器標籤"
},
"lightTheme": {
"message": "淺色",
"description": "淺色主題選項"
},
"darkTheme": {
"message": "深色",
"description": "深色主題選項"
},
"autoTheme": {
"message": "自動",
"description": "自動主題選項"
},
"advancedSettingsLabel": {
"message": "進階設定",
"description": "進階設定區段標籤"
},
"debugModeLabel": {
"message": "偵錯模式",
"description": "偵錯模式切換標籤"
},
"verboseLoggingLabel": {
"message": "詳細日誌",
"description": "詳細日誌切換標籤"
},
"successNotification": {
"message": "操作已成功完成",
"description": "通用成功通知"
},
"warningNotification": {
"message": "警告:請在繼續前檢查",
"description": "通用警告通知"
},
"infoNotification": {
"message": "資訊",
"description": "通用資訊通知"
},
"configCopiedNotification": {
"message": "設定已複製到剪貼簿",
"description": "設定複製成功訊息"
},
"dataClearedNotification": {
"message": "資料清除成功",
"description": "資料清除成功訊息"
},
"bytesUnit": {
"message": "bytes",
"description": "位元組單位"
},
"kilobytesUnit": {
"message": "KB",
"description": "千位元組單位"
},
"megabytesUnit": {
"message": "MB",
"description": "百萬位元組單位"
},
"gigabytesUnit": {
"message": "GB",
"description": "十億位元組單位"
},
"itemsUnit": {
"message": "項目",
"description": "項目計數單位"
},
"pagesUnit": {
"message": "頁面",
"description": "頁面計數單位"
}
}
================================================
FILE: app/chrome-extension/common/agent-models.ts
================================================
/**
* Agent CLI Model Definitions.
*
* Static model definitions for each CLI type.
* Based on the pattern from Claudable (other/cweb).
*/
import type { CodexReasoningEffort } from 'chrome-mcp-shared';
// ============================================================
// Types
// ============================================================
export interface ModelDefinition {
id: string;
name: string;
description?: string;
supportsImages?: boolean;
/** Supported reasoning effort levels for Codex models */
supportedReasoningEfforts?: readonly CodexReasoningEffort[];
}
export type AgentCliType = 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm';
// ============================================================
// Claude Models
// ============================================================
export const CLAUDE_MODELS: ModelDefinition[] = [
{
id: 'claude-sonnet-4-5-20250929',
name: 'Claude Sonnet 4.5',
description: 'Balanced model with large context window',
supportsImages: true,
},
{
id: 'claude-opus-4-5-20251101',
name: 'Claude Opus 4.5',
description: 'Strongest reasoning model',
supportsImages: true,
},
{
id: 'claude-haiku-4-5-20251001',
name: 'Claude Haiku 4.5',
description: 'Fast and cost-efficient',
supportsImages: true,
},
];
export const CLAUDE_DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
// ============================================================
// Codex Models
// ============================================================
/** Standard reasoning efforts supported by all models */
const CODEX_STANDARD_EFFORTS: readonly CodexReasoningEffort[] = ['low', 'medium', 'high'];
/** Extended reasoning efforts (includes xhigh) - only for gpt-5.2 and gpt-5.1-codex-max */
const CODEX_EXTENDED_EFFORTS: readonly CodexReasoningEffort[] = ['low', 'medium', 'high', 'xhigh'];
export const CODEX_MODELS: ModelDefinition[] = [
{
id: 'gpt-5.1',
name: 'GPT-5.1',
description: 'OpenAI high-quality reasoning model',
supportedReasoningEfforts: CODEX_STANDARD_EFFORTS,
},
{
id: 'gpt-5.2',
name: 'GPT-5.2',
description: 'OpenAI flagship reasoning model with extended effort support',
supportedReasoningEfforts: CODEX_EXTENDED_EFFORTS,
},
{
id: 'gpt-5.1-codex',
name: 'GPT-5.1 Codex',
description: 'Coding-optimized model for agent workflows',
supportedReasoningEfforts: CODEX_STANDARD_EFFORTS,
},
{
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
description: 'Highest quality coding model with extended effort support',
supportedReasoningEfforts: CODEX_EXTENDED_EFFORTS,
},
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
description: 'Fast, cost-efficient coding model',
supportedReasoningEfforts: CODEX_STANDARD_EFFORTS,
},
];
export const CODEX_DEFAULT_MODEL = 'gpt-5.1';
// Codex model alias normalization
const CODEX_ALIAS_MAP: Record = {
gpt5: 'gpt-5.1',
gpt_5: 'gpt-5.1',
'gpt-5': 'gpt-5.1',
'gpt-5.0': 'gpt-5.1',
};
const CODEX_KNOWN_IDS = new Set(CODEX_MODELS.map((model) => model.id));
/**
* Normalize a Codex model ID, handling aliases and falling back to default.
*/
export function normalizeCodexModelId(model?: string | null): string {
if (!model || typeof model !== 'string') {
return CODEX_DEFAULT_MODEL;
}
const trimmed = model.trim();
if (!trimmed) {
return CODEX_DEFAULT_MODEL;
}
const lower = trimmed.toLowerCase();
if (CODEX_ALIAS_MAP[lower]) {
return CODEX_ALIAS_MAP[lower];
}
if (CODEX_KNOWN_IDS.has(lower)) {
return lower;
}
// If the exact casing exists, allow it
if (CODEX_KNOWN_IDS.has(trimmed)) {
return trimmed;
}
return CODEX_DEFAULT_MODEL;
}
/**
* Get supported reasoning efforts for a Codex model.
* Returns standard efforts (low/medium/high) for unknown models.
*/
export function getCodexReasoningEfforts(modelId?: string | null): readonly CodexReasoningEffort[] {
const normalized = normalizeCodexModelId(modelId);
const model = CODEX_MODELS.find((m) => m.id === normalized);
return model?.supportedReasoningEfforts ?? CODEX_STANDARD_EFFORTS;
}
/**
* Check if a model supports xhigh reasoning effort.
*/
export function supportsXhighEffort(modelId?: string | null): boolean {
const efforts = getCodexReasoningEfforts(modelId);
return efforts.includes('xhigh');
}
// ============================================================
// Cursor Models
// ============================================================
export const CURSOR_MODELS: ModelDefinition[] = [
{
id: 'auto',
name: 'Auto',
description: 'Cursor auto-selects the best model',
},
{
id: 'claude-sonnet-4-5-20250929',
name: 'Claude Sonnet 4.5',
description: 'Anthropic Claude via Cursor',
supportsImages: true,
},
{
id: 'gpt-4.1',
name: 'GPT-4.1',
description: 'OpenAI model via Cursor',
},
];
export const CURSOR_DEFAULT_MODEL = 'auto';
// ============================================================
// Qwen Models
// ============================================================
export const QWEN_MODELS: ModelDefinition[] = [
{
id: 'qwen3-coder-plus',
name: 'Qwen3 Coder Plus',
description: 'Balanced 32k context model for coding',
},
{
id: 'qwen3-coder-pro',
name: 'Qwen3 Coder Pro',
description: 'Larger 128k context with stronger reasoning',
},
{
id: 'qwen3-coder',
name: 'Qwen3 Coder',
description: 'Fast iteration model',
},
];
export const QWEN_DEFAULT_MODEL = 'qwen3-coder-plus';
// ============================================================
// GLM Models
// ============================================================
export const GLM_MODELS: ModelDefinition[] = [
{
id: 'glm-4.6',
name: 'GLM 4.6',
description: 'Zhipu GLM 4.6 agent runtime',
},
];
export const GLM_DEFAULT_MODEL = 'glm-4.6';
// ============================================================
// Aggregated Definitions
// ============================================================
export const CLI_MODEL_DEFINITIONS: Record = {
claude: CLAUDE_MODELS,
codex: CODEX_MODELS,
cursor: CURSOR_MODELS,
qwen: QWEN_MODELS,
glm: GLM_MODELS,
};
export const CLI_DEFAULT_MODELS: Record = {
claude: CLAUDE_DEFAULT_MODEL,
codex: CODEX_DEFAULT_MODEL,
cursor: CURSOR_DEFAULT_MODEL,
qwen: QWEN_DEFAULT_MODEL,
glm: GLM_DEFAULT_MODEL,
};
// ============================================================
// Helper Functions
// ============================================================
/**
* Get model definitions for a specific CLI type.
*/
export function getModelsForCli(cli: string | null | undefined): ModelDefinition[] {
if (!cli) return [];
const key = cli.toLowerCase() as AgentCliType;
return CLI_MODEL_DEFINITIONS[key] || [];
}
/**
* Get the default model for a CLI type.
*/
export function getDefaultModelForCli(cli: string | null | undefined): string {
if (!cli) return '';
const key = cli.toLowerCase() as AgentCliType;
return CLI_DEFAULT_MODELS[key] || '';
}
/**
* Get display name for a model ID.
*/
export function getModelDisplayName(
cli: string | null | undefined,
modelId: string | null | undefined,
): string {
if (!cli || !modelId) return modelId || '';
const models = getModelsForCli(cli);
const model = models.find((m) => m.id === modelId);
return model?.name || modelId;
}
================================================
FILE: app/chrome-extension/common/constants.ts
================================================
/**
* Chrome Extension Constants
* Centralized configuration values and magic constants
*/
// Native Host Configuration
export const NATIVE_HOST = {
NAME: 'com.chromemcp.nativehost',
DEFAULT_PORT: 12306,
} as const;
// Chrome Extension Icons
export const ICONS = {
NOTIFICATION: 'icon/48.png',
} as const;
// Timeouts and Delays (in milliseconds)
export const TIMEOUTS = {
DEFAULT_WAIT: 1000,
NETWORK_CAPTURE_MAX: 30000,
NETWORK_CAPTURE_IDLE: 3000,
SCREENSHOT_DELAY: 100,
KEYBOARD_DELAY: 50,
CLICK_DELAY: 100,
} as const;
// Limits and Thresholds
export const LIMITS = {
MAX_NETWORK_REQUESTS: 100,
MAX_SEARCH_RESULTS: 50,
MAX_BOOKMARK_RESULTS: 100,
MAX_HISTORY_RESULTS: 100,
SIMILARITY_THRESHOLD: 0.1,
VECTOR_DIMENSIONS: 384,
} as const;
// Error Messages
export const ERROR_MESSAGES = {
NATIVE_CONNECTION_FAILED: 'Failed to connect to native host',
NATIVE_DISCONNECTED: 'Native connection disconnected',
SERVER_STATUS_LOAD_FAILED: 'Failed to load server status',
SERVER_STATUS_SAVE_FAILED: 'Failed to save server status',
TOOL_EXECUTION_FAILED: 'Tool execution failed',
INVALID_PARAMETERS: 'Invalid parameters provided',
PERMISSION_DENIED: 'Permission denied',
TAB_NOT_FOUND: 'Tab not found',
ELEMENT_NOT_FOUND: 'Element not found',
NETWORK_ERROR: 'Network error occurred',
} as const;
// Success Messages
export const SUCCESS_MESSAGES = {
TOOL_EXECUTED: 'Tool executed successfully',
CONNECTION_ESTABLISHED: 'Connection established',
SERVER_STARTED: 'Server started successfully',
SERVER_STOPPED: 'Server stopped successfully',
} as const;
// External Links
export const LINKS = {
TROUBLESHOOTING: 'https://github.com/hangwin/mcp-chrome/blob/master/docs/TROUBLESHOOTING.md',
} as const;
// File Extensions and MIME Types
export const FILE_TYPES = {
STATIC_EXTENSIONS: [
'.css',
'.js',
'.png',
'.jpg',
'.jpeg',
'.gif',
'.svg',
'.ico',
'.woff',
'.woff2',
'.ttf',
],
FILTERED_MIME_TYPES: ['text/html', 'text/css', 'text/javascript', 'application/javascript'],
IMAGE_FORMATS: ['png', 'jpeg', 'webp'] as const,
} as const;
// Network Filtering
export const NETWORK_FILTERS = {
// Substring match against full URL (not just hostname) to support patterns like 'facebook.com/tr'
EXCLUDED_DOMAINS: [
// Google
'google-analytics.com',
'googletagmanager.com',
'analytics.google.com',
'doubleclick.net',
'googlesyndication.com',
'googleads.g.doubleclick.net',
'stats.g.doubleclick.net',
'adservice.google.com',
'pagead2.googlesyndication.com',
// Amazon
'amazon-adsystem.com',
// Microsoft
'bat.bing.com',
'clarity.ms',
// Facebook
'connect.facebook.net',
'facebook.com/tr',
// Twitter
'analytics.twitter.com',
'ads-twitter.com',
// Other ad networks
'ads.yahoo.com',
'adroll.com',
'adnxs.com',
'criteo.com',
'quantserve.com',
'scorecardresearch.com',
// Analytics & session recording
'segment.io',
'amplitude.com',
'mixpanel.com',
'optimizely.com',
'static.hotjar.com',
'script.hotjar.com',
'crazyegg.com',
'clicktale.net',
'mouseflow.com',
'fullstory.com',
// LinkedIn (tracking pixels)
'linkedin.com/px',
],
// Static resource extensions (used when includeStatic=false)
STATIC_RESOURCE_EXTENSIONS: [
'.jpg',
'.jpeg',
'.png',
'.gif',
'.svg',
'.webp',
'.ico',
'.bmp',
'.cur',
'.css',
'.scss',
'.less',
'.js',
'.jsx',
'.ts',
'.tsx',
'.map',
'.woff',
'.woff2',
'.ttf',
'.eot',
'.otf',
'.mp3',
'.mp4',
'.avi',
'.mov',
'.wmv',
'.flv',
'.webm',
'.ogg',
'.wav',
'.pdf',
'.zip',
'.rar',
'.7z',
'.iso',
'.dmg',
'.doc',
'.docx',
'.xls',
'.xlsx',
'.ppt',
'.pptx',
],
// MIME types treated as static/binary (filtered when includeStatic=false)
STATIC_MIME_TYPES_TO_FILTER: [
'image/',
'font/',
'audio/',
'video/',
'text/css',
'text/javascript',
'application/javascript',
'application/x-javascript',
'application/pdf',
'application/zip',
'application/octet-stream',
],
// API-like MIME types (never filtered by MIME)
API_MIME_TYPES: [
'application/json',
'application/xml',
'text/xml',
'text/plain',
'text/event-stream',
'application/x-www-form-urlencoded',
'application/graphql',
'application/grpc',
'application/protobuf',
'application/x-protobuf',
'application/x-json',
'application/ld+json',
'application/problem+json',
'application/problem+xml',
'application/soap+xml',
'application/vnd.api+json',
],
STATIC_RESOURCE_TYPES: ['stylesheet', 'image', 'font', 'media', 'other'],
} as const;
// Semantic Similarity Configuration
export const SEMANTIC_CONFIG = {
DEFAULT_MODEL: 'sentence-transformers/all-MiniLM-L6-v2',
CHUNK_SIZE: 512,
CHUNK_OVERLAP: 50,
BATCH_SIZE: 32,
CACHE_SIZE: 1000,
} as const;
// Storage Keys
export const STORAGE_KEYS = {
SERVER_STATUS: 'serverStatus',
NATIVE_SERVER_PORT: 'nativeServerPort',
NATIVE_AUTO_CONNECT_ENABLED: 'nativeAutoConnectEnabled',
SEMANTIC_MODEL: 'selectedModel',
USER_PREFERENCES: 'userPreferences',
VECTOR_INDEX: 'vectorIndex',
USERSCRIPTS: 'userscripts',
USERSCRIPTS_DISABLED: 'userscripts_disabled',
// Record & Replay storage keys
RR_FLOWS: 'rr_flows',
RR_RUNS: 'rr_runs',
RR_PUBLISHED: 'rr_published_flows',
RR_SCHEDULES: 'rr_schedules',
RR_TRIGGERS: 'rr_triggers',
// Persistent recording state (guards resume across navigations/service worker restarts)
RR_RECORDING_STATE: 'rr_recording_state',
} as const;
// Notification Configuration
export const NOTIFICATIONS = {
PRIORITY: 2,
TYPE: 'basic' as const,
} as const;
export enum ExecutionWorld {
ISOLATED = 'ISOLATED',
MAIN = 'MAIN',
}
================================================
FILE: app/chrome-extension/common/element-marker-types.ts
================================================
// Element marker types shared across background, content scripts, and popup
export type UrlMatchType = 'exact' | 'prefix' | 'host';
export interface ElementMarker {
id: string;
// Original URL where the marker was created
url: string;
// Normalized pieces to support matching
origin: string; // scheme + host + port
host: string; // hostname
path: string; // pathname part only
matchType: UrlMatchType; // default: 'prefix'
name: string; // Human-friendly name, e.g., "Login Button"
selector: string; // Selector string
selectorType?: 'css' | 'xpath'; // Default: css
listMode?: boolean; // Whether this marker was created in list mode (allows multiple matches)
action?: 'click' | 'fill' | 'custom'; // Intended action hint (optional)
createdAt: number;
updatedAt: number;
}
export interface UpsertMarkerRequest {
id?: string;
url: string;
name: string;
selector: string;
selectorType?: 'css' | 'xpath';
listMode?: boolean;
matchType?: UrlMatchType;
action?: 'click' | 'fill' | 'custom';
}
// Validation actions for MCP-integrated verification
export enum MarkerValidationAction {
Hover = 'hover',
LeftClick = 'left_click',
RightClick = 'right_click',
DoubleClick = 'double_click',
TypeText = 'type_text',
PressKeys = 'press_keys',
Scroll = 'scroll',
}
export interface MarkerValidationRequest {
selector: string;
selectorType?: 'css' | 'xpath';
action: MarkerValidationAction;
// Optional payload for certain actions
text?: string; // for type_text
keys?: string; // for press_keys
// Event options for click-like actions
button?: 'left' | 'right' | 'middle';
bubbles?: boolean;
cancelable?: boolean;
modifiers?: { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean };
// Targeting options
coordinates?: { x: number; y: number }; // absolute viewport coords
offsetX?: number; // relative to element center if relativeTo = 'element'
offsetY?: number;
relativeTo?: 'element' | 'viewport';
// Navigation options for click-like actions
waitForNavigation?: boolean;
timeoutMs?: number;
// Scroll options
scrollDirection?: 'up' | 'down' | 'left' | 'right';
scrollAmount?: number; // pixels per tick
}
export interface MarkerValidationResponse {
success: boolean;
resolved?: boolean;
ref?: string;
center?: { x: number; y: number };
tool?: { name: string; ok: boolean; error?: string };
error?: string;
}
export interface MarkerQuery {
url?: string; // If present, query by URL match; otherwise list all
}
================================================
FILE: app/chrome-extension/common/message-types.ts
================================================
/**
* Consolidated message type constants for Chrome extension communication
* Note: Native message types are imported from the shared package
*/
import type { RealtimeEvent } from 'chrome-mcp-shared';
// Message targets for routing
export enum MessageTarget {
Offscreen = 'offscreen',
ContentScript = 'content_script',
Background = 'background',
}
// Background script message types
export const BACKGROUND_MESSAGE_TYPES = {
SWITCH_SEMANTIC_MODEL: 'switch_semantic_model',
GET_MODEL_STATUS: 'get_model_status',
UPDATE_MODEL_STATUS: 'update_model_status',
GET_STORAGE_STATS: 'get_storage_stats',
CLEAR_ALL_DATA: 'clear_all_data',
GET_SERVER_STATUS: 'get_server_status',
REFRESH_SERVER_STATUS: 'refresh_server_status',
SERVER_STATUS_CHANGED: 'server_status_changed',
INITIALIZE_SEMANTIC_ENGINE: 'initialize_semantic_engine',
// Record & Replay background control and queries
RR_START_RECORDING: 'rr_start_recording',
RR_STOP_RECORDING: 'rr_stop_recording',
RR_PAUSE_RECORDING: 'rr_pause_recording',
RR_RESUME_RECORDING: 'rr_resume_recording',
RR_GET_RECORDING_STATUS: 'rr_get_recording_status',
RR_LIST_FLOWS: 'rr_list_flows',
RR_FLOWS_CHANGED: 'rr_flows_changed',
RR_GET_FLOW: 'rr_get_flow',
RR_DELETE_FLOW: 'rr_delete_flow',
RR_PUBLISH_FLOW: 'rr_publish_flow',
RR_UNPUBLISH_FLOW: 'rr_unpublish_flow',
RR_RUN_FLOW: 'rr_run_flow',
RR_SAVE_FLOW: 'rr_save_flow',
RR_EXPORT_FLOW: 'rr_export_flow',
RR_EXPORT_ALL: 'rr_export_all',
RR_IMPORT_FLOW: 'rr_import_flow',
RR_LIST_RUNS: 'rr_list_runs',
// Triggers
RR_LIST_TRIGGERS: 'rr_list_triggers',
RR_SAVE_TRIGGER: 'rr_save_trigger',
RR_DELETE_TRIGGER: 'rr_delete_trigger',
RR_REFRESH_TRIGGERS: 'rr_refresh_triggers',
// Scheduling
RR_SCHEDULE_FLOW: 'rr_schedule_flow',
RR_UNSCHEDULE_FLOW: 'rr_unschedule_flow',
RR_LIST_SCHEDULES: 'rr_list_schedules',
// Element marker management
ELEMENT_MARKER_LIST_ALL: 'element_marker_list_all',
ELEMENT_MARKER_LIST_FOR_URL: 'element_marker_list_for_url',
ELEMENT_MARKER_SAVE: 'element_marker_save',
ELEMENT_MARKER_UPDATE: 'element_marker_update',
ELEMENT_MARKER_DELETE: 'element_marker_delete',
ELEMENT_MARKER_VALIDATE: 'element_marker_validate',
ELEMENT_MARKER_START: 'element_marker_start_from_popup',
// Element picker (human-in-the-loop element selection)
ELEMENT_PICKER_UI_EVENT: 'element_picker_ui_event',
ELEMENT_PICKER_FRAME_EVENT: 'element_picker_frame_event',
// Web editor (in-page visual editing)
WEB_EDITOR_TOGGLE: 'web_editor_toggle',
WEB_EDITOR_APPLY: 'web_editor_apply',
WEB_EDITOR_STATUS_QUERY: 'web_editor_status_query',
// Web editor <-> AgentChat integration (Phase 1.1)
WEB_EDITOR_APPLY_BATCH: 'web_editor_apply_batch',
WEB_EDITOR_TX_CHANGED: 'web_editor_tx_changed',
WEB_EDITOR_HIGHLIGHT_ELEMENT: 'web_editor_highlight_element',
// Web editor <-> AgentChat integration (Phase 2 - Revert)
WEB_EDITOR_REVERT_ELEMENT: 'web_editor_revert_element',
// Web editor <-> AgentChat integration - Selection sync
WEB_EDITOR_SELECTION_CHANGED: 'web_editor_selection_changed',
// Web editor <-> AgentChat integration - Clear selection (sidepanel -> web-editor)
WEB_EDITOR_CLEAR_SELECTION: 'web_editor_clear_selection',
// Web editor <-> AgentChat integration - Cancel execution
WEB_EDITOR_CANCEL_EXECUTION: 'web_editor_cancel_execution',
// Web editor props (Phase 7.1.6 early injection)
WEB_EDITOR_PROPS_REGISTER_EARLY_INJECTION: 'web_editor_props_register_early_injection',
// Web editor props - open source file in VSCode
WEB_EDITOR_OPEN_SOURCE: 'web_editor_open_source',
// Quick Panel <-> AgentChat integration
QUICK_PANEL_SEND_TO_AI: 'quick_panel_send_to_ai',
QUICK_PANEL_CANCEL_AI: 'quick_panel_cancel_ai',
// Quick Panel Search - Tabs bridge
QUICK_PANEL_TABS_QUERY: 'quick_panel_tabs_query',
QUICK_PANEL_TAB_ACTIVATE: 'quick_panel_tab_activate',
QUICK_PANEL_TAB_CLOSE: 'quick_panel_tab_close',
} as const;
// Offscreen message types
export const OFFSCREEN_MESSAGE_TYPES = {
SIMILARITY_ENGINE_INIT: 'similarityEngineInit',
SIMILARITY_ENGINE_COMPUTE: 'similarityEngineCompute',
SIMILARITY_ENGINE_BATCH_COMPUTE: 'similarityEngineBatchCompute',
SIMILARITY_ENGINE_STATUS: 'similarityEngineStatus',
// GIF encoding
GIF_ADD_FRAME: 'gifAddFrame',
GIF_FINISH: 'gifFinish',
GIF_RESET: 'gifReset',
} as const;
// Content script message types
export const CONTENT_MESSAGE_TYPES = {
WEB_FETCHER_GET_TEXT_CONTENT: 'webFetcherGetTextContent',
WEB_FETCHER_GET_HTML_CONTENT: 'getHtmlContent',
NETWORK_CAPTURE_PING: 'network_capture_ping',
CLICK_HELPER_PING: 'click_helper_ping',
FILL_HELPER_PING: 'fill_helper_ping',
KEYBOARD_HELPER_PING: 'keyboard_helper_ping',
SCREENSHOT_HELPER_PING: 'screenshot_helper_ping',
INTERACTIVE_ELEMENTS_HELPER_PING: 'interactive_elements_helper_ping',
ACCESSIBILITY_TREE_HELPER_PING: 'chrome_read_page_ping',
WAIT_HELPER_PING: 'wait_helper_ping',
DOM_OBSERVER_PING: 'dom_observer_ping',
} as const;
// Tool action message types (for chrome.runtime.sendMessage)
export const TOOL_MESSAGE_TYPES = {
// Screenshot related
SCREENSHOT_PREPARE_PAGE_FOR_CAPTURE: 'preparePageForCapture',
SCREENSHOT_GET_PAGE_DETAILS: 'getPageDetails',
SCREENSHOT_GET_ELEMENT_DETAILS: 'getElementDetails',
SCREENSHOT_SCROLL_PAGE: 'scrollPage',
SCREENSHOT_RESET_PAGE_AFTER_CAPTURE: 'resetPageAfterCapture',
// Web content fetching
WEB_FETCHER_GET_HTML_CONTENT: 'getHtmlContent',
WEB_FETCHER_GET_TEXT_CONTENT: 'getTextContent',
// User interactions
CLICK_ELEMENT: 'clickElement',
FILL_ELEMENT: 'fillElement',
SIMULATE_KEYBOARD: 'simulateKeyboard',
// Interactive elements
GET_INTERACTIVE_ELEMENTS: 'getInteractiveElements',
// Accessibility tree
GENERATE_ACCESSIBILITY_TREE: 'generateAccessibilityTree',
RESOLVE_REF: 'resolveRef',
ENSURE_REF_FOR_SELECTOR: 'ensureRefForSelector',
VERIFY_FINGERPRINT: 'verifyFingerprint',
DISPATCH_HOVER_FOR_REF: 'dispatchHoverForRef',
// Network requests
NETWORK_SEND_REQUEST: 'sendPureNetworkRequest',
// Wait helper
WAIT_FOR_TEXT: 'waitForText',
// Semantic similarity engine
SIMILARITY_ENGINE_INIT: 'similarityEngineInit',
SIMILARITY_ENGINE_COMPUTE_BATCH: 'similarityEngineComputeBatch',
// Record & Replay content script bridge
RR_RECORDER_CONTROL: 'rr_recorder_control',
RR_RECORDER_EVENT: 'rr_recorder_event',
// Record & Replay timeline feed (background -> content overlay)
RR_TIMELINE_UPDATE: 'rr_timeline_update',
// Quick Panel AI streaming events (background -> content script)
QUICK_PANEL_AI_EVENT: 'quick_panel_ai_event',
// DOM observer trigger bridge
SET_DOM_TRIGGERS: 'set_dom_triggers',
DOM_TRIGGER_FIRED: 'dom_trigger_fired',
// Record & Replay overlay: variable collection
COLLECT_VARIABLES: 'collectVariables',
// Element marker overlay control (content-side)
ELEMENT_MARKER_START: 'element_marker_start',
// Element picker (tool-driven, background <-> content scripts)
ELEMENT_PICKER_START: 'elementPickerStart',
ELEMENT_PICKER_STOP: 'elementPickerStop',
ELEMENT_PICKER_SET_ACTIVE_REQUEST: 'elementPickerSetActiveRequest',
ELEMENT_PICKER_UI_PING: 'elementPickerUiPing',
ELEMENT_PICKER_UI_SHOW: 'elementPickerUiShow',
ELEMENT_PICKER_UI_UPDATE: 'elementPickerUiUpdate',
ELEMENT_PICKER_UI_HIDE: 'elementPickerUiHide',
} as const;
// Type unions for type safety
export type BackgroundMessageType =
(typeof BACKGROUND_MESSAGE_TYPES)[keyof typeof BACKGROUND_MESSAGE_TYPES];
export type OffscreenMessageType =
(typeof OFFSCREEN_MESSAGE_TYPES)[keyof typeof OFFSCREEN_MESSAGE_TYPES];
export type ContentMessageType = (typeof CONTENT_MESSAGE_TYPES)[keyof typeof CONTENT_MESSAGE_TYPES];
export type ToolMessageType = (typeof TOOL_MESSAGE_TYPES)[keyof typeof TOOL_MESSAGE_TYPES];
// Legacy enum for backward compatibility (will be deprecated)
export enum SendMessageType {
// Screenshot related message types
ScreenshotPreparePageForCapture = 'preparePageForCapture',
ScreenshotGetPageDetails = 'getPageDetails',
ScreenshotGetElementDetails = 'getElementDetails',
ScreenshotScrollPage = 'scrollPage',
ScreenshotResetPageAfterCapture = 'resetPageAfterCapture',
// Web content fetching related message types
WebFetcherGetHtmlContent = 'getHtmlContent',
WebFetcherGetTextContent = 'getTextContent',
// Click related message types
ClickElement = 'clickElement',
// Input filling related message types
FillElement = 'fillElement',
// Interactive elements related message types
GetInteractiveElements = 'getInteractiveElements',
// Network request capture related message types
NetworkSendRequest = 'sendPureNetworkRequest',
// Keyboard event related message types
SimulateKeyboard = 'simulateKeyboard',
// Semantic similarity engine related message types
SimilarityEngineInit = 'similarityEngineInit',
SimilarityEngineComputeBatch = 'similarityEngineComputeBatch',
}
// ============================================================
// Quick Panel <-> AgentChat Message Contracts
// ============================================================
/**
* Context information that can be attached to a Quick Panel AI request.
* Allows passing page-specific data to enhance the AI's understanding.
*/
export interface QuickPanelAIContext {
/** Current page URL */
pageUrl?: string;
/** User's text selection on the page */
selectedText?: string;
/**
* Optional element metadata from the page.
* Kept as unknown to avoid tight coupling with specific element types.
*/
elementInfo?: unknown;
}
/**
* Payload for sending a message to AI via Quick Panel.
*/
export interface QuickPanelSendToAIPayload {
/** The user's instruction/question for the AI */
instruction: string;
/** Optional contextual information from the page */
context?: QuickPanelAIContext;
}
/**
* Response from QUICK_PANEL_SEND_TO_AI message handler.
*/
export type QuickPanelSendToAIResponse =
| { success: true; requestId: string; sessionId: string }
| { success: false; error: string };
/**
* Message structure for sending to AI.
*/
export interface QuickPanelSendToAIMessage {
type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_SEND_TO_AI;
payload: QuickPanelSendToAIPayload;
}
/**
* Payload for cancelling an active AI request.
*/
export interface QuickPanelCancelAIPayload {
/** The request ID to cancel */
requestId: string;
/**
* Optional session ID for fallback when background state is missing.
* This can happen after MV3 Service Worker restarts.
*/
sessionId?: string;
}
/**
* Response from QUICK_PANEL_CANCEL_AI message handler.
*/
export type QuickPanelCancelAIResponse = { success: true } | { success: false; error: string };
/**
* Message structure for cancelling AI request.
*/
export interface QuickPanelCancelAIMessage {
type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CANCEL_AI;
payload: QuickPanelCancelAIPayload;
}
/**
* Message pushed from background to content script with AI streaming events.
* Uses the same RealtimeEvent type as AgentChat for consistency.
*/
export interface QuickPanelAIEventMessage {
action: typeof TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT;
requestId: string;
sessionId: string;
event: RealtimeEvent;
}
// ============================================================
// Quick Panel Search - Tabs Bridge Contracts
// ============================================================
/**
* Payload for querying open tabs.
*/
export interface QuickPanelTabsQueryPayload {
/**
* When true (default), query tabs across all windows.
* When false, restrict results to the sender's window.
*/
includeAllWindows?: boolean;
}
/**
* Summary of a single tab returned from the background.
*/
export interface QuickPanelTabSummary {
tabId: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
active: boolean;
pinned: boolean;
audible: boolean;
muted: boolean;
index: number;
lastAccessed?: number;
}
/**
* Response from QUICK_PANEL_TABS_QUERY message handler.
*/
export type QuickPanelTabsQueryResponse =
| {
success: true;
tabs: QuickPanelTabSummary[];
currentTabId: number | null;
currentWindowId: number | null;
}
| { success: false; error: string };
/**
* Message structure for querying tabs.
*/
export interface QuickPanelTabsQueryMessage {
type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TABS_QUERY;
payload?: QuickPanelTabsQueryPayload;
}
/**
* Payload for activating a tab.
*/
export interface QuickPanelActivateTabPayload {
tabId: number;
windowId?: number;
}
/**
* Response from QUICK_PANEL_TAB_ACTIVATE message handler.
*/
export type QuickPanelActivateTabResponse = { success: true } | { success: false; error: string };
/**
* Message structure for activating a tab.
*/
export interface QuickPanelActivateTabMessage {
type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE;
payload: QuickPanelActivateTabPayload;
}
/**
* Payload for closing a tab.
*/
export interface QuickPanelCloseTabPayload {
tabId: number;
}
/**
* Response from QUICK_PANEL_TAB_CLOSE message handler.
*/
export type QuickPanelCloseTabResponse = { success: true } | { success: false; error: string };
/**
* Message structure for closing a tab.
*/
export interface QuickPanelCloseTabMessage {
type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE;
payload: QuickPanelCloseTabPayload;
}
================================================
FILE: app/chrome-extension/common/node-types.ts
================================================
// node-types.ts — centralized node type constants for Builder/UI layer
// Combines all executable Step types with UI-only nodes (e.g., trigger, delay)
import { STEP_TYPES } from './step-types';
export const NODE_TYPES = {
// Executable step types (spread from STEP_TYPES)
...STEP_TYPES,
// UI-only nodes
TRIGGER: 'trigger',
DELAY: 'delay',
} as const;
export type NodeTypeConst = (typeof NODE_TYPES)[keyof typeof NODE_TYPES];
================================================
FILE: app/chrome-extension/common/rr-v3-keepalive-protocol.ts
================================================
/**
* @fileoverview RR V3 Keepalive Protocol Constants
* @description Shared protocol constants for Background-Offscreen keepalive communication
*/
/** Keepalive Port 名称 */
export const RR_V3_KEEPALIVE_PORT_NAME = 'rr_v3_keepalive' as const;
/** Keepalive 消息类型 */
export type KeepaliveMessageType =
| 'keepalive.ping'
| 'keepalive.pong'
| 'keepalive.start'
| 'keepalive.stop';
/** Keepalive 消息 */
export interface KeepaliveMessage {
type: KeepaliveMessageType;
timestamp: number;
}
/** 默认心跳间隔(毫秒) - Offscreen 每隔这个间隔发送 ping */
export const DEFAULT_KEEPALIVE_PING_INTERVAL_MS = 20_000;
/** 最大心跳间隔(毫秒)- Chrome MV3 SW 约 30s 空闲后终止 */
export const MAX_KEEPALIVE_PING_INTERVAL_MS = 25_000;
================================================
FILE: app/chrome-extension/common/step-types.ts
================================================
// step-types.ts — re-export shared constants to keep single source of truth
export { STEP_TYPES } from 'chrome-mcp-shared';
export type StepTypeConst =
(typeof import('chrome-mcp-shared'))['STEP_TYPES'][keyof (typeof import('chrome-mcp-shared'))['STEP_TYPES']];
================================================
FILE: app/chrome-extension/common/tool-handler.ts
================================================
import type { CallToolResult, TextContent, ImageContent } from '@modelcontextprotocol/sdk/types.js';
export interface ToolResult extends CallToolResult {
content: (TextContent | ImageContent)[];
isError: boolean;
}
export interface ToolExecutor {
execute(args: any): Promise;
}
export const createErrorResponse = (
message: string = 'Unknown error, please try again',
): ToolResult => {
return {
content: [
{
type: 'text',
text: message,
},
],
isError: true,
};
};
================================================
FILE: app/chrome-extension/common/web-editor-types.ts
================================================
/**
* Web Editor V2 - Shared Type Definitions
*
* This module defines types shared between:
* - Background script (injection control)
* - Inject script (web-editor-v2.ts)
* - Future: UI panels
*/
// =============================================================================
// Editor State
// =============================================================================
/** Current state of the web editor */
export interface WebEditorState {
/** Whether the editor is currently active */
active: boolean;
/** Editor version for compatibility checks */
version: 2;
}
// =============================================================================
// Message Protocol (Background <-> Inject Script)
// =============================================================================
/**
* Action types for web editor V2 messages
*
* IMPORTANT: V2 uses versioned action names (suffix _v2) to avoid
* conflicts with V1 when both scripts might be injected in the same tab.
* This prevents double-response race conditions.
*
* V1 uses: web_editor_ping, web_editor_toggle, etc.
* V2 uses: web_editor_ping_v2, web_editor_toggle_v2, etc.
*/
export const WEB_EDITOR_V2_ACTIONS = {
/** Check if V2 editor is injected and get status */
PING: 'web_editor_ping_v2',
/** Toggle V2 editor on/off */
TOGGLE: 'web_editor_toggle_v2',
/** Start V2 editor */
START: 'web_editor_start_v2',
/** Stop V2 editor */
STOP: 'web_editor_stop_v2',
/** Highlight an element (from sidepanel hover) */
HIGHLIGHT_ELEMENT: 'web_editor_highlight_element_v2',
/** Revert an element to its original state (Phase 2 - Selective Undo) */
REVERT_ELEMENT: 'web_editor_revert_element_v2',
/** Clear selection (from sidepanel after send) */
CLEAR_SELECTION: 'web_editor_clear_selection_v2',
} as const;
/**
* Legacy V1 action types (for reference and background compatibility)
* These are used when USE_WEB_EDITOR_V2 is false
*/
export const WEB_EDITOR_V1_ACTIONS = {
PING: 'web_editor_ping',
TOGGLE: 'web_editor_toggle',
START: 'web_editor_start',
STOP: 'web_editor_stop',
APPLY: 'web_editor_apply',
} as const;
export type WebEditorV2Action = (typeof WEB_EDITOR_V2_ACTIONS)[keyof typeof WEB_EDITOR_V2_ACTIONS];
export type WebEditorV1Action = (typeof WEB_EDITOR_V1_ACTIONS)[keyof typeof WEB_EDITOR_V1_ACTIONS];
/** Editor version literal type */
export type WebEditorVersion = 1 | 2;
/** Ping request (V2) */
export interface WebEditorV2PingRequest {
action: typeof WEB_EDITOR_V2_ACTIONS.PING;
}
/** Ping response (V2) */
export interface WebEditorV2PingResponse {
status: 'pong';
active: boolean;
version: 2;
}
/** Toggle request (V2) */
export interface WebEditorV2ToggleRequest {
action: typeof WEB_EDITOR_V2_ACTIONS.TOGGLE;
}
/** Toggle response (V2) */
export interface WebEditorV2ToggleResponse {
active: boolean;
}
/** Start request (V2) */
export interface WebEditorV2StartRequest {
action: typeof WEB_EDITOR_V2_ACTIONS.START;
}
/** Start response (V2) */
export interface WebEditorV2StartResponse {
active: boolean;
}
/** Stop request (V2) */
export interface WebEditorV2StopRequest {
action: typeof WEB_EDITOR_V2_ACTIONS.STOP;
}
/** Stop response (V2) */
export interface WebEditorV2StopResponse {
active: boolean;
}
/** Union types for V2 type-safe message handling */
export type WebEditorV2Request =
| WebEditorV2PingRequest
| WebEditorV2ToggleRequest
| WebEditorV2StartRequest
| WebEditorV2StopRequest;
export type WebEditorV2Response =
| WebEditorV2PingResponse
| WebEditorV2ToggleResponse
| WebEditorV2StartResponse
| WebEditorV2StopResponse;
// =============================================================================
// Element Locator (Phase 1 - Basic Structure)
// =============================================================================
/**
* Framework debug source information
* Extracted from React Fiber or Vue component instance
*/
export interface DebugSource {
/** Source file path */
file: string;
/** Line number (1-based) */
line?: number;
/** Column number (1-based) */
column?: number;
/** Component name (if available) */
componentName?: string;
}
/**
* Element Locator - Primary key for element identification
*
* Uses multiple strategies to locate elements, supporting:
* - HMR/DOM changes recovery
* - Cross-session persistence
* - Framework-agnostic identification
*/
export interface ElementLocator {
/** CSS selector candidates (ordered by specificity) */
selectors: string[];
/** Structural fingerprint for similarity matching */
fingerprint: string;
/** Framework debug information (React/Vue) */
debugSource?: DebugSource;
/** DOM tree path (child indices from root) */
path: number[];
/** iframe selector chain (from top to target frame) - Phase 4 */
frameChain?: string[];
/** Shadow DOM host selector chain - Phase 2 */
shadowHostChain?: string[];
}
// =============================================================================
// Transaction System (Phase 1 - Basic Structure, Low Priority)
// =============================================================================
/** Transaction operation types */
export type TransactionType = 'style' | 'text' | 'class' | 'move' | 'structure';
/**
* Transaction snapshot for undo/redo
* Captures element state before/after changes
*/
export interface TransactionSnapshot {
/** Element locator for re-identification */
locator: ElementLocator;
/** innerHTML snapshot (for structure changes) */
html?: string;
/** Changed style properties */
styles?: Record;
/** Class list tokens (from `class` attribute) */
classes?: string[];
/** Text content */
text?: string;
}
/**
* Move position data
* Captures a concrete insertion point under a parent element
*/
export interface MoveOperationData {
/** Target parent element locator */
parentLocator: ElementLocator;
/** Insert position index (among element children) */
insertIndex: number;
/** Anchor sibling element locator (for stable positioning) */
anchorLocator?: ElementLocator;
/** Position relative to anchor */
anchorPosition: 'before' | 'after';
}
/**
* Move transaction data
* Captures both source and destination for undo/redo
*/
export interface MoveTransactionData {
/** Original location before move */
from: MoveOperationData;
/** Target location after move */
to: MoveOperationData;
}
/**
* Structure operation data
* For wrap/unwrap/delete/duplicate operations (Phase 5.5)
*/
export interface StructureOperationData {
/** Structure action type */
action: 'wrap' | 'unwrap' | 'delete' | 'duplicate';
/** Wrapper tag for wrap/unwrap actions */
wrapperTag?: string;
/** Wrapper inline styles for wrap/unwrap actions */
wrapperStyles?: Record;
/**
* Deterministic insertion position for undo/redo.
* Required for delete (restore) and duplicate (re-create).
*/
position?: MoveOperationData;
/**
* Serialized element HTML for undo/redo.
* Must be a single-root element outerHTML string.
* Used by delete (restore original) and duplicate (re-create clone).
*/
html?: string;
}
/**
* Transaction record for undo/redo system
*/
export interface Transaction {
/** Unique transaction ID */
id: string;
/** Operation type */
type: TransactionType;
/** Target element locator */
targetLocator: ElementLocator;
/**
* Stable element identifier for cross-transaction grouping.
* Used by AgentChat integration for element chips aggregation.
* Optional for backward compatibility with existing transactions.
*/
elementKey?: string;
/** State before change */
before: TransactionSnapshot;
/** State after change */
after: TransactionSnapshot;
/** Move-specific data */
moveData?: MoveTransactionData;
/** Structure-specific data */
structureData?: StructureOperationData;
/** Timestamp */
timestamp: number;
/** Whether merged with previous transaction */
merged: boolean;
}
// =============================================================================
// AgentChat Integration Types (Phase 1.1)
// =============================================================================
/** Stable element identifier for aggregating transactions across UI contexts */
export type WebEditorElementKey = string;
/**
* Net effect payload for a single element aggregated from the undo stack.
* Designed to be directly consumable by prompt builders.
*/
export interface NetEffectPayload {
/** Stable element key */
elementKey: WebEditorElementKey;
/** Locator snapshot for element re-identification */
locator: ElementLocator;
/**
* Aggregated style changes (first before -> last after).
* Contains ONLY the affected properties, not a full style snapshot.
* Empty string value means the property was removed/unset.
*/
styleChanges?: {
before: Record;
after: Record;
};
/** Aggregated text change (first before -> last after) */
textChange?: {
before: string;
after: string;
};
/** Aggregated class changes (first before -> last after) */
classChanges?: {
before: string[];
after: string[];
};
}
/** High-level change category for UI display */
export type ElementChangeType = 'style' | 'text' | 'class' | 'mixed';
/**
* Element change summary for Chips rendering in AgentChat.
* Aggregates multiple transactions for the same element.
*/
export interface ElementChangeSummary {
/** Stable element identifier */
elementKey: WebEditorElementKey;
/** Short label for Chips display (e.g., "button#submit") */
label: string;
/** Full label for tooltips with more context */
fullLabel: string;
/** Locator snapshot for highlighting and element recovery */
locator: ElementLocator;
/** High-level change category */
type: ElementChangeType;
/** Detailed change statistics for UI tooltips */
changes: {
style?: {
/** Number of new style properties added */
added: number;
/** Number of style properties removed */
removed: number;
/** Number of style properties modified */
modified: number;
/** List of affected style property names */
details: string[];
};
text?: {
/** Truncated preview of original text */
beforePreview: string;
/** Truncated preview of new text */
afterPreview: string;
};
class?: {
/** Classes added */
added: string[];
/** Classes removed */
removed: string[];
};
};
/** Contributing transaction IDs in chronological order */
transactionIds: string[];
/** Net effect payload for batch Apply */
netEffect: NetEffectPayload;
/** Timestamp of the most recent transaction */
updatedAt: number;
/** Debug source information if available */
debugSource?: DebugSource;
}
/** Action types for TX change events */
export type WebEditorTxChangeAction = 'push' | 'merge' | 'undo' | 'redo' | 'clear' | 'rollback';
/**
* TX change broadcast payload sent to Sidepanel/AgentChat.
* Emitted when the undo stack changes (push, undo, redo, clear).
*/
export interface WebEditorTxChangedPayload {
/** Source tab ID for multi-tab isolation */
tabId: number;
/** Action that triggered this change (for UI animations/incremental updates) */
action: WebEditorTxChangeAction;
/** Aggregated element-level summaries from the current undo stack */
elements: ElementChangeSummary[];
/** Current undo stack size */
undoCount: number;
/** Current redo stack size */
redoCount: number;
/** Whether there are applicable changes (style/text/class) */
hasApplicableChanges: boolean;
/** Page URL for context */
pageUrl?: string;
}
/**
* Batch Apply payload sent from web-editor to background.
*/
export interface WebEditorApplyBatchPayload {
/** Source tab ID */
tabId: number;
/** Element changes to apply */
elements: ElementChangeSummary[];
/** Element keys excluded by user */
excludedKeys: WebEditorElementKey[];
/** Page URL for context */
pageUrl?: string;
}
/**
* Highlight element request sent from AgentChat to the active tab.
*/
export interface WebEditorHighlightElementPayload {
/** Target tab ID */
tabId: number;
/** Element key to highlight */
elementKey: WebEditorElementKey;
/** Locator for element identification */
locator: ElementLocator;
/** Highlight mode: 'hover' to show, 'clear' to hide */
mode: 'hover' | 'clear';
}
/**
* Revert element request sent from AgentChat to the active tab.
* Used for Phase 2 - Selective Undo (reverting individual element changes).
*/
export interface WebEditorRevertElementPayload {
/** Target tab ID */
tabId: number;
/** Element key to revert */
elementKey: WebEditorElementKey;
}
/**
* Revert element response from content script.
*/
export interface WebEditorRevertElementResponse {
/** Whether the revert was successful */
success: boolean;
/** What was reverted (for UI feedback) */
reverted?: {
style?: boolean;
text?: boolean;
class?: boolean;
};
/** Error message if revert failed */
error?: string;
}
// =============================================================================
// Selection Sync Types
// =============================================================================
/**
* Summary of currently selected element.
* Lightweight payload for selection sync (no transaction data).
*/
export interface SelectedElementSummary {
/** Stable element identifier */
elementKey: WebEditorElementKey;
/** Locator for element identification and highlighting */
locator: ElementLocator;
/** Short display label (e.g., "div#app") */
label: string;
/** Full label with context (e.g., "body > div#app") */
fullLabel: string;
/** Tag name of the element */
tagName: string;
/** Timestamp for deduplication */
updatedAt: number;
}
/**
* Selection change broadcast payload.
* Sent immediately when user selects/deselects elements (no debounce).
*/
export interface WebEditorSelectionChangedPayload {
/** Source tab ID (filled by background from sender.tab.id) */
tabId: number;
/** Currently selected element, or null if deselected */
selected: SelectedElementSummary | null;
/** Page URL for context */
pageUrl?: string;
}
// =============================================================================
// Execution Cancel Types
// =============================================================================
/**
* Payload for canceling an ongoing Apply execution.
* Sent from web-editor toolbar or sidepanel to background.
*/
export interface WebEditorCancelExecutionPayload {
/** Session ID of the execution to cancel */
sessionId: string;
/** Request ID of the execution to cancel */
requestId: string;
}
/**
* Response from cancel execution request.
*/
export interface WebEditorCancelExecutionResponse {
/** Whether the cancel request was successful */
success: boolean;
/** Error message if cancellation failed */
error?: string;
}
// =============================================================================
// Public API Interface
// =============================================================================
/**
* Web Editor V2 Public API
* Exposed on window.__MCP_WEB_EDITOR_V2__
*/
export interface WebEditorV2Api {
/** Start the editor */
start: () => void;
/** Stop the editor */
stop: () => void;
/** Toggle editor on/off, returns new state */
toggle: () => boolean;
/** Get current state */
getState: () => WebEditorState;
/**
* Revert a specific element to its original state (Phase 2 - Selective Undo).
* Creates a compensating transaction that can be undone.
*/
revertElement: (elementKey: WebEditorElementKey) => Promise;
/**
* Clear current selection (called from sidepanel after send).
* Triggers deselect and broadcasts null selection.
*/
clearSelection: () => void;
}
// =============================================================================
// Global Declaration
// =============================================================================
declare global {
interface Window {
__MCP_WEB_EDITOR_V2__?: WebEditorV2Api;
}
}
================================================
FILE: app/chrome-extension/entrypoints/background/element-marker/element-marker-storage.ts
================================================
// IndexedDB storage for element markers (URL -> marked selectors)
// Uses the shared IndexedDbClient for robust transaction handling.
import { IndexedDbClient } from '@/utils/indexeddb-client';
import type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';
const DB_NAME = 'element_marker_storage';
const DB_VERSION = 1;
const STORE = 'markers';
const idb = new IndexedDbClient(DB_NAME, DB_VERSION, (db, oldVersion) => {
switch (oldVersion) {
case 0: {
const store = db.createObjectStore(STORE, { keyPath: 'id' });
// Useful indexes for lookups
store.createIndex('by_host', 'host', { unique: false });
store.createIndex('by_origin', 'origin', { unique: false });
store.createIndex('by_path', 'path', { unique: false });
}
}
});
function normalizeUrl(raw: string): { url: string; origin: string; host: string; path: string } {
try {
const u = new URL(raw);
return { url: raw, origin: u.origin, host: u.hostname, path: u.pathname };
} catch {
return { url: raw, origin: '', host: '', path: '' };
}
}
function now(): number {
return Date.now();
}
export async function listAllMarkers(): Promise {
return idb.getAll(STORE);
}
export async function listMarkersForUrl(url: string): Promise {
const { origin, path, host } = normalizeUrl(url);
const all = await idb.getAll(STORE);
// Simple matching policy:
// - exact: origin + path must match exactly
// - prefix: origin matches and marker.path is a prefix of current path
// - host: host matches regardless of path
return all.filter((m) => {
if (!m) return false;
if (m.matchType === 'exact') return m.origin === origin && m.path === path;
if (m.matchType === 'host') return !!m.host && m.host === host;
// default 'prefix'
return m.origin === origin && (m.path ? path.startsWith(m.path) : true);
});
}
export async function saveMarker(req: UpsertMarkerRequest): Promise {
const { url: rawUrl, selector } = req;
if (!rawUrl || !selector) throw new Error('url and selector are required');
const { url, origin, host, path } = normalizeUrl(rawUrl);
const ts = now();
const marker: ElementMarker = {
id: req.id || (globalThis.crypto?.randomUUID?.() ?? `${ts}_${Math.random()}`),
url,
origin,
host,
path,
matchType: req.matchType || 'prefix',
name: req.name || selector,
selector,
selectorType: req.selectorType || 'css',
listMode: req.listMode || false,
action: req.action || 'custom',
createdAt: ts,
updatedAt: ts,
};
await idb.put(STORE, marker);
return marker;
}
export async function updateMarker(marker: ElementMarker): Promise {
const existing = await idb.get(STORE, marker.id);
if (!existing) throw new Error('marker not found');
// Preserve createdAt from existing record, only update updatedAt
const updated: ElementMarker = {
...marker,
createdAt: existing.createdAt, // Never overwrite createdAt
updatedAt: now(),
};
await idb.put(STORE, updated);
}
export async function deleteMarker(id: string): Promise {
await idb.delete(STORE, id);
}
================================================
FILE: app/chrome-extension/entrypoints/background/element-marker/index.ts
================================================
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
import type {
UpsertMarkerRequest,
ElementMarker,
MarkerValidationRequest,
MarkerValidationAction,
} from '@/common/element-marker-types';
import {
deleteMarker,
listAllMarkers,
listMarkersForUrl,
saveMarker,
updateMarker,
} from './element-marker-storage';
import { computerTool } from '@/entrypoints/background/tools/browser/computer';
import { clickTool } from '@/entrypoints/background/tools/browser/interaction';
import { keyboardTool } from '@/entrypoints/background/tools/browser/keyboard';
const CONTEXT_MENU_ID = 'element_marker_mark';
/**
* Extract error message from MCP tool result
*/
function extractToolError(result: any): string | undefined {
if (!result) return undefined;
// Check for error in result content array
if (Array.isArray(result.content)) {
for (const item of result.content) {
if (item?.text) {
try {
const parsed = JSON.parse(item.text);
if (parsed?.error) return parsed.error;
if (parsed?.message) return parsed.message;
} catch {
// Not JSON, use as-is
return item.text;
}
}
}
}
// Fallback to direct error field
return result.error || (result.isError ? 'unknown tool error' : undefined);
}
async function ensureContextMenu() {
try {
// Guard: contextMenus permission may be missing
if (!(chrome as any).contextMenus?.create) return;
// Remove and re-create our single menu to avoid duplication
try {
await chrome.contextMenus.remove(CONTEXT_MENU_ID);
} catch {}
await chrome.contextMenus.create({
id: CONTEXT_MENU_ID,
title: '标注元素',
contexts: ['all'],
});
} catch (e) {
console.warn('ElementMarker: ensureContextMenu failed:', e);
}
}
/**
* Check if element-marker.js is already injected in the tab
* Uses a short timeout to avoid hanging on unresponsive tabs
*/
async function isMarkerInjected(tabId: number): Promise {
try {
const response = await Promise.race([
chrome.tabs.sendMessage(tabId, { action: 'element_marker_ping' }),
new Promise((resolve) => setTimeout(() => resolve(null), 300)),
]);
return response?.status === 'pong';
} catch {
return false;
}
}
/**
* Inject element-marker.js into the tab if not already injected
*/
async function injectMarkerHelper(tabId: number) {
// Check if already injected via ping
const alreadyInjected = await isMarkerInjected(tabId);
if (!alreadyInjected) {
try {
await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
files: ['inject-scripts/element-marker.js'],
world: 'ISOLATED',
} as any);
} catch (e) {
// Script injection may fail on some pages (e.g., chrome:// URLs)
console.warn('ElementMarker: script injection failed:', e);
}
}
try {
await chrome.tabs.sendMessage(tabId, { action: 'element_marker_start' } as any);
} catch (e) {
console.warn('ElementMarker: start overlay failed:', e);
}
}
export function initElementMarkerListeners() {
// Ensure context menu on startup
ensureContextMenu().catch(() => {});
// Respond to RR triggers refresh by re-ensuring our menu a bit later
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
try {
switch (message?.type) {
// Handle element marker start from popup
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_START: {
const tabId = message.tabId;
if (typeof tabId !== 'number') {
sendResponse({ success: false, error: 'invalid tabId' });
return true;
}
injectMarkerHelper(tabId)
.then(() => sendResponse({ success: true }))
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_ALL: {
listAllMarkers()
.then((markers) => sendResponse({ success: true, markers }))
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_FOR_URL: {
const url = String(message.url || '');
listMarkersForUrl(url)
.then((markers) => sendResponse({ success: true, markers }))
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE: {
const req = message.marker as UpsertMarkerRequest;
saveMarker(req)
.then((marker) => sendResponse({ success: true, marker }))
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_UPDATE: {
const marker = message.marker as ElementMarker;
updateMarker(marker)
.then(() => sendResponse({ success: true }))
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_DELETE: {
const id = String(message.id || '');
if (!id) {
sendResponse({ success: false, error: 'invalid id' });
return true;
}
deleteMarker(id)
.then(() => sendResponse({ success: true }))
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
return true;
}
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_VALIDATE: {
// Validate via MCP tool chain
(async () => {
const req = message as {
selector: string;
selectorType?: 'css' | 'xpath';
action: MarkerValidationAction;
listMode?: boolean;
text?: string;
keys?: string;
button?: 'left' | 'right' | 'middle';
bubbles?: boolean;
cancelable?: boolean;
modifiers?: any;
coordinates?: { x: number; y: number };
offsetX?: number;
offsetY?: number;
relativeTo?: 'element' | 'viewport';
};
// enrich typing with optional nav + scroll params
(req as any).waitForNavigation = (message as any).waitForNavigation;
(req as any).timeoutMs = (message as any).timeoutMs;
(req as any).scrollDirection = (message as any).scrollDirection;
(req as any).scrollAmount = (message as any).scrollAmount;
const selector = String(req.selector || '').trim();
const selectorType = (req.selectorType || 'css') as 'css' | 'xpath';
const action = req.action as MarkerValidationAction;
if (!selector) return sendResponse({ success: false, error: 'selector is required' });
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
if (!tab?.id) return sendResponse({ success: false, error: 'active tab not found' });
// 1) Ensure helper
try {
await chrome.scripting.executeScript({
target: { tabId: tab.id, allFrames: true },
files: ['inject-scripts/accessibility-tree-helper.js'],
world: 'ISOLATED',
} as any);
} catch {}
// 2) Resolve selector -> ref/center via helper (same as tools)
let ensured: any;
try {
ensured = await chrome.tabs.sendMessage(tab.id, {
action: 'ensureRefForSelector',
selector,
isXPath: selectorType === 'xpath',
allowMultiple: !!req.listMode,
} as any);
} catch (e) {
return sendResponse({
success: false,
error: String(e instanceof Error ? e.message : e),
});
}
if (!ensured || !ensured.success || !ensured.ref) {
return sendResponse({
success: false,
error: ensured?.error || 'failed to resolve selector',
});
}
const base = {
success: true,
resolved: true,
ref: ensured.ref,
center: ensured.center,
} as any;
// Compute optional coordinates from offsets
let coords: { x: number; y: number } | undefined = undefined;
if (
req.coordinates &&
typeof req.coordinates.x === 'number' &&
typeof req.coordinates.y === 'number'
) {
coords = { x: Math.round(req.coordinates.x), y: Math.round(req.coordinates.y) };
} else if (
req.relativeTo === 'element' &&
ensured.center &&
(typeof req.offsetX === 'number' || typeof req.offsetY === 'number')
) {
const dx = Number.isFinite(req.offsetX as any) ? (req.offsetX as number) : 0;
const dy = Number.isFinite(req.offsetY as any) ? (req.offsetY as number) : 0;
coords = { x: ensured.center.x + dx, y: ensured.center.y + dy };
}
// 3) Dispatch to appropriate tool for end-to-end validation
try {
switch (action) {
case 'hover': {
const r = await computerTool.execute(
coords
? { action: 'hover', coordinates: coords }
: ({ action: 'hover', ref: ensured.ref } as any),
);
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'computer.hover', ok: !r.isError, error };
break;
}
case 'left_click': {
const r = await clickTool.execute({
...(coords ? { coordinates: coords } : { ref: ensured.ref }),
waitForNavigation: !!req.waitForNavigation,
timeout: Number.isFinite(req.timeoutMs as any)
? (req.timeoutMs as number)
: 3000,
button: (req.button || 'left') as any,
modifiers: req.modifiers || {},
} as any);
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'interaction.click', ok: !r.isError, error };
break;
}
case 'double_click': {
const r = await clickTool.execute({
...(coords ? { coordinates: coords } : { ref: ensured.ref }),
double: true,
waitForNavigation: !!req.waitForNavigation,
timeout: Number.isFinite(req.timeoutMs as any)
? (req.timeoutMs as number)
: 3000,
button: (req.button || 'left') as any,
modifiers: req.modifiers || {},
} as any);
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'interaction.click(double)', ok: !r.isError, error };
break;
}
case 'right_click': {
const r = await clickTool.execute({
...(coords ? { coordinates: coords } : { ref: ensured.ref }),
waitForNavigation: !!req.waitForNavigation,
timeout: Number.isFinite(req.timeoutMs as any)
? (req.timeoutMs as number)
: 3000,
button: 'right',
modifiers: req.modifiers || {},
} as any);
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'interaction.click(right)', ok: !r.isError, error };
break;
}
case 'scroll': {
const direction = (req as any).scrollDirection || 'down';
const amount = Number.isFinite((req as any).scrollAmount)
? Number((req as any).scrollAmount)
: 300;
const payload = coords
? {
action: 'scroll',
scrollDirection: direction,
scrollAmount: amount,
coordinates: coords,
}
: ({
action: 'scroll',
scrollDirection: direction,
scrollAmount: amount,
ref: ensured.ref,
} as any);
const r = await computerTool.execute(payload as any);
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'computer.scroll', ok: !r.isError, error };
break;
}
case 'type_text': {
const text = String(req.text || '');
const r = await computerTool.execute({ action: 'type', ref: ensured.ref, text });
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'computer.type', ok: !r.isError, error };
break;
}
case 'press_keys': {
const keys = String(req.keys || '');
// Focus first by ref to ensure key target
try {
await clickTool.execute({
ref: ensured.ref,
waitForNavigation: false,
timeout: 2000,
});
} catch {}
const r = await keyboardTool.execute({ keys, delay: 0 } as any);
const error = r.isError ? extractToolError(r) : undefined;
base.tool = { name: 'keyboard.simulate', ok: !r.isError, error };
break;
}
default: {
base.tool = { name: 'noop', ok: true };
}
}
} catch (e) {
console.warn('[ElementMarker] Validation failed before tool execution', e);
base.tool = {
name: 'unknown',
ok: false,
error: String(e instanceof Error ? e.message : e),
};
}
// Log tool failures for debugging
if (base.tool && base.tool.ok === false) {
console.warn('[ElementMarker] Tool validation failure', {
action,
toolName: base.tool.name,
error: base.tool.error,
selector,
selectorType,
});
}
return sendResponse(base);
})();
return true;
}
// When RR refresh (or similar) happens, re-add our menu
case BACKGROUND_MESSAGE_TYPES.RR_REFRESH_TRIGGERS:
case BACKGROUND_MESSAGE_TYPES.RR_SAVE_TRIGGER:
case BACKGROUND_MESSAGE_TYPES.RR_DELETE_TRIGGER: {
setTimeout(() => ensureContextMenu().catch(() => {}), 300);
break;
}
}
} catch (e) {
sendResponse({ success: false, error: (e as any)?.message || String(e) });
}
return false;
});
// Context menu click routing
if ((chrome as any).contextMenus?.onClicked?.addListener) {
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
try {
if (info.menuItemId === CONTEXT_MENU_ID && tab?.id) {
await injectMarkerHelper(tab.id);
}
} catch (e) {
console.warn('ElementMarker: context menu click failed:', e);
}
});
}
}
================================================
FILE: app/chrome-extension/entrypoints/background/index.ts
================================================
import { initNativeHostListener } from './native-host';
import {
initSemanticSimilarityListener,
initializeSemanticEngineIfCached,
} from './semantic-similarity';
import { initStorageManagerListener } from './storage-manager';
import { cleanupModelCache } from '@/utils/semantic-similarity-engine';
import { initRecordReplayListeners } from './record-replay';
import { initElementMarkerListeners } from './element-marker';
import { initWebEditorListeners } from './web-editor';
import { initQuickPanelAgentHandler } from './quick-panel/agent-handler';
import { initQuickPanelCommands } from './quick-panel/commands';
import { initQuickPanelTabsHandler } from './quick-panel/tabs-handler';
// Record-Replay V3 (feature flag)
import { bootstrapV3 } from './record-replay-v3/bootstrap';
/**
* Feature flag for RR-V3
* Set to true to enable the new Record-Replay V3 engine
*/
const ENABLE_RR_V3 = true;
/**
* Background script entry point
* Initializes all background services and listeners
*/
export default defineBackground(() => {
// Open welcome page on first install
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
// Open the welcome/onboarding page for new installations
chrome.tabs.create({
url: chrome.runtime.getURL('/welcome.html'),
});
}
});
// Initialize core services
initNativeHostListener();
initSemanticSimilarityListener();
initStorageManagerListener();
// Record & Replay V1/V2 listeners
initRecordReplayListeners();
// Record & Replay V3 (new engine)
if (ENABLE_RR_V3) {
bootstrapV3()
.then((runtime) => {
console.log(`[RR-V3] Bootstrap complete, ownerId: ${runtime.ownerId}`);
})
.catch((error) => {
console.error('[RR-V3] Bootstrap failed:', error);
});
}
// Element marker: context menu + CRUD listeners
initElementMarkerListeners();
// Web editor: toggle edit-mode overlay
initWebEditorListeners();
// Quick Panel: send messages to AgentChat via background-stream bridge
initQuickPanelAgentHandler();
// Quick Panel: tabs search bridge for content script UI
initQuickPanelTabsHandler();
// Quick Panel: keyboard shortcut handler
initQuickPanelCommands();
// Conditionally initialize semantic similarity engine if model cache exists
initializeSemanticEngineIfCached()
.then((initialized) => {
if (initialized) {
console.log('Background: Semantic similarity engine initialized from cache');
} else {
console.log(
'Background: Semantic similarity engine initialization skipped (no cache found)',
);
}
})
.catch((error) => {
console.warn('Background: Failed to conditionally initialize semantic engine:', error);
});
// Initial cleanup on startup
cleanupModelCache().catch((error) => {
console.warn('Background: Initial cache cleanup failed:', error);
});
});
================================================
FILE: app/chrome-extension/entrypoints/background/keepalive-manager.ts
================================================
/**
* @fileoverview Keepalive Manager
* @description Global singleton service for managing Service Worker keepalive.
*
* This module provides a unified interface for acquiring and releasing keepalive
* references. Multiple modules can acquire keepalive independently using tags,
* and the underlying keepalive mechanism will remain active as long as at least
* one reference is held.
*/
import {
createOffscreenKeepaliveController,
type KeepaliveController,
} from './record-replay-v3/engine/keepalive/offscreen-keepalive';
const LOG_PREFIX = '[KeepaliveManager]';
/**
* Singleton keepalive controller instance.
* Created lazily to avoid initialization issues during module loading.
*/
let controller: KeepaliveController | null = null;
/**
* Get or create the singleton keepalive controller.
*/
function getController(): KeepaliveController {
if (!controller) {
controller = createOffscreenKeepaliveController({ logger: console });
console.debug(`${LOG_PREFIX} Controller initialized`);
}
return controller;
}
/**
* Acquire a keepalive reference with a tag.
*
* @param tag - Identifier for the reference (e.g., 'native-host', 'rr-engine')
* @returns A release function to call when keepalive is no longer needed
*
* @example
* ```typescript
* const release = acquireKeepalive('native-host');
* // ... do work that needs SW to stay alive ...
* release(); // Release when done
* ```
*/
export function acquireKeepalive(tag: string): () => void {
try {
const release = getController().acquire(tag);
console.debug(`${LOG_PREFIX} Acquired keepalive for tag: ${tag}`);
return () => {
try {
release();
console.debug(`${LOG_PREFIX} Released keepalive for tag: ${tag}`);
} catch (error) {
console.warn(`${LOG_PREFIX} Failed to release keepalive for ${tag}:`, error);
}
};
} catch (error) {
console.warn(`${LOG_PREFIX} Failed to acquire keepalive for ${tag}:`, error);
return () => {};
}
}
/**
* Check if keepalive is currently active (any references held).
*/
export function isKeepaliveActive(): boolean {
try {
return getController().isActive();
} catch {
return false;
}
}
/**
* Get the current keepalive reference count.
* Useful for debugging.
*/
export function getKeepaliveRefCount(): number {
try {
return getController().getRefCount();
} catch {
return 0;
}
}
================================================
FILE: app/chrome-extension/entrypoints/background/native-host.ts
================================================
import { NativeMessageType } from 'chrome-mcp-shared';
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
import { NATIVE_HOST, STORAGE_KEYS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '@/common/constants';
import { handleCallTool } from './tools';
import { listPublished, getFlow } from './record-replay/flow-store';
import { acquireKeepalive } from './keepalive-manager';
const LOG_PREFIX = '[NativeHost]';
let nativePort: chrome.runtime.Port | null = null;
export const HOST_NAME = NATIVE_HOST.NAME;
// ==================== Reconnect Configuration ====================
const RECONNECT_BASE_DELAY_MS = 500;
const RECONNECT_MAX_DELAY_MS = 60_000;
const RECONNECT_MAX_FAST_ATTEMPTS = 8;
const RECONNECT_COOLDOWN_DELAY_MS = 5 * 60_000;
// ==================== Auto-connect State ====================
let keepaliveRelease: (() => void) | null = null;
let autoConnectEnabled = true;
let autoConnectLoaded = false;
let ensurePromise: Promise | null = null;
let reconnectTimer: ReturnType | null = null;
let reconnectAttempts = 0;
let manualDisconnect = false;
/**
* Server status management interface
*/
interface ServerStatus {
isRunning: boolean;
port?: number;
lastUpdated: number;
}
let currentServerStatus: ServerStatus = {
isRunning: false,
lastUpdated: Date.now(),
};
/**
* Save server status to chrome.storage
*/
async function saveServerStatus(status: ServerStatus): Promise {
try {
await chrome.storage.local.set({ [STORAGE_KEYS.SERVER_STATUS]: status });
} catch (error) {
console.error(ERROR_MESSAGES.SERVER_STATUS_SAVE_FAILED, error);
}
}
/**
* Load server status from chrome.storage
*/
async function loadServerStatus(): Promise {
try {
const result = await chrome.storage.local.get([STORAGE_KEYS.SERVER_STATUS]);
if (result[STORAGE_KEYS.SERVER_STATUS]) {
return result[STORAGE_KEYS.SERVER_STATUS];
}
} catch (error) {
console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);
}
return {
isRunning: false,
lastUpdated: Date.now(),
};
}
/**
* Broadcast server status change to all listeners
*/
function broadcastServerStatusChange(status: ServerStatus): void {
chrome.runtime
.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.SERVER_STATUS_CHANGED,
payload: status,
})
.catch(() => {
// Ignore errors if no listeners are present
});
}
// ==================== Port Normalization ====================
/**
* Normalize a port value to a valid port number or null.
*/
function normalizePort(value: unknown): number | null {
const n =
typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;
if (!Number.isFinite(n)) return null;
const port = Math.floor(n);
if (port <= 0 || port > 65535) return null;
return port;
}
// ==================== Reconnect Utilities ====================
/**
* Add jitter to a delay value to avoid thundering herd.
*/
function withJitter(ms: number): number {
const ratio = 0.7 + Math.random() * 0.6;
return Math.max(0, Math.round(ms * ratio));
}
/**
* Calculate reconnect delay based on attempt number.
* Uses exponential backoff with jitter, then switches to cooldown interval.
*/
function getReconnectDelayMs(attempt: number): number {
if (attempt >= RECONNECT_MAX_FAST_ATTEMPTS) {
return withJitter(RECONNECT_COOLDOWN_DELAY_MS);
}
const delay = Math.min(RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt), RECONNECT_MAX_DELAY_MS);
return withJitter(delay);
}
/**
* Clear the reconnect timer if active.
*/
function clearReconnectTimer(): void {
if (!reconnectTimer) return;
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
/**
* Reset reconnect state after successful connection.
*/
function resetReconnectState(): void {
reconnectAttempts = 0;
clearReconnectTimer();
}
// ==================== Keepalive Management ====================
/**
* Sync keepalive hold based on autoConnectEnabled state.
* When auto-connect is enabled, we hold a keepalive reference to keep SW alive.
*/
function syncKeepaliveHold(): void {
if (autoConnectEnabled) {
if (!keepaliveRelease) {
keepaliveRelease = acquireKeepalive('native-host');
console.debug(`${LOG_PREFIX} Acquired keepalive`);
}
return;
}
if (keepaliveRelease) {
try {
keepaliveRelease();
console.debug(`${LOG_PREFIX} Released keepalive`);
} catch {
// Ignore
}
keepaliveRelease = null;
}
}
// ==================== Auto-connect Settings ====================
/**
* Load the nativeAutoConnectEnabled setting from storage.
*/
async function loadNativeAutoConnectEnabled(): Promise {
try {
const result = await chrome.storage.local.get([STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]);
const raw = result[STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED];
if (typeof raw === 'boolean') return raw;
} catch (error) {
console.warn(`${LOG_PREFIX} Failed to load nativeAutoConnectEnabled`, error);
}
return true; // Default to enabled
}
/**
* Set the nativeAutoConnectEnabled setting and persist to storage.
*/
async function setNativeAutoConnectEnabled(enabled: boolean): Promise {
autoConnectEnabled = enabled;
autoConnectLoaded = true;
try {
await chrome.storage.local.set({ [STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]: enabled });
console.debug(`${LOG_PREFIX} Set nativeAutoConnectEnabled=${enabled}`);
} catch (error) {
console.warn(`${LOG_PREFIX} Failed to persist nativeAutoConnectEnabled`, error);
}
syncKeepaliveHold();
}
// ==================== Port Preference ====================
/**
* Get the preferred port for connecting to native server.
* Priority: explicit override > user preference > last known port > default
*/
async function getPreferredPort(override?: unknown): Promise {
const explicit = normalizePort(override);
if (explicit) return explicit;
try {
const result = await chrome.storage.local.get([
STORAGE_KEYS.NATIVE_SERVER_PORT,
STORAGE_KEYS.SERVER_STATUS,
]);
const userPort = normalizePort(result[STORAGE_KEYS.NATIVE_SERVER_PORT]);
if (userPort) return userPort;
const status = result[STORAGE_KEYS.SERVER_STATUS] as Partial | undefined;
const statusPort = normalizePort(status?.port);
if (statusPort) return statusPort;
} catch (error) {
console.warn(`${LOG_PREFIX} Failed to read preferred port`, error);
}
const inMemoryPort = normalizePort(currentServerStatus.port);
if (inMemoryPort) return inMemoryPort;
return NATIVE_HOST.DEFAULT_PORT;
}
// ==================== Reconnect Scheduling ====================
/**
* Schedule a reconnect attempt with exponential backoff.
*/
function scheduleReconnect(reason: string): void {
if (nativePort) return;
if (manualDisconnect) return;
if (!autoConnectEnabled) return;
if (reconnectTimer) return;
const delay = getReconnectDelayMs(reconnectAttempts);
console.debug(
`${LOG_PREFIX} Reconnect scheduled in ${delay}ms (attempt=${reconnectAttempts}, reason=${reason})`,
);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (nativePort) return;
if (manualDisconnect || !autoConnectEnabled) return;
reconnectAttempts += 1;
void ensureNativeConnected(`reconnect:${reason}`).catch(() => {});
}, delay);
}
// ==================== Server Status Update ====================
/**
* Mark server as stopped and broadcast the change.
*/
async function markServerStopped(reason: string): Promise {
currentServerStatus = {
isRunning: false,
port: currentServerStatus.port,
lastUpdated: Date.now(),
};
try {
await saveServerStatus(currentServerStatus);
} catch {
// Ignore
}
broadcastServerStatusChange(currentServerStatus);
console.debug(`${LOG_PREFIX} Server marked stopped (${reason})`);
}
// ==================== Core Ensure Function ====================
/**
* Ensure native connection is established.
* This is the main entry point for auto-connect logic.
*
* @param trigger - Description of what triggered this call (for logging)
* @param portOverride - Optional explicit port to use
* @returns Whether the connection is now established
*/
async function ensureNativeConnected(trigger: string, portOverride?: unknown): Promise {
// Concurrency protection: only one ensure flow at a time
if (ensurePromise) return ensurePromise;
ensurePromise = (async () => {
// Load auto-connect setting if not yet loaded
if (!autoConnectLoaded) {
autoConnectEnabled = await loadNativeAutoConnectEnabled();
autoConnectLoaded = true;
syncKeepaliveHold();
}
// If auto-connect is disabled, do nothing
if (!autoConnectEnabled) {
console.debug(`${LOG_PREFIX} Auto-connect disabled, skipping ensure (trigger=${trigger})`);
return false;
}
// Sync keepalive hold
syncKeepaliveHold();
// Already connected
if (nativePort) {
console.debug(`${LOG_PREFIX} Already connected (trigger=${trigger})`);
return true;
}
// Get the port to use
const port = await getPreferredPort(portOverride);
console.debug(`${LOG_PREFIX} Attempting connection on port ${port} (trigger=${trigger})`);
// Attempt connection
const ok = connectNativeHost(port);
if (!ok) {
console.warn(`${LOG_PREFIX} Connection failed (trigger=${trigger})`);
scheduleReconnect(`connect_failed:${trigger}`);
return false;
}
console.debug(`${LOG_PREFIX} Connection initiated successfully (trigger=${trigger})`);
// Note: Don't reset reconnect state here. Wait for SERVER_STARTED confirmation.
// Chrome may return a Port but disconnect immediately if native host is missing.
return true;
})().finally(() => {
ensurePromise = null;
});
return ensurePromise;
}
/**
* Connect to the native messaging host
* @returns Whether the connection was initiated successfully
*/
export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT): boolean {
if (nativePort) {
return true;
}
try {
nativePort = chrome.runtime.connectNative(HOST_NAME);
nativePort.onMessage.addListener(async (message) => {
if (message.type === NativeMessageType.PROCESS_DATA && message.requestId) {
const requestId = message.requestId;
const requestPayload = message.payload;
nativePort?.postMessage({
responseToRequestId: requestId,
payload: {
status: 'success',
message: SUCCESS_MESSAGES.TOOL_EXECUTED,
data: requestPayload,
},
});
} else if (message.type === NativeMessageType.CALL_TOOL && message.requestId) {
const requestId = message.requestId;
try {
const result = await handleCallTool(message.payload);
nativePort?.postMessage({
responseToRequestId: requestId,
payload: {
status: 'success',
message: SUCCESS_MESSAGES.TOOL_EXECUTED,
data: result,
},
});
} catch (error) {
nativePort?.postMessage({
responseToRequestId: requestId,
payload: {
status: 'error',
message: ERROR_MESSAGES.TOOL_EXECUTION_FAILED,
error: error instanceof Error ? error.message : String(error),
},
});
}
} else if (message.type === 'rr_list_published_flows' && message.requestId) {
const requestId = message.requestId;
try {
const published = await listPublished();
const items = [] as any[];
for (const p of published) {
const flow = await getFlow(p.id);
if (!flow) continue;
items.push({
id: p.id,
slug: p.slug,
version: p.version,
name: p.name,
description: p.description || flow.description || '',
variables: flow.variables || [],
meta: flow.meta || {},
});
}
nativePort?.postMessage({
responseToRequestId: requestId,
payload: { status: 'success', items },
});
} catch (error: any) {
nativePort?.postMessage({
responseToRequestId: requestId,
payload: { status: 'error', error: error?.message || String(error) },
});
}
} else if (message.type === NativeMessageType.SERVER_STARTED) {
const port = message.payload?.port;
currentServerStatus = {
isRunning: true,
port: port,
lastUpdated: Date.now(),
};
await saveServerStatus(currentServerStatus);
broadcastServerStatusChange(currentServerStatus);
// Server is confirmed running - now we can reset reconnect state
resetReconnectState();
console.log(`${SUCCESS_MESSAGES.SERVER_STARTED} on port ${port}`);
} else if (message.type === NativeMessageType.SERVER_STOPPED) {
currentServerStatus = {
isRunning: false,
port: currentServerStatus.port, // Keep last known port for reconnection
lastUpdated: Date.now(),
};
await saveServerStatus(currentServerStatus);
broadcastServerStatusChange(currentServerStatus);
console.log(SUCCESS_MESSAGES.SERVER_STOPPED);
} else if (message.type === NativeMessageType.ERROR_FROM_NATIVE_HOST) {
console.error('Error from native host:', message.payload?.message || 'Unknown error');
} else if (message.type === 'file_operation_response') {
// Forward file operation response back to the requesting tool
chrome.runtime.sendMessage(message).catch(() => {
// Ignore if no listeners
});
}
});
nativePort.onDisconnect.addListener(() => {
console.warn(ERROR_MESSAGES.NATIVE_DISCONNECTED, chrome.runtime.lastError);
nativePort = null;
// Mark server as stopped since native host disconnection means server is down
void markServerStopped('native_port_disconnected');
// Handle reconnection based on disconnect reason
if (manualDisconnect) {
manualDisconnect = false;
return;
}
if (!autoConnectEnabled) return;
scheduleReconnect('native_port_disconnected');
});
nativePort.postMessage({ type: NativeMessageType.START, payload: { port } });
// Note: Don't reset reconnect state here. Wait for SERVER_STARTED confirmation.
// Chrome may return a Port but disconnect immediately if native host is missing.
return true;
} catch (error) {
console.warn(ERROR_MESSAGES.NATIVE_CONNECTION_FAILED, error);
nativePort = null;
return false;
}
}
/**
* Initialize native host listeners and load initial state
*/
export const initNativeHostListener = () => {
// Initialize server status from storage
loadServerStatus()
.then((status) => {
currentServerStatus = status;
})
.catch((error) => {
console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);
});
// Auto-connect on SW activation (covers SW restart after idle termination)
void ensureNativeConnected('sw_startup').catch(() => {});
// Auto-connect on Chrome browser startup
chrome.runtime.onStartup.addListener(() => {
void ensureNativeConnected('onStartup').catch(() => {});
});
// Auto-connect on extension install/update
chrome.runtime.onInstalled.addListener(() => {
void ensureNativeConnected('onInstalled').catch(() => {});
});
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
// Allow UI to call tools directly
if (message && message.type === 'call_tool' && message.name) {
handleCallTool({ name: message.name, args: message.args })
.then((res) => sendResponse({ success: true, result: res }))
.catch((err) =>
sendResponse({ success: false, error: err instanceof Error ? err.message : String(err) }),
);
return true;
}
const msgType = typeof message === 'string' ? message : message?.type;
// ENSURE_NATIVE: Trigger ensure without changing autoConnectEnabled
if (msgType === NativeMessageType.ENSURE_NATIVE) {
const portOverride = typeof message === 'object' ? message.port : undefined;
ensureNativeConnected('ui_ensure', portOverride)
.then((connected) => {
sendResponse({ success: true, connected, autoConnectEnabled });
})
.catch((e) => {
sendResponse({ success: false, connected: nativePort !== null, error: String(e) });
});
return true;
}
// CONNECT_NATIVE: Explicit user connect, re-enables auto-connect
if (msgType === NativeMessageType.CONNECT_NATIVE) {
const portOverride = typeof message === 'object' ? message.port : undefined;
const normalized = normalizePort(portOverride);
(async () => {
// Explicit user connect: re-enable auto-connect
await setNativeAutoConnectEnabled(true);
if (normalized) {
// Best-effort: persist preferred port
try {
await chrome.storage.local.set({ [STORAGE_KEYS.NATIVE_SERVER_PORT]: normalized });
} catch {
// Ignore
}
}
return ensureNativeConnected('ui_connect', normalized ?? undefined);
})()
.then((connected) => {
sendResponse({ success: true, connected });
})
.catch((e) => {
sendResponse({ success: false, connected: nativePort !== null, error: String(e) });
});
return true;
}
if (msgType === NativeMessageType.PING_NATIVE) {
const connected = nativePort !== null;
sendResponse({ connected, autoConnectEnabled });
return true;
}
// DISCONNECT_NATIVE: Explicit user disconnect, disables auto-connect
if (msgType === NativeMessageType.DISCONNECT_NATIVE) {
(async () => {
// Explicit user disconnect: disable auto-connect and stop reconnect loop
await setNativeAutoConnectEnabled(false);
clearReconnectTimer();
reconnectAttempts = 0;
syncKeepaliveHold();
if (nativePort) {
// Only set manualDisconnect if we actually have a port to disconnect.
// This prevents the flag from persisting when there's no active connection.
manualDisconnect = true;
try {
nativePort.disconnect();
} catch {
// Ignore
}
nativePort = null;
}
await markServerStopped('manual_disconnect');
})()
.then(() => {
sendResponse({ success: true });
})
.catch((e) => {
sendResponse({ success: false, error: String(e) });
});
return true;
}
if (message.type === BACKGROUND_MESSAGE_TYPES.GET_SERVER_STATUS) {
sendResponse({
success: true,
serverStatus: currentServerStatus,
connected: nativePort !== null,
});
return true;
}
if (message.type === BACKGROUND_MESSAGE_TYPES.REFRESH_SERVER_STATUS) {
loadServerStatus()
.then((storedStatus) => {
currentServerStatus = storedStatus;
sendResponse({
success: true,
serverStatus: currentServerStatus,
connected: nativePort !== null,
});
})
.catch((error) => {
console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);
sendResponse({
success: false,
error: ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED,
serverStatus: currentServerStatus,
connected: nativePort !== null,
});
});
return true;
}
// Forward file operation messages to native host
if (message.type === 'forward_to_native' && message.message) {
if (nativePort) {
nativePort.postMessage(message.message);
sendResponse({ success: true });
} else {
sendResponse({ success: false, error: 'Native host not connected' });
}
return true;
}
});
};
================================================
FILE: app/chrome-extension/entrypoints/background/quick-panel/agent-handler.ts
================================================
/**
* Quick Panel Agent Handler
*
* Background service that bridges Quick Panel (content script) with the native-server Agent.
* Handles message routing, SSE streaming, and lifecycle management for AI chat requests.
*
* Architecture:
* - Quick Panel sends QUICK_PANEL_SEND_TO_AI via chrome.runtime.sendMessage
* - This handler subscribes to SSE first, then fires POST /act
* - Incoming RealtimeEvents are filtered by requestId and forwarded to the originating tab
* - Keepalive is explicitly managed to prevent MV3 Service Worker suspension during streaming
*
* @see https://developer.chrome.com/docs/extensions/mv3/service_workers/
*/
import type { AgentActRequest, RealtimeEvent } from 'chrome-mcp-shared';
import { NativeMessageType } from 'chrome-mcp-shared';
import { NATIVE_HOST, STORAGE_KEYS } from '@/common/constants';
import {
BACKGROUND_MESSAGE_TYPES,
TOOL_MESSAGE_TYPES,
type QuickPanelAIEventMessage,
type QuickPanelCancelAIMessage,
type QuickPanelCancelAIResponse,
type QuickPanelSendToAIMessage,
type QuickPanelSendToAIResponse,
} from '@/common/message-types';
import { acquireKeepalive } from '../keepalive-manager';
import { openAgentChatSidepanel } from '../utils/sidepanel';
// ============================================================
// Constants
// ============================================================
const LOG_PREFIX = '[QuickPanelAgent]';
const KEEPALIVE_TAG = 'quick-panel-ai';
/** Storage key for AgentChat selected session ID (owned by sidepanel composables) */
const STORAGE_KEY_SELECTED_SESSION = 'agent-selected-session-id';
/** Timeout for initial SSE connection establishment */
const SSE_CONNECT_TIMEOUT_MS = 3000;
/** Safety timeout for entire request lifecycle (15 minutes) */
const REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
/** Flag indicating SSE connection was successful */
const SSE_CONNECTED = Symbol('SSE_CONNECTED');
/** Flag indicating SSE connection timed out but we should continue */
const SSE_TIMEOUT = Symbol('SSE_TIMEOUT');
// ============================================================
// Types
// ============================================================
/**
* Represents an active streaming request from Quick Panel.
*
* Background maintains this state to:
* 1. Route SSE events to the correct tab
* 2. Manage keepalive lifecycle
* 3. Handle cancellation and cleanup
*/
interface ActiveRequest {
readonly requestId: string;
readonly sessionId: string;
readonly instruction: string;
readonly tabId: number;
readonly windowId?: number;
readonly frameId?: number;
readonly port: number;
readonly createdAt: number;
readonly abortController: AbortController;
readonly releaseKeepalive: () => void;
readonly timeoutId: ReturnType;
}
// ============================================================
// State
// ============================================================
/** Active streaming requests indexed by requestId */
const activeRequests = new Map();
/** Initialization flag to prevent duplicate listeners */
let initialized = false;
// ============================================================
// Utility Functions
// ============================================================
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function normalizePort(value: unknown): number | null {
const num =
typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;
if (!Number.isFinite(num)) return null;
const port = Math.floor(num);
if (port <= 0 || port > 65535) return null;
return port;
}
function createRequestId(): string {
// Prefer crypto.randomUUID for proper UUID format
try {
const id = crypto?.randomUUID?.();
if (id) return id;
} catch {
// Fallback for environments without crypto.randomUUID
}
return `req_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isTerminalStatus(status: string): boolean {
return status === 'completed' || status === 'error' || status === 'cancelled';
}
// ============================================================
// Event Factories
// ============================================================
function createErrorEvent(sessionId: string, requestId: string, error: string): RealtimeEvent {
return {
type: 'error',
error: error || 'Unknown error',
data: { sessionId, requestId },
};
}
function createCancelledStatusEvent(
sessionId: string,
requestId: string,
message?: string,
): RealtimeEvent {
return {
type: 'status',
data: {
sessionId,
status: 'cancelled',
requestId,
message: message || 'Cancelled by user',
},
};
}
// ============================================================
// Event Forwarding
// ============================================================
/**
* Forward a RealtimeEvent to the Quick Panel in the originating tab.
* Handles receiver unavailability gracefully by cleaning up the request.
*/
function forwardEventToQuickPanel(request: ActiveRequest, event: RealtimeEvent): void {
const message: QuickPanelAIEventMessage = {
action: TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT,
requestId: request.requestId,
sessionId: request.sessionId,
event,
};
const sendOptions =
typeof request.frameId === 'number' ? { frameId: request.frameId } : undefined;
const sendPromise = sendOptions
? chrome.tabs.sendMessage(request.tabId, message, sendOptions)
: chrome.tabs.sendMessage(request.tabId, message);
sendPromise.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
// Detect receiver unavailability (tab closed, navigated, Quick Panel closed)
const receiverGone =
msg.includes('Receiving end does not exist') ||
msg.includes('No tab with id') ||
msg.includes('The message port closed');
if (receiverGone) {
cleanupRequest(request.requestId, 'receiver_unavailable');
}
});
}
// ============================================================
// Request Lifecycle Management
// ============================================================
/**
* Clean up an active request and release all associated resources.
* Idempotent - safe to call multiple times.
*/
function cleanupRequest(requestId: string, reason: string): void {
const request = activeRequests.get(requestId);
if (!request) return;
activeRequests.delete(requestId);
// Clear timeout
try {
clearTimeout(request.timeoutId);
} catch {
// Ignore
}
// Abort SSE connection
try {
request.abortController.abort();
} catch {
// Ignore
}
// Release keepalive
try {
request.releaseKeepalive();
} catch {
// Ignore
}
console.debug(`${LOG_PREFIX} Cleaned up request ${requestId} (${reason})`);
}
// ============================================================
// Session Validation
// ============================================================
/**
* Validate that the selected session exists on the native server.
* Returns false if the session is invalid or server is unreachable.
*/
async function validateSession(port: number, sessionId: string): Promise {
const url = `http://127.0.0.1:${port}/agent/sessions/${encodeURIComponent(sessionId)}`;
try {
const response = await fetch(url);
return response.ok;
} catch {
return false;
}
}
// ============================================================
// SSE Event Filtering
// ============================================================
/**
* Determine if a RealtimeEvent should be forwarded for a specific requestId.
*
* Events without requestId (connected, heartbeat) are session-level signals
* and are not forwarded to avoid confusion with request-specific events.
*/
function shouldForwardEvent(event: RealtimeEvent, requestId: string): boolean {
switch (event.type) {
case 'message':
return event.data?.requestId === requestId;
case 'status':
return event.data?.requestId === requestId;
case 'usage':
return event.data?.requestId === requestId;
case 'error':
return event.data?.requestId === requestId;
case 'connected':
case 'heartbeat':
// Session-level signals, not request-scoped
return false;
default:
return false;
}
}
// ============================================================
// SSE Subscription
// ============================================================
interface SseSubscription {
/**
* Resolves with true when SSE connection is established.
* Resolves with false if connection failed (request was cleaned up).
*/
ready: Promise;
/** Resolves when SSE stream ends (normally or due to error/abort) */
done: Promise;
}
/**
* Create an SSE subscription for the request's session.
*
* The subscription:
* 1. Connects to the session's /stream endpoint
* 2. Filters events by requestId
* 3. Forwards matching events to Quick Panel
* 4. Triggers cleanup on terminal status
*
* @returns SseSubscription with ready promise that resolves to:
* - true: SSE connected successfully
* - false: SSE failed (request was cleaned up, don't send /act)
*/
function createSseSubscription(request: ActiveRequest): SseSubscription {
// Track whether ready has been resolved
let readySettled = false;
let readyResolve: (connected: boolean) => void;
const ready = new Promise((resolve) => {
readyResolve = resolve;
});
// Helper to resolve ready exactly once
const settleReady = (connected: boolean): void => {
if (readySettled) return;
readySettled = true;
readyResolve(connected);
};
const done = (async () => {
const sseUrl = `http://127.0.0.1:${request.port}/agent/chat/${encodeURIComponent(request.sessionId)}/stream`;
try {
const response = await fetch(sseUrl, {
method: 'GET',
headers: { Accept: 'text/event-stream' },
signal: request.abortController.signal,
});
if (!response.ok || !response.body) {
throw new Error(`SSE stream unavailable (HTTP ${response.status})`);
}
// Signal that SSE is connected successfully
settleReady(true);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// Read and parse SSE stream
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const raw = line.slice(5).trim();
if (!raw) continue;
try {
const event = JSON.parse(raw) as RealtimeEvent;
// Filter by requestId to prevent cross-request leakage
if (!shouldForwardEvent(event, request.requestId)) {
continue;
}
forwardEventToQuickPanel(request, event);
// Cleanup on terminal status
if (event.type === 'status' && event.data?.requestId === request.requestId) {
if (isTerminalStatus(event.data.status)) {
cleanupRequest(request.requestId, `terminal_status:${event.data.status}`);
return;
}
}
} catch {
// Ignore parse errors (best-effort stream processing)
}
}
}
} catch (err) {
// AbortError is intentional (cancellation or cleanup)
if (err instanceof Error && err.name === 'AbortError') {
// Signal not connected if aborted before connecting
settleReady(false);
return;
}
// Surface error to UI and cleanup if request is still active
if (activeRequests.has(request.requestId)) {
const msg = err instanceof Error ? err.message : String(err);
forwardEventToQuickPanel(
request,
createErrorEvent(request.sessionId, request.requestId, msg),
);
cleanupRequest(request.requestId, 'sse_error');
}
// Signal failed connection
settleReady(false);
}
})();
return { ready, done };
}
// ============================================================
// Agent API
// ============================================================
/**
* Send the act request to native-server.
* The server will emit events via SSE which are already being subscribed.
*
* @param request - Active request context
* @throws Error if request was cancelled/aborted or HTTP request fails
*/
async function postActRequest(request: ActiveRequest): Promise {
// Check if request was cancelled before sending
if (request.abortController.signal.aborted) {
throw new Error('Request was cancelled');
}
const url = `http://127.0.0.1:${request.port}/agent/chat/${encodeURIComponent(request.sessionId)}/act`;
const payload: AgentActRequest = {
instruction: request.instruction,
// Ensures session-level config is loaded (engine, model, options, project binding)
dbSessionId: request.sessionId,
// Enables SSE-first flow and requestId filtering on session-scoped streams
requestId: request.requestId,
};
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: request.abortController.signal,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `HTTP ${response.status}`);
}
}
/**
* Cancel an active request on the native-server.
*/
async function cancelRequestOnServer(
port: number,
sessionId: string,
requestId: string,
): Promise {
const url = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/cancel/${encodeURIComponent(requestId)}`;
try {
await fetch(url, { method: 'DELETE' });
} catch {
// Best-effort: cancellation might still succeed if request already ended
}
}
// ============================================================
// Request Orchestration
// ============================================================
/**
* Check if the request is still active and not cancelled.
* Used as a guard before each async operation to handle race conditions.
*/
function isRequestStillActive(request: ActiveRequest): boolean {
return activeRequests.has(request.requestId) && !request.abortController.signal.aborted;
}
/**
* Main orchestration function for starting a Quick Panel AI request.
*
* Flow:
* 1. Ensure native server is running
* 2. Validate session exists
* 3. Open sidepanel (best-effort)
* 4. Start SSE subscription (wait for connection)
* 5. Fire act request
* 6. Let SSE handle event forwarding and cleanup
*
* @remarks
* Guards are placed after each async operation to handle cancellation races.
*/
async function startRequest(request: ActiveRequest): Promise {
try {
// Best-effort: ensure native server is running
await chrome.runtime.sendMessage({ type: NativeMessageType.ENSURE_NATIVE }).catch(() => null);
// Guard: check if cancelled during ENSURE_NATIVE
if (!isRequestStillActive(request)) return;
// Validate session still exists
const sessionValid = await validateSession(request.port, request.sessionId);
// Guard: check if cancelled during validation
if (!isRequestStillActive(request)) return;
if (!sessionValid) {
forwardEventToQuickPanel(
request,
createErrorEvent(
request.sessionId,
request.requestId,
'Selected Agent session is not available. Please open AgentChat and select a valid session.',
),
);
// Open sidepanel without deep-linking to invalid session
openAgentChatSidepanel(request.tabId, request.windowId).catch(() => {});
cleanupRequest(request.requestId, 'session_invalid');
return;
}
// Best-effort: open sidepanel deep-linked to current session
openAgentChatSidepanel(request.tabId, request.windowId, request.sessionId).catch(() => {});
// Start SSE subscription BEFORE sending act request to avoid missing early events
const sse = createSseSubscription(request);
// Wait for SSE connection with timeout
// The race returns either:
// - boolean from sse.ready (true=connected, false=failed)
// - undefined from timeout (treat as "proceed with caution")
const sseResult = await Promise.race([
sse.ready,
sleep(SSE_CONNECT_TIMEOUT_MS).then(() => SSE_TIMEOUT),
]);
// Guard: check if cancelled during SSE connection
if (!isRequestStillActive(request)) return;
// If SSE explicitly failed (returned false), don't send /act
// The SSE subscription already cleaned up and sent error to UI
if (sseResult === false) {
console.debug(`${LOG_PREFIX} SSE failed for ${request.requestId}, not sending /act`);
return;
}
// If SSE timed out, log warning but continue (degraded experience)
if (sseResult === SSE_TIMEOUT) {
console.warn(
`${LOG_PREFIX} SSE connection timed out for ${request.requestId}, proceeding anyway`,
);
}
// Fire the act request
await postActRequest(request);
// SSE subscription continues running and will handle cleanup on terminal status
void sse.done;
} catch (err) {
// Abort errors are expected during cancellation
if (err instanceof Error && err.name === 'AbortError') {
return;
}
// Request may have been cleaned up already
if (!activeRequests.has(request.requestId)) return;
const msg = err instanceof Error ? err.message : String(err);
forwardEventToQuickPanel(request, createErrorEvent(request.sessionId, request.requestId, msg));
cleanupRequest(request.requestId, 'start_failed');
}
}
// ============================================================
// Message Handlers
// ============================================================
/**
* Handle QUICK_PANEL_SEND_TO_AI message.
* Creates a new streaming request and starts the orchestration flow.
*/
async function handleSendToAI(
message: QuickPanelSendToAIMessage,
sender: chrome.runtime.MessageSender,
): Promise {
const tabId = sender?.tab?.id;
const windowId = sender?.tab?.windowId;
const frameId = typeof sender?.frameId === 'number' ? sender.frameId : undefined;
if (typeof tabId !== 'number') {
return { success: false, error: 'Quick Panel request must originate from a tab.' };
}
const instruction = normalizeString(message?.payload?.instruction).trim();
if (!instruction) {
return { success: false, error: 'instruction is required' };
}
// Read server port and selected session from storage
const stored = await chrome.storage.local.get([
STORAGE_KEYS.NATIVE_SERVER_PORT,
STORAGE_KEY_SELECTED_SESSION,
]);
const port = normalizePort(stored?.[STORAGE_KEYS.NATIVE_SERVER_PORT]) ?? NATIVE_HOST.DEFAULT_PORT;
const sessionId = normalizeString(stored?.[STORAGE_KEY_SELECTED_SESSION]).trim();
if (!sessionId) {
// No session selected: open sidepanel for user to select/create one
openAgentChatSidepanel(tabId, windowId).catch(() => {});
return {
success: false,
error:
'No Agent session selected. Please open AgentChat, select or create a session, then try again.',
};
}
// Create request state
const requestId = createRequestId();
const releaseKeepalive = acquireKeepalive(KEEPALIVE_TAG);
const abortController = new AbortController();
// Safety timeout to prevent infinite streaming
const timeoutId = setTimeout(() => {
const activeRequest = activeRequests.get(requestId);
if (!activeRequest) return;
forwardEventToQuickPanel(
activeRequest,
createErrorEvent(
activeRequest.sessionId,
activeRequest.requestId,
'Quick Panel stream timed out. Please continue in AgentChat sidepanel.',
),
);
cleanupRequest(requestId, 'timeout');
}, REQUEST_TIMEOUT_MS);
const request: ActiveRequest = {
requestId,
sessionId,
instruction,
tabId,
windowId: typeof windowId === 'number' ? windowId : undefined,
frameId,
port,
createdAt: Date.now(),
abortController,
releaseKeepalive,
timeoutId,
};
activeRequests.set(requestId, request);
// Start the request asynchronously (don't await)
void startRequest(request);
return { success: true, requestId, sessionId };
}
/**
* Handle QUICK_PANEL_CANCEL_AI message.
* Cancels an active request both locally and on the server.
*/
async function handleCancelAI(
message: QuickPanelCancelAIMessage,
sender: chrome.runtime.MessageSender,
): Promise {
const tabId = sender?.tab?.id;
const frameId = typeof sender?.frameId === 'number' ? sender.frameId : undefined;
if (typeof tabId !== 'number') {
return { success: false, error: 'Cancel request must originate from a tab.' };
}
const requestId = normalizeString(message?.payload?.requestId).trim();
const fallbackSessionId = normalizeString(message?.payload?.sessionId).trim();
if (!requestId) {
return { success: false, error: 'requestId is required' };
}
const activeRequest = activeRequests.get(requestId);
const sessionId = activeRequest?.sessionId || fallbackSessionId;
if (!sessionId) {
return {
success: false,
error: 'Unknown sessionId for this request. Please cancel from AgentChat sidepanel.',
};
}
// Abort SSE immediately for responsive UX
if (activeRequest) {
try {
activeRequest.abortController.abort();
} catch {
// Ignore
}
}
// Determine port
let port = activeRequest?.port;
if (!port) {
const stored = await chrome.storage.local.get([STORAGE_KEYS.NATIVE_SERVER_PORT]);
port = normalizePort(stored?.[STORAGE_KEYS.NATIVE_SERVER_PORT]) ?? NATIVE_HOST.DEFAULT_PORT;
}
// Cancel on server (async, don't await)
void cancelRequestOnServer(port, sessionId, requestId);
// Send synthetic cancelled status to UI
const cancelledEvent = createCancelledStatusEvent(sessionId, requestId);
const eventMessage: QuickPanelAIEventMessage = {
action: TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT,
requestId,
sessionId,
event: cancelledEvent,
};
const sendOptions = typeof frameId === 'number' ? { frameId } : undefined;
const sendPromise = sendOptions
? chrome.tabs.sendMessage(tabId, eventMessage, sendOptions)
: chrome.tabs.sendMessage(tabId, eventMessage);
sendPromise
.catch(() => {})
.finally(() => {
cleanupRequest(requestId, 'cancelled_by_user');
});
return { success: true };
}
// ============================================================
// Initialization
// ============================================================
/**
* Initialize the Quick Panel Agent Handler.
* Sets up message listeners and tab cleanup handlers.
*/
export function initQuickPanelAgentHandler(): void {
if (initialized) return;
initialized = true;
// Message listener for Quick Panel messages
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Handle QUICK_PANEL_SEND_TO_AI
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_SEND_TO_AI) {
handleSendToAI(message as QuickPanelSendToAIMessage, sender)
.then(sendResponse)
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
sendResponse({ success: false, error: msg || 'Unknown error' });
});
return true; // Async response
}
// Handle QUICK_PANEL_CANCEL_AI
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CANCEL_AI) {
handleCancelAI(message as QuickPanelCancelAIMessage, sender)
.then(sendResponse)
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
sendResponse({ success: false, error: msg || 'Unknown error' });
});
return true; // Async response
}
return false;
});
// Clean up requests when their tab is closed
chrome.tabs.onRemoved.addListener((tabId) => {
for (const [requestId, request] of activeRequests) {
if (request.tabId === tabId) {
cleanupRequest(requestId, 'tab_removed');
}
}
});
console.debug(`${LOG_PREFIX} Initialized`);
}
================================================
FILE: app/chrome-extension/entrypoints/background/quick-panel/commands.ts
================================================
/**
* Quick Panel Commands Handler
*
* Handles keyboard shortcuts for Quick Panel functionality.
* Listens for the 'toggle_quick_panel' command and sends toggle message
* to the content script in the active tab.
*/
// ============================================================
// Constants
// ============================================================
const COMMAND_KEY = 'toggle_quick_panel';
const LOG_PREFIX = '[QuickPanelCommands]';
// ============================================================
// Helpers
// ============================================================
/**
* Get the ID of the currently active tab
*/
async function getActiveTabId(): Promise {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab?.id ?? null;
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to get active tab:`, err);
return null;
}
}
/**
* Check if a tab can receive content scripts
*/
function isValidTabUrl(url?: string): boolean {
if (!url) return false;
// Cannot inject into browser internal pages
const invalidPrefixes = [
'chrome://',
'chrome-extension://',
'edge://',
'about:',
'moz-extension://',
'devtools://',
'view-source:',
'data:',
// 'file://',
];
return !invalidPrefixes.some((prefix) => url.startsWith(prefix));
}
// ============================================================
// Main Handler
// ============================================================
/**
* Toggle Quick Panel in the active tab
*/
async function toggleQuickPanelInActiveTab(): Promise {
const tabId = await getActiveTabId();
if (tabId === null) {
console.warn(`${LOG_PREFIX} No active tab found`);
return;
}
// Get tab info to check URL validity
try {
const tab = await chrome.tabs.get(tabId);
if (!isValidTabUrl(tab.url)) {
console.warn(`${LOG_PREFIX} Cannot inject into tab URL: ${tab.url}`);
return;
}
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to get tab info:`, err);
return;
}
// Send toggle message to content script
try {
const response = await chrome.tabs.sendMessage(tabId, { action: 'toggle_quick_panel' });
if (response?.success) {
console.log(`${LOG_PREFIX} Quick Panel toggled, visible: ${response.visible}`);
} else {
console.warn(`${LOG_PREFIX} Toggle failed:`, response?.error);
}
} catch (err) {
// Content script may not be loaded yet; this is expected on some pages
console.warn(
`${LOG_PREFIX} Failed to send toggle message (content script may not be loaded):`,
err,
);
}
}
// ============================================================
// Initialization
// ============================================================
/**
* Initialize Quick Panel keyboard command listener
*/
export function initQuickPanelCommands(): void {
console.log(`${LOG_PREFIX} initQuickPanelCommands called`);
chrome.commands.onCommand.addListener(async (command) => {
console.log(`${LOG_PREFIX} onCommand received:`, command);
if (command !== COMMAND_KEY) {
console.log(`${LOG_PREFIX} Command not matched, expected:`, COMMAND_KEY);
return;
}
console.log(`${LOG_PREFIX} Command matched, calling toggleQuickPanelInActiveTab...`);
try {
await toggleQuickPanelInActiveTab();
console.log(`${LOG_PREFIX} toggleQuickPanelInActiveTab completed`);
} catch (err) {
console.error(`${LOG_PREFIX} Command handler error:`, err);
}
});
console.log(`${LOG_PREFIX} Command listener registered for: ${COMMAND_KEY}`);
}
================================================
FILE: app/chrome-extension/entrypoints/background/quick-panel/tabs-handler.ts
================================================
/**
* Quick Panel Tabs Handler
*
* Background service worker bridge for Quick Panel (content script) to:
* - Enumerate tabs for search suggestions
* - Activate a selected tab
* - Close a tab
*
* Note: Content scripts cannot access chrome.tabs.* directly.
*/
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelActivateTabMessage,
type QuickPanelActivateTabResponse,
type QuickPanelCloseTabMessage,
type QuickPanelCloseTabResponse,
type QuickPanelTabSummary,
type QuickPanelTabsQueryMessage,
type QuickPanelTabsQueryResponse,
} from '@/common/message-types';
// ============================================================
// Constants
// ============================================================
const LOG_PREFIX = '[QuickPanelTabs]';
// ============================================================
// Helpers
// ============================================================
function isValidTabId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function isValidWindowId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function normalizeBoolean(value: unknown): boolean {
return value === true;
}
function getLastAccessed(tab: chrome.tabs.Tab): number | undefined {
const anyTab = tab as unknown as { lastAccessed?: unknown };
const value = anyTab.lastAccessed;
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message || String(err);
}
return String(err);
}
/**
* Convert a chrome.tabs.Tab to our summary format.
* Returns null if tab is invalid.
*/
function toTabSummary(tab: chrome.tabs.Tab): QuickPanelTabSummary | null {
if (!isValidTabId(tab.id)) return null;
const windowId = isValidWindowId(tab.windowId) ? tab.windowId : null;
if (windowId === null) return null;
return {
tabId: tab.id,
windowId,
title: tab.title ?? '',
url: tab.url ?? '',
favIconUrl: tab.favIconUrl ?? undefined,
active: normalizeBoolean(tab.active),
pinned: normalizeBoolean(tab.pinned),
audible: normalizeBoolean(tab.audible),
muted: normalizeBoolean(tab.mutedInfo?.muted),
index: typeof tab.index === 'number' && Number.isFinite(tab.index) ? tab.index : 0,
lastAccessed: getLastAccessed(tab),
};
}
// ============================================================
// Message Handlers
// ============================================================
async function handleTabsQuery(
message: QuickPanelTabsQueryMessage,
sender: chrome.runtime.MessageSender,
): Promise {
try {
const includeAllWindows = message.payload?.includeAllWindows ?? true;
// Extract current context from sender
const currentWindowId = isValidWindowId(sender.tab?.windowId) ? sender.tab!.windowId : null;
const currentTabId = isValidTabId(sender.tab?.id) ? sender.tab!.id : null;
// Quick Panel should only be called from content scripts (which have sender.tab)
// Reject requests without valid sender tab context for security
if (!includeAllWindows && currentWindowId === null) {
return {
success: false,
error: 'Invalid request: sender tab context required for window-scoped queries',
};
}
// Build query info based on scope
const queryInfo: chrome.tabs.QueryInfo = includeAllWindows
? {}
: { windowId: currentWindowId! };
const tabs = await chrome.tabs.query(queryInfo);
// Convert to summaries, filtering out invalid tabs
const summaries: QuickPanelTabSummary[] = [];
for (const tab of tabs) {
const summary = toTabSummary(tab);
if (summary) {
summaries.push(summary);
}
}
return {
success: true,
tabs: summaries,
currentTabId,
currentWindowId,
};
} catch (err) {
console.warn(`${LOG_PREFIX} Error querying tabs:`, err);
return {
success: false,
error: safeErrorMessage(err) || 'Failed to query tabs',
};
}
}
async function handleActivateTab(
message: QuickPanelActivateTabMessage,
): Promise {
try {
const tabId = message.payload?.tabId;
const windowId = message.payload?.windowId;
if (!isValidTabId(tabId)) {
return { success: false, error: 'Invalid tabId' };
}
// Focus the window first if provided
if (isValidWindowId(windowId)) {
try {
await chrome.windows.update(windowId, { focused: true });
} catch {
// Best-effort: tab activation may still succeed without focusing window.
}
}
// Activate the tab
await chrome.tabs.update(tabId, { active: true });
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Error activating tab:`, err);
return {
success: false,
error: safeErrorMessage(err) || 'Failed to activate tab',
};
}
}
async function handleCloseTab(
message: QuickPanelCloseTabMessage,
): Promise {
try {
const tabId = message.payload?.tabId;
if (!isValidTabId(tabId)) {
return { success: false, error: 'Invalid tabId' };
}
await chrome.tabs.remove(tabId);
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Error closing tab:`, err);
return {
success: false,
error: safeErrorMessage(err) || 'Failed to close tab',
};
}
}
// ============================================================
// Initialization
// ============================================================
let initialized = false;
/**
* Initialize the Quick Panel Tabs handler.
* Safe to call multiple times - subsequent calls are no-ops.
*/
export function initQuickPanelTabsHandler(): void {
if (initialized) return;
initialized = true;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Tabs query
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TABS_QUERY) {
handleTabsQuery(message as QuickPanelTabsQueryMessage, sender).then(sendResponse);
return true; // Will respond asynchronously
}
// Tab activate
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE) {
handleActivateTab(message as QuickPanelActivateTabMessage).then(sendResponse);
return true;
}
// Tab close
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE) {
handleCloseTab(message as QuickPanelCloseTabMessage).then(sendResponse);
return true;
}
return false; // Not handled by this listener
});
console.debug(`${LOG_PREFIX} Initialized`);
}
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts
================================================
/**
* Adapter Layer: Step ↔ Action
*
* Provides conversion utilities between the legacy Step system and the new Action system.
* This adapter enables gradual migration while maintaining backward compatibility.
*
* Architecture:
* - `stepToAction`: Converts a Step to an ExecutableAction
* - `execCtxToActionCtx`: Converts ExecCtx to ActionExecutionContext
* - `actionResultToExecResult`: Converts ActionExecutionResult to ExecResult
* - `createStepExecutor`: Factory for a Step executor backed by ActionRegistry
*/
import type { ExecCtx, ExecResult } from '../nodes/types';
import type { Step } from '../types';
import type { ActionRegistry } from './registry';
import type {
ActionExecutionContext,
ActionExecutionResult,
ActionPolicy,
ExecutableAction,
ExecutableActionType,
ExecutionFlags,
VariableStore,
} from './types';
// ================================
// Type Mapping
// ================================
/**
* Map legacy step types to new action types
* Most types map 1:1, but some require special handling
*/
const STEP_TYPE_TO_ACTION_TYPE: Record = {
// Interaction
click: 'click',
dblclick: 'dblclick',
fill: 'fill',
key: 'key',
scroll: 'scroll',
drag: 'drag',
// Timing
wait: 'wait',
delay: 'delay',
// Validation
assert: 'assert',
// Data
extract: 'extract',
script: 'script',
http: 'http',
screenshot: 'screenshot',
// Navigation / Tabs
navigate: 'navigate',
openTab: 'openTab',
switchTab: 'switchTab',
closeTab: 'closeTab',
handleDownload: 'handleDownload',
// Control Flow
if: 'if',
foreach: 'foreach',
while: 'while',
switchFrame: 'switchFrame',
// TODO: Add when handlers are implemented
// triggerEvent: 'triggerEvent',
// setAttribute: 'setAttribute',
// loopElements: 'loopElements',
// executeFlow: 'executeFlow',
};
// ================================
// Context Conversion
// ================================
/**
* Convert legacy ExecCtx to ActionExecutionContext
*/
export function execCtxToActionCtx(
ctx: ExecCtx,
tabId: number,
options?: {
stepId?: string;
runId?: string;
pushLog?: (entry: unknown) => void;
/** Execution flags to pass to action handlers */
execution?: ExecutionFlags;
},
): ActionExecutionContext {
// Use provided stepId for proper log attribution, fallback to 'action' only if not provided
const logStepId = options?.stepId || 'action';
return {
vars: ctx.vars as VariableStore,
tabId,
frameId: ctx.frameId,
runId: options?.runId,
log: (message: string, level?: 'info' | 'warn' | 'error') => {
ctx.logger({
stepId: logStepId,
status: level === 'error' ? 'failed' : level === 'warn' ? 'warning' : 'success',
message,
});
},
pushLog: options?.pushLog,
execution: options?.execution,
};
}
// ================================
// Step → Action Conversion
// ================================
/**
* Convert a legacy Step to an ExecutableAction
*
* The conversion maps step properties to action params and policy.
* Unknown step types are passed through as-is for forward compatibility.
*/
export function stepToAction(step: Step): ExecutableAction | null {
const actionType = STEP_TYPE_TO_ACTION_TYPE[step.type];
if (!actionType) {
// Unsupported step type
return null;
}
// Build policy if step has timeout or retry config
let policy: ActionPolicy | undefined;
if (step.timeoutMs || step.retry) {
policy = {};
if (step.timeoutMs) {
policy.timeout = { ms: step.timeoutMs };
}
if (step.retry) {
policy.retry = {
retries: step.retry.count ?? 0,
intervalMs: step.retry.intervalMs ?? 0,
// Step backoff only supports 'none' | 'exp', map to Action backoff type
backoff: step.retry.backoff === 'exp' ? 'exp' : 'none',
};
}
}
// Build base action - use type assertion for generic action
// Note: Step doesn't have name/disabled at base level, they are on NodeBase
const action = {
id: step.id,
type: actionType,
params: extractParams(step),
policy,
} as ExecutableAction;
return action;
}
/**
* Legacy SelectorCandidate format: { type, value, weight? }
* Action SelectorCandidate format: { type, selector/xpath/text/etc, weight? }
*/
interface LegacySelectorCandidate {
type: string;
value: string;
weight?: number;
}
interface LegacyTargetLocator {
ref?: string;
candidates: LegacySelectorCandidate[];
// Additional fields from recorder
selector?: string;
tag?: string;
}
/**
* Parse legacy ARIA value format
* Formats:
* - "role[name=...]" (e.g., "button[name=\"Submit\"]")
* - "aria-label=..." (role-less, just name)
*/
function parseAriaValue(value: string): { role?: string; name: string } {
// Try "role[name=...]" format
const roleMatch = value.match(/^([a-zA-Z]+)\[name=["']?(.+?)["']?\]$/);
if (roleMatch) {
return { role: roleMatch[1], name: roleMatch[2] };
}
// Try "aria-label=..." format
const labelMatch = value.match(/^aria-label=["']?(.+?)["']?$/);
if (labelMatch) {
return { name: labelMatch[1] };
}
// Fallback: treat entire value as name
return { name: value };
}
/**
* Convert legacy SelectorCandidate to Action SelectorCandidate
*/
function convertSelectorCandidate(legacy: LegacySelectorCandidate): Record {
const base: Record = { type: legacy.type };
if (typeof legacy.weight === 'number') {
base.weight = legacy.weight;
}
switch (legacy.type) {
case 'css':
case 'attr':
// CSS and attr use 'selector' field
base.selector = legacy.value;
break;
case 'xpath':
// XPath uses 'xpath' field
base.xpath = legacy.value;
break;
case 'text':
// Text uses 'text' field
base.text = legacy.value;
break;
case 'aria': {
// ARIA: parse "role[name=...]" or "aria-label=..." format
const parsed = parseAriaValue(legacy.value);
if (parsed.role) {
base.role = parsed.role;
}
base.name = parsed.name;
break;
}
default:
// Unknown type, pass through as-is
base.value = legacy.value;
}
return base;
}
/**
* Convert legacy TargetLocator to Action ElementTarget
* Preserves additional fields like selector and tag for locator optimization
*/
function convertTargetLocator(target: LegacyTargetLocator): Record {
const result: Record = {};
if (target.ref) {
result.ref = target.ref;
}
// Preserve selector field for fast-path (e.g., #id selectors)
if (typeof target.selector === 'string' && target.selector.trim()) {
result.selector = target.selector;
}
// Preserve tag hint for text/aria matching
if (typeof target.tag === 'string' && target.tag.trim()) {
result.hint = { tagName: target.tag };
}
if (Array.isArray(target.candidates) && target.candidates.length > 0) {
result.candidates = target.candidates.map(convertSelectorCandidate);
}
return result;
}
/**
* Check if a value looks like a legacy TargetLocator that needs conversion
*
* Detection criteria:
* 1. Must be an object with candidates array
* 2. Candidates must use legacy format (has 'value' field, not 'selector'/'xpath'/'text')
*
* This prevents double-conversion of already-converted Action format targets.
*/
function isLegacyTargetLocator(value: unknown): value is LegacyTargetLocator {
if (!value || typeof value !== 'object') return false;
const obj = value as Record;
// Must have candidates array
if (!Array.isArray(obj.candidates)) {
// If only has ref without candidates, check if it's legacy format
return typeof obj.ref === 'string' && !obj.hint;
}
// Check first candidate to determine format
const firstCandidate = obj.candidates[0];
if (!firstCandidate || typeof firstCandidate !== 'object') {
return false;
}
const candidate = firstCandidate as Record;
// Legacy format uses 'value' field
// Action format uses 'selector', 'xpath', 'text', etc. (NOT 'value')
const hasValueField = typeof candidate.value === 'string';
const hasActionFields =
typeof candidate.selector === 'string' ||
typeof candidate.xpath === 'string' ||
typeof candidate.text === 'string' ||
typeof candidate.name === 'string';
// It's legacy if it has 'value' field and doesn't have action-specific fields
return hasValueField && !hasActionFields;
}
/**
* Extract action params from step
* Each step type has its own param structure
*
* This function also converts legacy data structures to Action-compatible formats:
* - TargetLocator.candidates: { type, value } -> { type, selector/xpath/text }
*/
function extractParams(step: Step): Record {
// The step already contains params inline, so we extract them
// excluding common fields that go into the action base
// Use unknown first to satisfy TypeScript's type narrowing
const stepObj = step as unknown as Record;
const { id, type, timeoutMs, retry, screenshotOnFail, ...params } = stepObj;
// Convert TargetLocator fields if present
const converted: Record = {};
for (const [key, value] of Object.entries(params)) {
if (key === 'target' && isLegacyTargetLocator(value)) {
converted[key] = convertTargetLocator(value);
} else if (key === 'start' && isLegacyTargetLocator(value)) {
// For drag step
converted[key] = convertTargetLocator(value);
} else if (key === 'end' && isLegacyTargetLocator(value)) {
// For drag step
converted[key] = convertTargetLocator(value);
} else {
converted[key] = value;
}
}
return converted;
}
// ================================
// Result Conversion
// ================================
/**
* Convert ActionExecutionResult to legacy ExecResult
*/
export function actionResultToExecResult(result: ActionExecutionResult): ExecResult {
const execResult: ExecResult = {};
// Map nextLabel for control flow
if (result.nextLabel) {
execResult.nextLabel = result.nextLabel;
}
// Map control directives
if (result.control) {
execResult.control = result.control;
}
// If action already handled logging, mark it
if (result.status === 'success') {
execResult.alreadyLogged = false; // Let StepRunner handle logging
}
return execResult;
}
// ================================
// Executor Factory
// ================================
/**
* Result from attempting to execute a step via actions
*/
export type StepExecutionAttempt =
| { supported: true; result: ExecResult }
| { supported: false; reason: string };
/**
* Options for step executor
*/
export interface StepExecutorOptions {
runId?: string;
pushLog?: (entry: unknown) => void;
/**
* If true, throws on unsupported step types instead of returning { supported: false }
* Use this in strict mode where all steps must go through ActionRegistry
*/
strict?: boolean;
/**
* Skip ActionRegistry retry policy.
* When true, the action's retry policy is removed before execution.
* Use this when StepRunner already handles retry via withRetry().
*/
skipRetry?: boolean;
/**
* Skip navigation waiting inside action handlers.
* When true, handlers like click/navigate skip their internal nav-wait logic.
* Use this when StepRunner already handles navigation waiting.
*/
skipNavWait?: boolean;
}
/**
* Create a step executor that uses ActionRegistry
*
* This is the main integration point - it creates a function that can
* replace the legacy `executeStep` call in StepRunner.
*
* The executor returns a discriminated union indicating whether the step
* was supported by ActionRegistry. This allows hybrid mode to fall back
* to legacy execution gracefully.
*/
export function createStepExecutor(registry: ActionRegistry) {
return async function executeStepViaActions(
ctx: ExecCtx,
step: Step,
tabId: number,
options?: StepExecutorOptions,
): Promise {
// Convert step to action
let action = stepToAction(step);
if (!action) {
const reason = `Unsupported step type for ActionRegistry: ${step.type}`;
if (options?.strict) {
throw new Error(reason);
}
return { supported: false, reason };
}
// Skip retry policy if StepRunner handles it
// This avoids double retry: StepRunner.withRetry() + ActionRegistry.retry
if (options?.skipRetry === true && action.policy?.retry) {
action = { ...action, policy: { ...action.policy, retry: undefined } };
}
// Check if handler exists
const handler = registry.get(action.type);
if (!handler) {
const reason = `No handler registered for action type: ${action.type}`;
if (options?.strict) {
throw new Error(reason);
}
return { supported: false, reason };
}
// Build execution flags for handlers
const execution: ExecutionFlags | undefined =
options?.skipNavWait === true ? { skipNavWait: true } : undefined;
// Convert context with proper stepId for log attribution
const actionCtx = execCtxToActionCtx(ctx, tabId, {
stepId: step.id,
runId: options?.runId,
pushLog: options?.pushLog,
execution,
});
// Execute via registry (includes retry, timeout, hooks)
const result = await registry.execute(actionCtx, action);
// Handle failure - still return as supported, but throw the error
if (result.status === 'failed') {
const error = result.error;
throw new Error(
error?.message || `Action ${action.type} failed: ${error?.code || 'UNKNOWN'}`,
);
}
// Sync vars back (in case action modified them)
Object.assign(ctx.vars, actionCtx.vars);
// Sync frameId back (in case switchFrame modified it)
if (actionCtx.frameId !== undefined) {
ctx.frameId = actionCtx.frameId;
}
// Sync tabId back (in case openTab/switchTab changed it)
// Chrome tabId is always a positive safe integer
if (result.status === 'success') {
const nextTabId = result.newTabId;
if (typeof nextTabId === 'number' && Number.isSafeInteger(nextTabId) && nextTabId > 0) {
ctx.tabId = nextTabId;
}
}
// Convert result
return { supported: true, result: actionResultToExecResult(result) };
};
}
// ================================
// Type Guards
// ================================
/**
* Check if a step type is supported by ActionRegistry
*/
export function isActionSupported(stepType: string): boolean {
return stepType in STEP_TYPE_TO_ACTION_TYPE;
}
/**
* Get the action type for a step type
*/
export function getActionType(stepType: string): ExecutableActionType | undefined {
return STEP_TYPE_TO_ACTION_TYPE[stepType];
}
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/assert.ts
================================================
/**
* Assert Action Handler
*
* Validates page state against specified conditions:
* - exists: Selector can be resolved to an element
* - visible: Element exists and has non-zero dimensions
* - textPresent: Text appears in the page content
* - attribute: Element attribute equals/matches/exists
*/
import { failed, invalid, ok, tryResolveString } from '../registry';
import type { ActionHandler, Assertion, VariableStore } from '../types';
/** Default timeout for polling assertions (ms) */
const DEFAULT_ASSERT_TIMEOUT_MS = 5000;
/** Polling interval for retry assertions (ms) */
const POLL_INTERVAL_MS = 200;
/** Maximum attribute name length */
const MAX_ATTR_NAME_LENGTH = 256;
/**
* Validates assertion configuration at build time
*/
function validateAssertion(assert: Assertion): { ok: true } | { ok: false; error: string } {
switch (assert.kind) {
case 'exists':
case 'visible':
if (assert.selector === undefined) {
return { ok: false, error: `Assertion "${assert.kind}" requires a selector` };
}
break;
case 'textPresent':
if (assert.text === undefined) {
return { ok: false, error: 'Assertion "textPresent" requires a text value' };
}
break;
case 'attribute':
if (assert.selector === undefined) {
return { ok: false, error: 'Assertion "attribute" requires a selector' };
}
if (assert.name === undefined) {
return { ok: false, error: 'Assertion "attribute" requires an attribute name' };
}
// Must have at least equals or matches (or neither for existence check)
break;
default: {
const exhaustive: never = assert;
return { ok: false, error: `Unknown assertion kind: ${(exhaustive as Assertion).kind}` };
}
}
return { ok: true };
}
/**
* Resolve assertion parameters at runtime
*/
function resolveAssertionParams(
assert: Assertion,
vars: VariableStore,
): { ok: true; resolved: ResolvedAssertion } | { ok: false; error: string } {
switch (assert.kind) {
case 'exists':
case 'visible': {
const selectorResult = tryResolveString(assert.selector, vars);
if (!selectorResult.ok) return selectorResult;
const selector = selectorResult.value.trim();
if (!selector) return { ok: false, error: `Empty selector for "${assert.kind}" assertion` };
return {
ok: true,
resolved: { kind: assert.kind, selector },
};
}
case 'textPresent': {
const textResult = tryResolveString(assert.text, vars);
if (!textResult.ok) return textResult;
const text = textResult.value;
if (!text) return { ok: false, error: 'Empty text for "textPresent" assertion' };
return {
ok: true,
resolved: { kind: 'textPresent', text },
};
}
case 'attribute': {
const selectorResult = tryResolveString(assert.selector, vars);
if (!selectorResult.ok) return selectorResult;
const selector = selectorResult.value.trim();
if (!selector) return { ok: false, error: 'Empty selector for "attribute" assertion' };
const nameResult = tryResolveString(assert.name, vars);
if (!nameResult.ok) return nameResult;
const attrName = nameResult.value.trim();
if (!attrName) return { ok: false, error: 'Empty attribute name' };
if (attrName.length > MAX_ATTR_NAME_LENGTH) {
return { ok: false, error: `Attribute name exceeds ${MAX_ATTR_NAME_LENGTH} characters` };
}
let equals: string | undefined;
let matches: string | undefined;
if (assert.equals !== undefined) {
const equalsResult = tryResolveString(assert.equals, vars);
if (!equalsResult.ok) return equalsResult;
equals = equalsResult.value;
}
if (assert.matches !== undefined) {
const matchesResult = tryResolveString(assert.matches, vars);
if (!matchesResult.ok) return matchesResult;
matches = matchesResult.value;
// Validate regex
try {
new RegExp(matches);
} catch {
return { ok: false, error: `Invalid regex pattern: ${matches}` };
}
}
return {
ok: true,
resolved: { kind: 'attribute', selector, attrName, equals, matches },
};
}
}
}
/**
* Resolved assertion with all variables interpolated
*/
type ResolvedAssertion =
| { kind: 'exists'; selector: string }
| { kind: 'visible'; selector: string }
| { kind: 'textPresent'; text: string }
| { kind: 'attribute'; selector: string; attrName: string; equals?: string; matches?: string };
/**
* Execute assertion check in page context
*/
async function checkAssertionInPage(
tabId: number,
frameId: number | undefined,
resolved: ResolvedAssertion,
): Promise<{ passed: boolean; message?: string }> {
const frameIds = typeof frameId === 'number' ? [frameId] : undefined;
try {
const injected = await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
world: 'MAIN',
func: (assertion: ResolvedAssertion) => {
try {
switch (assertion.kind) {
case 'exists': {
const el = document.querySelector(assertion.selector);
return el ? { passed: true } : { passed: false, message: 'Element not found' };
}
case 'visible': {
const el = document.querySelector(assertion.selector);
if (!el) return { passed: false, message: 'Element not found' };
const rect = el.getBoundingClientRect();
const hasSize = rect.width > 0 && rect.height > 0;
if (!hasSize) return { passed: false, message: 'Element has zero dimensions' };
// Check if element is visible in viewport
const style = window.getComputedStyle(el);
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0'
) {
return { passed: false, message: 'Element is hidden via CSS' };
}
return { passed: true };
}
case 'textPresent': {
const text = assertion.text;
const bodyText = document.body?.textContent || '';
if (bodyText.includes(text)) return { passed: true };
return { passed: false, message: `Text "${text}" not found in page` };
}
case 'attribute': {
const el = document.querySelector(assertion.selector);
if (!el) return { passed: false, message: 'Element not found' };
const attrValue = el.getAttribute(assertion.attrName);
// Check existence only
if (assertion.equals === undefined && assertion.matches === undefined) {
return attrValue !== null
? { passed: true }
: { passed: false, message: `Attribute "${assertion.attrName}" not found` };
}
// Check equals
if (assertion.equals !== undefined) {
if (attrValue === assertion.equals) return { passed: true };
return {
passed: false,
message: `Attribute "${assertion.attrName}" is "${attrValue}", expected "${assertion.equals}"`,
};
}
// Check matches (regex)
if (assertion.matches !== undefined) {
if (attrValue === null) {
return { passed: false, message: `Attribute "${assertion.attrName}" not found` };
}
const regex = new RegExp(assertion.matches);
if (regex.test(attrValue)) return { passed: true };
return {
passed: false,
message: `Attribute "${assertion.attrName}" value "${attrValue}" does not match pattern "${assertion.matches}"`,
};
}
return { passed: true };
}
}
} catch (e) {
return { passed: false, message: e instanceof Error ? e.message : String(e) };
}
},
args: [resolved],
});
const result = Array.isArray(injected) ? injected[0]?.result : undefined;
if (!result || typeof result !== 'object') {
return { passed: false, message: 'Assertion script returned invalid result' };
}
return result as { passed: boolean; message?: string };
} catch (e) {
return {
passed: false,
message: `Script execution failed: ${e instanceof Error ? e.message : String(e)}`,
};
}
}
/**
* Poll assertion until it passes or timeout
*/
async function pollAssertion(
tabId: number,
frameId: number | undefined,
resolved: ResolvedAssertion,
timeoutMs: number,
): Promise<{ passed: boolean; message?: string }> {
const startTime = Date.now();
let lastResult: { passed: boolean; message?: string } = {
passed: false,
message: 'Timeout before first check',
};
while (Date.now() - startTime < timeoutMs) {
lastResult = await checkAssertionInPage(tabId, frameId, resolved);
if (lastResult.passed) return lastResult;
// Wait before next poll
const remaining = timeoutMs - (Date.now() - startTime);
if (remaining > 0) {
await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_INTERVAL_MS, remaining)));
}
}
return {
passed: false,
message: `${lastResult.message || 'Assertion failed'} (timeout: ${timeoutMs}ms)`,
};
}
export const assertHandler: ActionHandler<'assert'> = {
type: 'assert',
validate: (action) => {
const validation = validateAssertion(action.params.assert);
if (!validation.ok) {
return invalid(validation.error);
}
return ok();
},
describe: (action) => {
const assert = action.params.assert;
switch (assert.kind) {
case 'exists':
return `Assert exists: ${truncate(String(assert.selector), 30)}`;
case 'visible':
return `Assert visible: ${truncate(String(assert.selector), 30)}`;
case 'textPresent':
return `Assert text: "${truncate(String(assert.text), 25)}"`;
case 'attribute':
return `Assert attr: ${truncate(String(assert.name), 15)}`;
default:
return 'Assert';
}
},
run: async (ctx, action) => {
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found for assert action');
}
// Resolve assertion parameters
const resolved = resolveAssertionParams(action.params.assert, ctx.vars);
if (!resolved.ok) {
return failed('VALIDATION_ERROR', resolved.error);
}
// Determine timeout from policy or default
const timeoutMs = action.policy?.timeout?.ms ?? DEFAULT_ASSERT_TIMEOUT_MS;
const failStrategy = action.params.failStrategy ?? 'stop';
// Execute assertion with polling
const result = await pollAssertion(tabId, ctx.frameId, resolved.resolved, timeoutMs);
if (result.passed) {
return { status: 'success' };
}
// Handle failure based on strategy
const errorMessage = result.message || 'Assertion failed';
switch (failStrategy) {
case 'warn':
ctx.log(`Assertion warning: ${errorMessage}`, 'warn');
return { status: 'success' };
case 'retry':
// Return failed with retryable error code
// The scheduler should handle retry based on policy
return failed('ASSERTION_FAILED', errorMessage);
case 'stop':
default:
return failed('ASSERTION_FAILED', errorMessage);
}
},
};
/** Truncate string for display */
function truncate(str: string, maxLen: number): string {
if (typeof str !== 'string') return '(dynamic)';
return str.length > maxLen ? str.slice(0, maxLen) + '...' : str;
}
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts
================================================
/**
* Click and Double-Click Action Handlers
*
* Handles click interactions:
* - Single click
* - Double click
* - Post-click navigation/network wait
* - Selector fallback with logging
*/
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { ENGINE_CONSTANTS } from '../../engine/constants';
import {
maybeQuickWaitForNav,
waitForNavigationDone,
waitForNetworkIdle,
} from '../../engine/policies/wait';
import { failed, invalid, ok } from '../registry';
import type {
Action,
ActionExecutionContext,
ActionExecutionResult,
ActionHandler,
} from '../types';
import {
clampInt,
ensureElementVisible,
logSelectorFallback,
readTabUrl,
selectorLocator,
toSelectorTarget,
} from './common';
/**
* Shared click execution logic for both click and dblclick
*/
async function executeClick(
ctx: ActionExecutionContext,
action: Action,
): Promise> {
const vars = ctx.vars;
const tabId = ctx.tabId;
// Check if StepRunner owns nav-wait (skip internal nav-wait logic)
const skipNavWait = ctx.execution?.skipNavWait === true;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found');
}
// Ensure page is read before locating element
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
// Only read beforeUrl if we need to do nav-wait
const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId);
const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(
action.params.target,
vars,
);
// Locate element using shared selector locator
const located = await selectorLocator.locate(tabId, selectorTarget, {
frameId: ctx.frameId,
preferRef: false,
});
const frameId = located?.frameId ?? ctx.frameId;
const refToUse = located?.ref ?? selectorTarget.ref;
const selectorToUse = !located?.ref ? firstCssOrAttr : undefined;
if (!refToUse && !selectorToUse) {
return failed('TARGET_NOT_FOUND', 'Could not locate target element');
}
// Verify element visibility if we have a ref
if (located?.ref) {
const isVisible = await ensureElementVisible(tabId, located.ref, frameId);
if (!isVisible) {
return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');
}
}
// Execute click with tool timeout
const toolTimeout = clampInt(action.policy?.timeout?.ms ?? 10000, 1000, 30000);
const clickResult = await handleCallTool({
name: TOOL_NAMES.BROWSER.CLICK,
args: {
ref: refToUse,
selector: selectorToUse,
waitForNavigation: false,
timeout: toolTimeout,
frameId,
tabId,
double: action.type === 'dblclick',
},
});
if ((clickResult as { isError?: boolean })?.isError) {
const errorContent = (clickResult as { content?: Array<{ text?: string }> })?.content;
const errorMsg = errorContent?.[0]?.text || `${action.type} action failed`;
return failed('UNKNOWN', errorMsg);
}
// Log selector fallback if used
const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
const fallbackUsed =
resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
if (fallbackUsed) {
logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
}
// Skip post-click wait if StepRunner handles it
if (skipNavWait) {
return { status: 'success' };
}
// Post-click wait handling (only when handler owns nav-wait)
const waitMs = clampInt(
action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS,
0,
ENGINE_CONSTANTS.MAX_WAIT_MS,
);
const after = action.params.after ?? {};
if (after.waitForNavigation) {
await waitForNavigationDone(beforeUrl, waitMs);
} else if (after.waitForNetworkIdle) {
const totalMs = clampInt(waitMs, 1000, ENGINE_CONSTANTS.MAX_WAIT_MS);
const idleMs = Math.min(1500, Math.max(500, Math.floor(totalMs / 3)));
await waitForNetworkIdle(totalMs, idleMs);
} else {
// Quick sniff for navigation that might have been triggered
await maybeQuickWaitForNav(beforeUrl, waitMs);
}
return { status: 'success' };
}
/**
* Validate click target configuration
*/
function validateClickTarget(target: {
ref?: string;
candidates?: unknown[];
}): { ok: true } | { ok: false; errors: [string, ...string[]] } {
const hasRef = typeof target?.ref === 'string' && target.ref.trim().length > 0;
const hasCandidates = Array.isArray(target?.candidates) && target.candidates.length > 0;
if (hasRef || hasCandidates) {
return ok();
}
return invalid('Missing target selector or ref');
}
export const clickHandler: ActionHandler<'click'> = {
type: 'click',
validate: (action) =>
validateClickTarget(action.params.target as { ref?: string; candidates?: unknown[] }),
describe: (action) => {
const target = action.params.target;
if (typeof (target as { ref?: string }).ref === 'string') {
return `Click element ${(target as { ref: string }).ref}`;
}
return 'Click element';
},
run: async (ctx, action) => {
return await executeClick(ctx, action);
},
};
export const dblclickHandler: ActionHandler<'dblclick'> = {
type: 'dblclick',
validate: (action) =>
validateClickTarget(action.params.target as { ref?: string; candidates?: unknown[] }),
describe: (action) => {
const target = action.params.target;
if (typeof (target as { ref?: string }).ref === 'string') {
return `Double-click element ${(target as { ref: string }).ref}`;
}
return 'Double-click element';
},
run: async (ctx, action) => {
return await executeClick(ctx, action);
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/common.ts
================================================
/**
* Common utilities for Action handlers
*
* Shared helpers for:
* - Variable resolution and template interpolation
* - Selector target conversion
* - Element visibility verification
* - Logging utilities
*/
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
import {
createChromeSelectorLocator,
type SelectorCandidate as SharedSelectorCandidate,
type SelectorCandidateSource,
type SelectorStability,
type SelectorTarget,
} from '@/shared/selector';
import { tryResolveString } from '../registry';
import type { ActionExecutionContext, ElementTarget, Resolvable, VariableStore } from '../types';
// ================================
// Selector Locator Instance
// ================================
export const selectorLocator = createChromeSelectorLocator();
// ================================
// String Resolution Utilities
// ================================
/**
* Interpolate {varName} placeholders in a string using variable store
*/
export function interpolateBraces(template: string, vars: VariableStore): string {
return String(template || '').replace(/\{([^}]+)\}/g, (_match, key) => {
const value = (vars as Record)[key];
return value == null ? '' : String(value);
});
}
/**
* Resolve a Resolvable value with template interpolation
*/
export function resolveString(
value: Resolvable,
vars: VariableStore,
): { ok: true; value: string } | { ok: false; error: string } {
const resolved = tryResolveString(value, vars);
if (!resolved.ok) return resolved;
return { ok: true, value: interpolateBraces(resolved.value, vars) };
}
/**
* Resolve an optional Resolvable value
*/
export function resolveOptionalString(
value: Resolvable | undefined,
vars: VariableStore,
): string | undefined {
if (value === undefined) return undefined;
const resolved = resolveString(value, vars);
if (!resolved.ok) return undefined;
const out = resolved.value.trim();
return out.length > 0 ? out : undefined;
}
// ================================
// Number Utilities
// ================================
/**
* Clamp a number to a range with integer conversion
*/
export function clampInt(value: number, min: number, max: number): number {
const n = Number(value);
if (!Number.isFinite(n)) return min;
return Math.min(max, Math.max(min, Math.floor(n)));
}
// ================================
// Selector Target Conversion
// ================================
export interface ConvertedSelectorTarget {
selectorTarget: SelectorTarget;
/** Type of the first candidate (for fallback logging) */
firstCandidateType?: string;
/** First CSS or attr selector value (for tool fallback) */
firstCssOrAttr?: string;
}
/**
* Convert Action ElementTarget to shared SelectorTarget
*
* Handles:
* - Resolvable candidate values
* - Template interpolation
* - Weight assignment for locator priority
*/
export function toSelectorTarget(
target: ElementTarget,
vars: VariableStore,
): ConvertedSelectorTarget {
const srcCandidates = Array.isArray(target.candidates) ? target.candidates : [];
const firstCandidateType =
srcCandidates.length > 0
? String((srcCandidates[0] as { type?: string })?.type || '') || undefined
: undefined;
// Find first CSS/attr selector for tool fallback
let firstCssOrAttr: string | undefined;
for (const c of srcCandidates) {
if (c.type !== 'css' && c.type !== 'attr') continue;
const resolved = resolveString(c.selector, vars);
if (resolved.ok && resolved.value.trim()) {
firstCssOrAttr = resolved.value;
break;
}
}
// Extract selector from target if present
const primaryRaw =
typeof (target as { selector?: string }).selector === 'string'
? String((target as { selector?: string }).selector).trim()
: '';
const selectorInterpolated = primaryRaw ? interpolateBraces(primaryRaw, vars).trim() : '';
const selector = selectorInterpolated || undefined;
// Extract tagName hint
const tagName =
typeof (target as { tag?: string }).tag === 'string'
? String((target as { tag?: string }).tag)
: typeof (target as { hint?: { tagName?: string } }).hint?.tagName === 'string'
? String((target as { hint?: { tagName?: string } }).hint!.tagName)
: undefined;
// Convert candidates with weight assignment
// Preserve user-defined weights while keeping text candidates as last resort
let nonTextIndex = 0;
let textIndex = 0;
const candidates: SharedSelectorCandidate[] = [];
for (const c of srcCandidates) {
const idx = c.type === 'text' ? textIndex++ : nonTextIndex++;
// Respect user-defined weight if present, otherwise use position-based weight
const userWeight =
typeof (c as { weight?: number }).weight === 'number' &&
Number.isFinite((c as { weight?: number }).weight)
? (c as { weight: number }).weight
: 0;
// Non-text candidates get higher base weight
const weightBase = c.type === 'text' ? 0 : 1000;
const weight = weightBase + userWeight - idx;
// Preserve source and stability metadata from original candidate
// Type-safely extract optional source and stability fields
const rawSource = (c as { source?: SelectorCandidateSource }).source;
const rawStability = (c as { stability?: SelectorStability }).stability;
const meta: Pick = {
weight,
...(rawSource && { source: rawSource }),
...(rawStability && { stability: rawStability }),
};
switch (c.type) {
case 'css': {
const resolved = resolveString(c.selector, vars);
if (!resolved.ok) continue;
candidates.push({ type: 'css', value: resolved.value, ...meta });
break;
}
case 'attr': {
const resolved = resolveString(c.selector, vars);
if (!resolved.ok) continue;
candidates.push({ type: 'attr', value: resolved.value, ...meta });
break;
}
case 'xpath': {
const resolved = resolveString(c.xpath, vars);
if (!resolved.ok) continue;
candidates.push({ type: 'xpath', value: resolved.value, ...meta });
break;
}
case 'text': {
const resolved = resolveString(c.text, vars);
if (!resolved.ok) continue;
candidates.push({
type: 'text',
value: resolved.value,
...meta,
match: c.match,
tagNameHint: c.tagNameHint ?? tagName,
});
break;
}
case 'aria': {
const role = resolveOptionalString(c.role, vars);
const name = resolveOptionalString(c.name, vars);
// Skip aria candidate if no name provided (would produce useless selector)
if (!name) break;
// Avoid injecting fake role; use aria-label format when role is not specified
const value = role
? `${role}[name=${JSON.stringify(name)}]`
: `aria-label=${JSON.stringify(name)}`;
candidates.push({ type: 'aria', value, ...meta, role, name });
break;
}
}
}
// Ensure at least one candidate
const ensuredCandidates: [SharedSelectorCandidate, ...SharedSelectorCandidate[]] =
candidates.length > 0
? (candidates as [SharedSelectorCandidate, ...SharedSelectorCandidate[]])
: [{ type: 'css', value: '' }];
return {
selectorTarget: {
selector,
candidates: ensuredCandidates,
tagName,
ref:
typeof (target as { ref?: string }).ref === 'string'
? String((target as { ref?: string }).ref)
: undefined,
},
firstCandidateType,
firstCssOrAttr,
};
}
// ================================
// Chrome Message Utilities
// ================================
/**
* Result type for sendMessageToTab
*/
export type SendMessageResult = { ok: true; value: T } | { ok: false; error: string };
/**
* Send message to tab with optional frameId
* Returns structured result to avoid silent failures
*/
export async function sendMessageToTab(
tabId: number,
message: unknown,
frameId?: number,
): Promise> {
try {
let response: T;
if (typeof frameId === 'number') {
response = await chrome.tabs.sendMessage(tabId, message, { frameId });
} else {
response = await chrome.tabs.sendMessage(tabId, message);
}
return { ok: true, value: response };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
}
// ================================
// Element Verification
// ================================
/**
* Verify element is visible by checking its bounding rect
*/
export async function ensureElementVisible(
tabId: number,
ref: string,
frameId: number | undefined,
): Promise {
const result = await sendMessageToTab<{ rect?: { width: number; height: number } }>(
tabId,
{ action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref },
frameId,
);
if (!result.ok) return false;
const rect = result.value?.rect;
return !!rect && rect.width > 0 && rect.height > 0;
}
/**
* Get current tab URL
*/
export async function readTabUrl(tabId: number): Promise {
try {
const tab = await chrome.tabs.get(tabId);
return tab?.url || '';
} catch {
return '';
}
}
// ================================
// Logging Utilities
// ================================
export interface FallbackLogEntry {
stepId: string;
status: 'success';
message: string;
fallbackUsed: boolean;
fallbackFrom: string;
fallbackTo: string;
}
/**
* Log selector fallback usage for debugging
*/
export function logSelectorFallback(
ctx: Pick,
actionId: string,
from: string,
to: string,
): void {
try {
ctx.pushLog?.({
stepId: actionId,
status: 'success',
message: `Selector fallback used (${from} -> ${to})`,
fallbackUsed: true,
fallbackFrom: from,
fallbackTo: to,
});
} catch {
// Ignore logging errors
}
}
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/control-flow.ts
================================================
/**
* Control Flow Action Handlers
*
* Handles flow control operations:
* - if: Conditional branching
* - foreach: Loop over array
* - while: Loop with condition
* - switchFrame: Switch to a different frame
*
* Note: The actual loop iteration is handled by the Scheduler.
* These handlers return control directives that tell the Scheduler how to proceed.
*/
import {
failed,
invalid,
ok,
tryResolveNumber,
tryResolveString,
tryResolveValue,
} from '../registry';
import type {
ActionHandler,
Condition,
ControlDirective,
EdgeLabel,
VariableStore,
} from '../types';
/** Default max iterations for while loops */
const DEFAULT_MAX_ITERATIONS = 1000;
// ================================
// Condition Evaluation
// ================================
/**
* Evaluate a condition against variables
*/
function evaluateCondition(condition: Condition, vars: VariableStore): boolean {
switch (condition.kind) {
case 'expr': {
// Expression evaluation not supported in default resolver
// Return false for safety
return false;
}
case 'compare': {
const leftResult = tryResolveValue(condition.left, vars);
const rightResult = tryResolveValue(condition.right, vars);
if (!leftResult.ok || !rightResult.ok) return false;
const left = leftResult.value;
const right = rightResult.value;
switch (condition.op) {
case 'eq':
return left === right;
case 'eqi':
return String(left).toLowerCase() === String(right).toLowerCase();
case 'neq':
return left !== right;
case 'gt':
return Number(left) > Number(right);
case 'gte':
return Number(left) >= Number(right);
case 'lt':
return Number(left) < Number(right);
case 'lte':
return Number(left) <= Number(right);
case 'contains':
return String(left).includes(String(right));
case 'containsI':
return String(left).toLowerCase().includes(String(right).toLowerCase());
case 'notContains':
return !String(left).includes(String(right));
case 'notContainsI':
return !String(left).toLowerCase().includes(String(right).toLowerCase());
case 'startsWith':
return String(left).startsWith(String(right));
case 'endsWith':
return String(left).endsWith(String(right));
case 'regex': {
try {
const regex = new RegExp(String(right));
return regex.test(String(left));
} catch {
return false;
}
}
default:
return false;
}
}
case 'truthy': {
const result = tryResolveValue(condition.value, vars);
if (!result.ok) return false;
return Boolean(result.value);
}
case 'falsy': {
const result = tryResolveValue(condition.value, vars);
if (!result.ok) return true;
return !result.value;
}
case 'not':
return !evaluateCondition(condition.condition, vars);
case 'and':
return condition.conditions.every((c) => evaluateCondition(c, vars));
case 'or':
return condition.conditions.some((c) => evaluateCondition(c, vars));
default:
return false;
}
}
// ================================
// if Handler
// ================================
export const ifHandler: ActionHandler<'if'> = {
type: 'if',
validate: (action) => {
const params = action.params;
if (params.mode === 'binary') {
if (!params.condition) {
return invalid('Binary if requires a condition');
}
} else if (params.mode === 'branches') {
if (!params.branches || params.branches.length === 0) {
return invalid('Branches if requires at least one branch');
}
} else {
return invalid(`Unknown if mode: ${String((params as { mode: string }).mode)}`);
}
return ok();
},
describe: (action) => {
if (action.params.mode === 'binary') {
return 'If condition';
}
const branchCount = action.params.mode === 'branches' ? action.params.branches.length : 0;
return `If (${branchCount} branches)`;
},
run: async (ctx, action) => {
const params = action.params;
if (params.mode === 'binary') {
const result = evaluateCondition(params.condition, ctx.vars);
const label: EdgeLabel = result
? (params.trueLabel ?? 'true')
: (params.falseLabel ?? 'false');
return { status: 'success', nextLabel: label };
}
// Branches mode
if (params.mode === 'branches') {
for (const branch of params.branches) {
if (evaluateCondition(branch.condition, ctx.vars)) {
return { status: 'success', nextLabel: branch.label };
}
}
// No branch matched, use else label
const elseLabel = params.elseLabel ?? 'default';
return { status: 'success', nextLabel: elseLabel };
}
return failed('VALIDATION_ERROR', 'Invalid if mode');
},
};
// ================================
// foreach Handler
// ================================
export const foreachHandler: ActionHandler<'foreach'> = {
type: 'foreach',
validate: (action) => {
const params = action.params;
if (!params.listVar) {
return invalid('foreach requires a listVar');
}
if (!params.subflowId) {
return invalid('foreach requires a subflowId');
}
return ok();
},
describe: (action) => {
return `For each in ${action.params.listVar}`;
},
run: async (ctx, action) => {
const params = action.params;
// Check if listVar exists and is an array
const list = ctx.vars[params.listVar];
if (!Array.isArray(list)) {
return failed('VALIDATION_ERROR', `Variable "${params.listVar}" is not an array`);
}
if (list.length === 0) {
// Empty list, nothing to iterate
return { status: 'success' };
}
// Return control directive for scheduler to handle
const directive: ControlDirective = {
kind: 'foreach',
listVar: params.listVar,
itemVar: params.itemVar || 'item',
subflowId: params.subflowId,
concurrency: params.concurrency,
};
return { status: 'success', control: directive };
},
};
// ================================
// while Handler
// ================================
export const whileHandler: ActionHandler<'while'> = {
type: 'while',
validate: (action) => {
const params = action.params;
if (!params.condition) {
return invalid('while requires a condition');
}
if (!params.subflowId) {
return invalid('while requires a subflowId');
}
return ok();
},
describe: () => {
return 'While loop';
},
run: async (ctx, action) => {
const params = action.params;
// Check if condition is currently true
const conditionResult = evaluateCondition(params.condition, ctx.vars);
if (!conditionResult) {
// Condition is false, don't enter loop
return { status: 'success' };
}
// Return control directive for scheduler to handle
const directive: ControlDirective = {
kind: 'while',
condition: params.condition,
subflowId: params.subflowId,
maxIterations: params.maxIterations ?? DEFAULT_MAX_ITERATIONS,
};
return { status: 'success', control: directive };
},
};
// ================================
// switchFrame Handler
// ================================
export const switchFrameHandler: ActionHandler<'switchFrame'> = {
type: 'switchFrame',
validate: (action) => {
const target = action.params.target;
if (!target) {
return invalid('switchFrame requires a target');
}
if (target.kind !== 'top' && target.kind !== 'index' && target.kind !== 'urlContains') {
return invalid(`Unknown frame target kind: ${String((target as { kind: string }).kind)}`);
}
return ok();
},
describe: (action) => {
const target = action.params.target;
if (target.kind === 'top') return 'Switch to top frame';
if (target.kind === 'index') return `Switch to frame #${target.index}`;
if (target.kind === 'urlContains') return 'Switch frame (by URL)';
return 'Switch frame';
},
run: async (ctx, action) => {
const target = action.params.target;
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found');
}
try {
if (target.kind === 'top') {
// Reset to main frame (frameId = 0)
ctx.frameId = 0;
return { status: 'success' };
}
// Get all frames in the tab
const frames = await chrome.webNavigation.getAllFrames({ tabId });
if (!frames || frames.length === 0) {
return failed('FRAME_NOT_FOUND', 'No frames found in tab');
}
let targetFrame: chrome.webNavigation.GetAllFrameResultDetails | undefined;
if (target.kind === 'index') {
const indexResult = tryResolveNumber(target.index, ctx.vars);
if (!indexResult.ok) {
return failed('VALIDATION_ERROR', `Failed to resolve frame index: ${indexResult.error}`);
}
const index = Math.floor(indexResult.value);
// Find frame by index (excluding main frame which is 0)
const childFrames = frames.filter((f) => f.frameId !== 0);
if (index < 0 || index >= childFrames.length) {
return failed(
'FRAME_NOT_FOUND',
`Frame index ${index} out of bounds (${childFrames.length} frames)`,
);
}
targetFrame = childFrames[index];
} else if (target.kind === 'urlContains') {
const urlResult = tryResolveString(target.value, ctx.vars);
if (!urlResult.ok) {
return failed('VALIDATION_ERROR', `Failed to resolve URL pattern: ${urlResult.error}`);
}
const urlPattern = urlResult.value.trim().toLowerCase();
// Empty pattern is invalid
if (!urlPattern) {
return failed('VALIDATION_ERROR', 'URL pattern cannot be empty');
}
targetFrame = frames.find((f) => f.url && f.url.toLowerCase().includes(urlPattern));
}
if (!targetFrame) {
return failed('FRAME_NOT_FOUND', 'No matching frame found');
}
// The frameId will be used by subsequent actions
// Store it in context (this is typically handled by scheduler)
ctx.frameId = targetFrame.frameId;
return { status: 'success' };
} catch (e) {
return failed(
'FRAME_NOT_FOUND',
`Failed to switch frame: ${e instanceof Error ? e.message : String(e)}`,
);
}
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/delay.ts
================================================
/**
* Delay Action Handler
*
* Provides a simple pause in execution flow.
* Supports variable resolution for dynamic delay times.
*/
import { failed, invalid, ok, tryResolveNumber } from '../registry';
import type { ActionHandler } from '../types';
/** Maximum delay time to prevent integer overflow in setTimeout */
const MAX_DELAY_MS = 2_147_483_647;
export const delayHandler: ActionHandler<'delay'> = {
type: 'delay',
validate: (action) => {
if (action.params.sleep === undefined) {
return invalid('Missing sleep parameter');
}
return ok();
},
describe: (action) => {
const ms = typeof action.params.sleep === 'number' ? action.params.sleep : '(dynamic)';
return `Delay ${ms}ms`;
},
run: async (ctx, action) => {
const resolved = tryResolveNumber(action.params.sleep, ctx.vars);
if (!resolved.ok) {
return failed('VALIDATION_ERROR', resolved.error);
}
const ms = Math.max(0, Math.min(MAX_DELAY_MS, Math.floor(resolved.value)));
if (ms > 0) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
return { status: 'success' };
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/dom.ts
================================================
/**
* DOM Tools Action Handlers
*
* Handles DOM manipulation actions:
* - triggerEvent: Dispatch a custom DOM Event on an element
* - setAttribute: Set or remove an attribute on an element
*
* Design notes:
* - Both handlers follow the same pattern as click.ts
* - Element location uses selectorLocator from shared code
* - CSS selector resolution supports ref fallback
*/
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { failed, invalid, ok, tryResolveJson } from '../registry';
import type {
ActionExecutionResult,
ActionHandler,
ElementTarget,
JsonValue,
VariableStore,
} from '../types';
import {
interpolateBraces,
logSelectorFallback,
resolveString,
selectorLocator,
sendMessageToTab,
toSelectorTarget,
} from './common';
// ================================
// Type Definitions
// ================================
interface ResolveRefResponse {
success?: boolean;
selector?: string;
error?: string;
}
interface DomScriptResult {
success: boolean;
error?: string;
}
interface ResolvedTarget {
selector: string;
frameId: number | undefined;
firstCandidateType?: string;
resolvedBy?: string;
}
// ================================
// Shared Utilities
// ================================
/**
* Check if target has valid ref or candidates
* Accepts unknown to safely handle malformed input in validate()
*/
function hasValidTarget(target: unknown): boolean {
if (typeof target !== 'object' || target === null) return false;
const t = target as { ref?: unknown; candidates?: unknown };
const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0;
const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0;
return hasRef || hasCandidates;
}
/**
* Strip frame prefix from composite selector (e.g., "frame|>selector" -> "selector")
*/
function stripCompositePrefix(selector: string): string {
const raw = String(selector || '').trim();
if (!raw.includes('|>')) return raw;
const parts = raw
.split('|>')
.map((p) => p.trim())
.filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : raw;
}
/**
* Resolve ElementTarget to a CSS selector string
*
* Resolution order:
* 1. Try to locate element using selectorLocator
* 2. If ref found, resolve it to CSS selector via content script
* 3. Fall back to first CSS/attr candidate if no ref
*/
async function resolveTargetSelector(
tabId: number,
target: ElementTarget,
vars: VariableStore,
contextFrameId: number | undefined,
): Promise<{ ok: true; value: ResolvedTarget } | { ok: false; error: string }> {
const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(target, vars);
// Locate element using shared selector locator
const located = await selectorLocator.locate(tabId, selectorTarget, {
frameId: contextFrameId,
preferRef: false,
});
const frameId = located?.frameId ?? contextFrameId;
const refToUse = located?.ref ?? selectorTarget.ref;
// Must have either ref or CSS/attr candidate
if (!refToUse && !firstCssOrAttr) {
return { ok: false, error: 'Could not locate target element' };
}
let selector: string | undefined;
// Try to resolve ref to CSS selector
if (refToUse) {
const resolved = await sendMessageToTab(
tabId,
{ action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: refToUse },
frameId,
);
if (resolved.ok && resolved.value?.success !== false && resolved.value?.selector) {
const sel = resolved.value.selector.trim();
if (sel) selector = sel;
}
}
// Fall back to CSS/attr candidate
if (!selector && firstCssOrAttr) {
const stripped = stripCompositePrefix(firstCssOrAttr);
if (stripped) selector = stripped;
}
if (!selector) {
return { ok: false, error: 'Could not resolve a CSS selector for the target element' };
}
return {
ok: true,
value: {
selector,
frameId,
firstCandidateType,
// Only mark as 'ref' if locator actually resolved via ref
resolvedBy: located?.resolvedBy || (located?.ref ? 'ref' : undefined),
},
};
}
/**
* Log selector fallback if a different selector type was used
*/
function maybeLogFallback(
ctx: Parameters[0],
actionId: string,
resolved: ResolvedTarget,
): void {
const { resolvedBy, firstCandidateType } = resolved;
const fallbackUsed =
resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
if (fallbackUsed) {
logSelectorFallback(ctx, actionId, String(firstCandidateType), String(resolvedBy));
}
}
// ================================
// triggerEvent Handler
// ================================
export const triggerEventHandler: ActionHandler<'triggerEvent'> = {
type: 'triggerEvent',
validate: (action) => {
if (!hasValidTarget(action.params.target)) {
return invalid('triggerEvent requires a target ref or selector candidates');
}
const event = action.params.event;
if (event === undefined || event === null) {
return invalid('Missing event parameter');
}
if (typeof event === 'string' && event.trim().length === 0) {
return invalid('event must be a non-empty string');
}
return ok();
},
describe: (action) => {
const ev = typeof action.params.event === 'string' ? action.params.event : '(dynamic)';
const display = ev.length > 30 ? ev.slice(0, 30) + '...' : ev;
return `Trigger event "${display}"`;
},
run: async (ctx, action): Promise> => {
const { tabId, vars, frameId } = ctx;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found for triggerEvent action');
}
// Resolve event type
const eventResolved = resolveString(action.params.event, vars);
if (!eventResolved.ok) {
return failed('VALIDATION_ERROR', eventResolved.error);
}
const eventType = eventResolved.value.trim();
if (!eventType) {
return failed('VALIDATION_ERROR', 'Event type is empty');
}
// Event options
const bubbles = action.params.bubbles !== false;
const cancelable = action.params.cancelable === true;
// Ensure page is read for element location
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });
// Resolve target selector
const targetResolved = await resolveTargetSelector(tabId, action.params.target, vars, frameId);
if (!targetResolved.ok) {
return failed('TARGET_NOT_FOUND', targetResolved.error);
}
const { selector, frameId: resolvedFrameId } = targetResolved.value;
const frameIds = typeof resolvedFrameId === 'number' ? [resolvedFrameId] : undefined;
// Execute event dispatch in page context
try {
const injected = await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
world: 'MAIN',
func: (
sel: string,
type: string,
bubbles: boolean,
cancelable: boolean,
): DomScriptResult => {
try {
const el = document.querySelector(sel);
if (!el) {
// Use special error code to distinguish from script execution errors
return { success: false, error: `[TARGET_NOT_FOUND] Element not found: ${sel}` };
}
const event = new Event(type, { bubbles, cancelable });
el.dispatchEvent(event);
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
},
args: [selector, eventType, bubbles, cancelable],
});
const result = Array.isArray(injected) ? injected[0]?.result : undefined;
if (!result || typeof result !== 'object') {
return failed('SCRIPT_FAILED', 'triggerEvent script returned invalid result');
}
const typed = result as DomScriptResult;
if (!typed.success) {
// Parse error code from message if present (e.g., "[TARGET_NOT_FOUND] ...")
const errorMsg = typed.error || `Failed to dispatch "${eventType}"`;
const code = errorMsg.startsWith('[TARGET_NOT_FOUND]')
? 'TARGET_NOT_FOUND'
: 'SCRIPT_FAILED';
return failed(code, errorMsg.replace(/^\[TARGET_NOT_FOUND\]\s*/, ''));
}
} catch (e) {
return failed(
'SCRIPT_FAILED',
`Failed to trigger event "${eventType}": ${e instanceof Error ? e.message : String(e)}`,
);
}
maybeLogFallback(ctx, action.id, targetResolved.value);
return { status: 'success' };
},
};
// ================================
// setAttribute Handler
// ================================
export const setAttributeHandler: ActionHandler<'setAttribute'> = {
type: 'setAttribute',
validate: (action) => {
if (!hasValidTarget(action.params.target)) {
return invalid('setAttribute requires a target ref or selector candidates');
}
const name = action.params.name;
if (name === undefined || name === null) {
return invalid('Missing name parameter');
}
if (typeof name === 'string' && name.trim().length === 0) {
return invalid('name must be a non-empty string');
}
return ok();
},
describe: (action) => {
const name = typeof action.params.name === 'string' ? action.params.name : '(dynamic)';
const display = name.length > 30 ? name.slice(0, 30) + '...' : name;
return action.params.remove ? `Remove attribute "${display}"` : `Set attribute "${display}"`;
},
run: async (ctx, action): Promise> => {
const { tabId, vars, frameId } = ctx;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found for setAttribute action');
}
// Resolve attribute name
const nameResolved = resolveString(action.params.name, vars);
if (!nameResolved.ok) {
return failed('VALIDATION_ERROR', nameResolved.error);
}
const attrName = nameResolved.value.trim();
if (!attrName) {
return failed('VALIDATION_ERROR', 'Attribute name is empty');
}
const remove = action.params.remove === true;
// Resolve attribute value (only if not removing)
let attrValue: JsonValue = null;
if (!remove && action.params.value !== undefined) {
const valueResolved = tryResolveJson(action.params.value, vars);
if (!valueResolved.ok) {
return failed('VALIDATION_ERROR', valueResolved.error);
}
// Apply template interpolation for string values
attrValue =
typeof valueResolved.value === 'string'
? interpolateBraces(valueResolved.value, vars)
: valueResolved.value;
}
// Ensure page is read for element location
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });
// Resolve target selector
const targetResolved = await resolveTargetSelector(tabId, action.params.target, vars, frameId);
if (!targetResolved.ok) {
return failed('TARGET_NOT_FOUND', targetResolved.error);
}
const { selector, frameId: resolvedFrameId } = targetResolved.value;
const frameIds = typeof resolvedFrameId === 'number' ? [resolvedFrameId] : undefined;
// Execute attribute modification in page context
try {
const injected = await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
world: 'MAIN',
func: (sel: string, name: string, value: JsonValue, remove: boolean): DomScriptResult => {
try {
const el = document.querySelector(sel);
if (!el) {
// Use special error code to distinguish from script execution errors
return { success: false, error: `[TARGET_NOT_FOUND] Element not found: ${sel}` };
}
if (remove) {
el.removeAttribute(name);
} else {
// Convert value to string for setAttribute
const strValue =
value === null || value === undefined
? ''
: typeof value === 'string'
? value
: String(value);
el.setAttribute(name, strValue);
}
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
},
args: [selector, attrName, attrValue, remove],
});
const result = Array.isArray(injected) ? injected[0]?.result : undefined;
if (!result || typeof result !== 'object') {
return failed('SCRIPT_FAILED', 'setAttribute script returned invalid result');
}
const typed = result as DomScriptResult;
if (!typed.success) {
const actionDesc = remove ? 'remove' : 'set';
// Parse error code from message if present (e.g., "[TARGET_NOT_FOUND] ...")
const errorMsg = typed.error || `Failed to ${actionDesc} attribute "${attrName}"`;
const code = errorMsg.startsWith('[TARGET_NOT_FOUND]')
? 'TARGET_NOT_FOUND'
: 'SCRIPT_FAILED';
return failed(code, errorMsg.replace(/^\[TARGET_NOT_FOUND\]\s*/, ''));
}
} catch (e) {
const actionDesc = remove ? 'remove' : 'set';
return failed(
'SCRIPT_FAILED',
`Failed to ${actionDesc} attribute "${attrName}": ${e instanceof Error ? e.message : String(e)}`,
);
}
maybeLogFallback(ctx, action.id, targetResolved.value);
return { status: 'success' };
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/drag.ts
================================================
/**
* Drag Action Handler
*
* Performs a left-click drag from a start target to an end target.
*
* Features:
* - Locates start/end via shared SelectorLocator (ref + candidates)
* - Executes via chrome_computer with action="left_click_drag" (CDP-based)
* - Uses optional `path` endpoints as a fallback for coordinates
* - Validates element visibility before drag
*/
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { failed, invalid, ok } from '../registry';
import type { ActionHandler, ElementTarget, Point, VariableStore } from '../types';
import {
ensureElementVisible,
logSelectorFallback,
selectorLocator,
toSelectorTarget,
} from './common';
interface Coordinates {
x: number;
y: number;
}
/** Check if target has valid selector specification */
function hasTargetSpec(target: unknown): boolean {
if (!target || typeof target !== 'object') return false;
const t = target as { ref?: unknown; candidates?: unknown };
const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0;
const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0;
return hasRef || hasCandidates;
}
/** Check if value is a finite number */
function isFiniteNumber(v: unknown): v is number {
return typeof v === 'number' && Number.isFinite(v);
}
/** Extract start/end coordinates from path array */
function getPathEndpoints(
path: ReadonlyArray | undefined,
): { startCoordinates: Coordinates; endCoordinates: Coordinates } | null {
if (!Array.isArray(path) || path.length < 2) return null;
const first = path[0];
const last = path[path.length - 1];
if (!first || !last) return null;
if (!isFiniteNumber(first.x) || !isFiniteNumber(first.y)) return null;
if (!isFiniteNumber(last.x) || !isFiniteNumber(last.y)) return null;
return {
startCoordinates: { x: first.x, y: first.y },
endCoordinates: { x: last.x, y: last.y },
};
}
/** Extract error text from tool result */
function extractToolError(result: unknown, fallback: string): string {
const content = (result as { content?: Array<{ text?: string }> })?.content;
return content?.find((c) => typeof c?.text === 'string')?.text || fallback;
}
/** Locate target and verify visibility */
async function locateTarget(
tabId: number,
frameId: number | undefined,
target: ElementTarget | undefined,
vars: VariableStore,
role: 'start' | 'end',
): Promise<
| { ok: true; ref?: string; firstCandidateType?: string; resolvedBy?: string }
| { ok: false; error: string; code: 'TARGET_NOT_FOUND' | 'ELEMENT_NOT_VISIBLE' }
> {
if (!target || !hasTargetSpec(target)) {
return { ok: true };
}
const { selectorTarget, firstCandidateType } = toSelectorTarget(target, vars);
const located = await selectorLocator.locate(tabId, selectorTarget, {
frameId,
preferRef: false,
});
const locatedFrameId = located?.frameId ?? frameId;
const ref = located?.ref ?? selectorTarget.ref;
const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
// Verify visibility for freshly located refs
if (located?.ref) {
const visible = await ensureElementVisible(tabId, located.ref, locatedFrameId);
if (!visible) {
return {
ok: false,
error: `Drag ${role} element is not visible`,
code: 'ELEMENT_NOT_VISIBLE',
};
}
}
return { ok: true, ref, firstCandidateType, resolvedBy };
}
export const dragHandler: ActionHandler<'drag'> = {
type: 'drag',
validate: (action) => {
const pathEndpoints = getPathEndpoints(action.params.path);
// If path is present, it must be well-formed
if (action.params.path !== undefined && action.params.path.length > 0 && !pathEndpoints) {
return invalid('path must contain at least two points with finite x/y coordinates');
}
const hasStart = hasTargetSpec(action.params.start);
const hasEnd = hasTargetSpec(action.params.end);
const hasPath = !!pathEndpoints;
// Must have either target spec or path coordinates
if (!hasStart && !hasPath) {
return invalid('Drag start must include a non-empty ref or selector candidates');
}
if (!hasEnd && !hasPath) {
return invalid('Drag end must include a non-empty ref or selector candidates');
}
return ok();
},
describe: (action) => {
const startRef = (action.params.start as { ref?: unknown })?.ref;
const endRef = (action.params.end as { ref?: unknown })?.ref;
const s = typeof startRef === 'string' && startRef.trim() ? startRef.trim() : '';
const e = typeof endRef === 'string' && endRef.trim() ? endRef.trim() : '';
if (s && e) {
const truncS = s.length > 15 ? s.slice(0, 15) + '...' : s;
const truncE = e.length > 15 ? e.slice(0, 15) + '...' : e;
return `Drag ${truncS} → ${truncE}`;
}
if (s) return `Drag from ${s.length > 20 ? s.slice(0, 20) + '...' : s}`;
if (e) return `Drag to ${e.length > 20 ? e.slice(0, 20) + '...' : e}`;
const pathEndpoints = getPathEndpoints(action.params.path);
if (pathEndpoints) {
const { startCoordinates, endCoordinates } = pathEndpoints;
return `Drag (${startCoordinates.x},${startCoordinates.y}) → (${endCoordinates.x},${endCoordinates.y})`;
}
return 'Drag';
},
run: async (ctx, action) => {
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found for drag action');
}
// Ensure element refs are fresh before locating
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });
// Get path coordinates as fallback
const pathEndpoints = getPathEndpoints(action.params.path);
const startCoordinates = pathEndpoints?.startCoordinates;
const endCoordinates = pathEndpoints?.endCoordinates;
// Locate start target
const startResult = await locateTarget(
tabId,
ctx.frameId,
action.params.start,
ctx.vars,
'start',
);
if (!startResult.ok) {
return failed(startResult.code, startResult.error);
}
// Locate end target
const endResult = await locateTarget(tabId, ctx.frameId, action.params.end, ctx.vars, 'end');
if (!endResult.ok) {
return failed(endResult.code, endResult.error);
}
// Validate we have at least one way to identify start and end
if (!startResult.ref && !startCoordinates) {
return failed('TARGET_NOT_FOUND', 'Could not resolve drag start (ref or path coordinates)');
}
if (!endResult.ref && !endCoordinates) {
return failed('TARGET_NOT_FOUND', 'Could not resolve drag end (ref or path coordinates)');
}
// Execute drag via chrome_computer tool
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.COMPUTER,
args: {
action: 'left_click_drag',
tabId,
startRef: startResult.ref,
ref: endResult.ref,
startCoordinates,
coordinates: endCoordinates,
},
});
if ((res as { isError?: boolean })?.isError) {
return failed('UNKNOWN', extractToolError(res, 'Drag action failed'));
}
// Log selector fallback after successful execution
const startFallbackUsed =
startResult.resolvedBy &&
startResult.firstCandidateType &&
startResult.resolvedBy !== 'ref' &&
startResult.resolvedBy !== startResult.firstCandidateType;
if (startFallbackUsed) {
logSelectorFallback(
ctx,
action.id,
`start:${String(startResult.firstCandidateType)}`,
`start:${String(startResult.resolvedBy)}`,
);
}
const endFallbackUsed =
endResult.resolvedBy &&
endResult.firstCandidateType &&
endResult.resolvedBy !== 'ref' &&
endResult.resolvedBy !== endResult.firstCandidateType;
if (endFallbackUsed) {
logSelectorFallback(
ctx,
action.id,
`end:${String(endResult.firstCandidateType)}`,
`end:${String(endResult.resolvedBy)}`,
);
}
return { status: 'success' };
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/extract.ts
================================================
/**
* Extract Action Handler
*
* Extracts data from the page and stores in variables:
* - selector mode: Extract text/attribute from elements
* - js mode: Execute JavaScript and capture return value
*/
import { failed, invalid, ok, tryResolveString } from '../registry';
import type { ActionHandler, BrowserWorld, JsonValue, VariableStore } from '../types';
/** Default attribute to extract */
const DEFAULT_EXTRACT_ATTR = 'textContent';
/**
* Execute extraction script in page context
*/
async function executeExtraction(
tabId: number,
frameId: number | undefined,
mode: 'selector' | 'js',
params: {
selector?: string;
attr?: string;
code?: string;
world?: BrowserWorld;
},
): Promise<{ ok: true; value: JsonValue } | { ok: false; error: string }> {
const frameIds = typeof frameId === 'number' ? [frameId] : undefined;
const world = params.world === 'ISOLATED' ? 'ISOLATED' : 'MAIN';
try {
if (mode === 'selector') {
const injected = await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
world,
func: (selector: string, attr: string) => {
const el = document.querySelector(selector);
if (!el) {
return { success: false, error: `Element not found: ${selector}` };
}
let value: JsonValue;
// Handle special attribute names
if (attr === 'text' || attr === 'textContent') {
value = el.textContent?.trim() ?? '';
} else if (attr === 'innerText') {
value = (el as HTMLElement).innerText?.trim() ?? '';
} else if (attr === 'innerHTML') {
value = el.innerHTML;
} else if (attr === 'outerHTML') {
value = el.outerHTML;
} else if (attr === 'value') {
// For form elements
value = (el as HTMLInputElement).value ?? '';
} else if (attr === 'checked') {
value = (el as HTMLInputElement).checked ?? false;
} else if (attr === 'href') {
value = (el as HTMLAnchorElement).href ?? el.getAttribute('href') ?? '';
} else if (attr === 'src') {
value = (el as HTMLImageElement).src ?? el.getAttribute('src') ?? '';
} else {
// Generic attribute
const attrValue = el.getAttribute(attr);
value = attrValue ?? '';
}
return { success: true, value };
},
args: [params.selector!, params.attr!],
});
const result = Array.isArray(injected) ? injected[0]?.result : undefined;
if (!result || typeof result !== 'object') {
return { ok: false, error: 'Extraction script returned invalid result' };
}
if (!result.success) {
return { ok: false, error: result.error || 'Extraction failed' };
}
return { ok: true, value: result.value as JsonValue };
}
// JS mode
const injected = await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
world,
func: (code: string) => {
try {
// Create function and execute
const fn = new Function(code);
const result = fn();
// Handle promises
if (result instanceof Promise) {
return result.then(
(value: unknown) => ({ success: true, value }),
(error: Error) => ({ success: false, error: error?.message || String(error) }),
);
}
return { success: true, value: result };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
},
args: [params.code!],
});
const result = Array.isArray(injected) ? injected[0]?.result : undefined;
// Handle async result
if (result instanceof Promise) {
const asyncResult = await result;
if (!asyncResult || typeof asyncResult !== 'object') {
return { ok: false, error: 'Async extraction returned invalid result' };
}
if (!asyncResult.success) {
return { ok: false, error: asyncResult.error || 'Extraction failed' };
}
return { ok: true, value: asyncResult.value as JsonValue };
}
if (!result || typeof result !== 'object') {
return { ok: false, error: 'Extraction script returned invalid result' };
}
const typedResult = result as { success: boolean; value?: unknown; error?: string };
if (!typedResult.success) {
return { ok: false, error: typedResult.error || 'Extraction failed' };
}
return { ok: true, value: typedResult.value as JsonValue };
} catch (e) {
return {
ok: false,
error: `Script execution failed: ${e instanceof Error ? e.message : String(e)}`,
};
}
}
/**
* Resolve extraction parameters
*/
function resolveExtractParams(
params: unknown,
vars: VariableStore,
): { ok: true; mode: 'selector' | 'js'; resolved: ResolvedParams } | { ok: false; error: string } {
const p = params as {
mode: 'selector' | 'js';
selector?: unknown;
attr?: unknown;
code?: string;
world?: BrowserWorld;
saveAs: string;
};
if (p.mode === 'selector') {
const selectorResult = tryResolveString(p.selector as string, vars);
if (!selectorResult.ok) return selectorResult;
const selector = selectorResult.value.trim();
if (!selector) return { ok: false, error: 'Empty selector' };
let attr = DEFAULT_EXTRACT_ATTR;
if (p.attr !== undefined && p.attr !== null) {
const attrResult = tryResolveString(p.attr as string, vars);
if (!attrResult.ok) return attrResult;
attr = attrResult.value.trim() || DEFAULT_EXTRACT_ATTR;
}
return {
ok: true,
mode: 'selector',
resolved: { selector, attr, saveAs: p.saveAs },
};
}
if (p.mode === 'js') {
if (!p.code || typeof p.code !== 'string') {
return { ok: false, error: 'JS mode requires code string' };
}
return {
ok: true,
mode: 'js',
resolved: { code: p.code, world: p.world, saveAs: p.saveAs },
};
}
return { ok: false, error: `Unknown extract mode: ${String(p.mode)}` };
}
type ResolvedParams =
| { selector: string; attr: string; saveAs: string }
| { code: string; world?: BrowserWorld; saveAs: string };
export const extractHandler: ActionHandler<'extract'> = {
type: 'extract',
validate: (action) => {
const params = action.params as {
mode: string;
selector?: unknown;
code?: string;
saveAs?: string;
};
if (params.mode !== 'selector' && params.mode !== 'js') {
return invalid(`Invalid extract mode: ${String(params.mode)}`);
}
if (!params.saveAs || typeof params.saveAs !== 'string' || params.saveAs.trim().length === 0) {
return invalid('Extract action requires a non-empty saveAs variable name');
}
if (params.mode === 'selector' && params.selector === undefined) {
return invalid('Selector mode requires a selector');
}
if (params.mode === 'js' && (!params.code || typeof params.code !== 'string')) {
return invalid('JS mode requires a code string');
}
return ok();
},
describe: (action) => {
const params = action.params as { mode: string; saveAs?: string };
const varName = params.saveAs || '?';
return params.mode === 'js' ? `Extract JS → ${varName}` : `Extract → ${varName}`;
},
run: async (ctx, action) => {
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found for extract action');
}
const resolved = resolveExtractParams(action.params, ctx.vars);
if (!resolved.ok) {
return failed('VALIDATION_ERROR', resolved.error);
}
const extractParams =
resolved.mode === 'selector'
? {
selector: (resolved.resolved as { selector: string }).selector,
attr: (resolved.resolved as { attr: string }).attr,
}
: {
code: (resolved.resolved as { code: string }).code,
world: (resolved.resolved as { world?: BrowserWorld }).world,
};
const result = await executeExtraction(tabId, ctx.frameId, resolved.mode, extractParams);
if (!result.ok) {
return failed('SCRIPT_FAILED', result.error);
}
// Store in variables
const saveAs = (resolved.resolved as { saveAs: string }).saveAs;
ctx.vars[saveAs] = result.value;
return {
status: 'success',
output: { value: result.value },
};
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/fill.ts
================================================
/**
* Fill Action Handler
*
* Handles form input actions:
* - Text input
* - File upload
* - Auto-scroll and focus
* - Selector fallback with logging
*/
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { failed, invalid, ok } from '../registry';
import type { ActionHandler } from '../types';
import {
ensureElementVisible,
logSelectorFallback,
resolveString,
selectorLocator,
sendMessageToTab,
toSelectorTarget,
} from './common';
export const fillHandler: ActionHandler<'fill'> = {
type: 'fill',
validate: (action) => {
const target = action.params.target as { ref?: string; candidates?: unknown[] };
const hasRef = typeof target?.ref === 'string' && target.ref.trim().length > 0;
const hasCandidates = Array.isArray(target?.candidates) && target.candidates.length > 0;
const hasValue = action.params.value !== undefined;
if (!hasValue) {
return invalid('Missing value parameter');
}
if (!hasRef && !hasCandidates) {
return invalid('Missing target selector or ref');
}
return ok();
},
describe: (action) => {
const value = typeof action.params.value === 'string' ? action.params.value : '(dynamic)';
const displayValue = value.length > 20 ? value.slice(0, 20) + '...' : value;
return `Fill "${displayValue}"`;
},
run: async (ctx, action) => {
const vars = ctx.vars;
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found');
}
// Ensure page is read before locating element
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
// Resolve fill value
const valueResolved = resolveString(action.params.value, vars);
if (!valueResolved.ok) {
return failed('VALIDATION_ERROR', valueResolved.error);
}
const value = valueResolved.value;
// Locate target element
const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(
action.params.target,
vars,
);
const located = await selectorLocator.locate(tabId, selectorTarget, {
frameId: ctx.frameId,
preferRef: false,
});
const frameId = located?.frameId ?? ctx.frameId;
const refToUse = located?.ref ?? selectorTarget.ref;
const cssSelector = !located?.ref ? firstCssOrAttr : undefined;
if (!refToUse && !cssSelector) {
return failed('TARGET_NOT_FOUND', 'Could not locate target element');
}
// Verify element visibility if we have a ref
if (located?.ref) {
const isVisible = await ensureElementVisible(tabId, located.ref, frameId);
if (!isVisible) {
return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');
}
}
// Check for file input and handle file upload
// Use firstCssOrAttr to check input type even when ref is available
const selectorForTypeCheck = firstCssOrAttr || cssSelector;
if (selectorForTypeCheck) {
const attrResult = await sendMessageToTab<{ value?: string }>(
tabId,
{ action: 'getAttributeForSelector', selector: selectorForTypeCheck, name: 'type' },
frameId,
);
const inputType = (attrResult.ok ? (attrResult.value?.value ?? '') : '').toLowerCase();
if (inputType === 'file') {
const uploadResult = await handleCallTool({
name: TOOL_NAMES.BROWSER.FILE_UPLOAD,
args: { selector: selectorForTypeCheck, filePath: value, tabId },
});
if ((uploadResult as { isError?: boolean })?.isError) {
const errorContent = (uploadResult as { content?: Array<{ text?: string }> })?.content;
const errorMsg = errorContent?.[0]?.text || 'File upload failed';
return failed('UNKNOWN', errorMsg);
}
// Log fallback if used
const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
const fallbackUsed =
resolvedBy &&
firstCandidateType &&
resolvedBy !== 'ref' &&
resolvedBy !== firstCandidateType;
if (fallbackUsed) {
logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
}
return { status: 'success' };
}
}
// Scroll element into view (best-effort)
if (cssSelector) {
try {
await handleCallTool({
name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
args: {
type: 'MAIN',
jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el){el.scrollIntoView({behavior:'instant',block:'center',inline:'nearest'});}}catch(e){}`,
tabId,
},
});
} catch {
// Ignore scroll errors
}
}
// Focus element (best-effort, ignore errors)
if (located?.ref) {
await sendMessageToTab(tabId, { action: 'focusByRef', ref: located.ref }, frameId);
} else if (cssSelector) {
await handleCallTool({
name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
args: {
type: 'MAIN',
jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el&&el.focus){el.focus();}}catch(e){}`,
tabId,
},
});
}
// Execute fill
const fillResult = await handleCallTool({
name: TOOL_NAMES.BROWSER.FILL,
args: {
ref: refToUse,
selector: cssSelector,
value,
frameId,
tabId,
},
});
if ((fillResult as { isError?: boolean })?.isError) {
const errorContent = (fillResult as { content?: Array<{ text?: string }> })?.content;
const errorMsg = errorContent?.[0]?.text || 'Fill action failed';
return failed('UNKNOWN', errorMsg);
}
// Log fallback if used
const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
const fallbackUsed =
resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
if (fallbackUsed) {
logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
}
return { status: 'success' };
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/http.ts
================================================
/**
* HTTP Action Handler
*
* Makes HTTP requests from the extension context.
* Supports:
* - All common HTTP methods (GET, POST, PUT, PATCH, DELETE)
* - JSON and text body types
* - Form data
* - Custom headers
* - Response validation
* - Result capture to variables
*/
import { failed, invalid, ok, tryResolveString, tryResolveValue } from '../registry';
import type {
ActionHandler,
Assignments,
HttpBody,
HttpHeaders,
HttpFormData,
HttpMethod,
HttpOkStatus,
HttpResponse,
JsonValue,
Resolvable,
VariableStore,
} from '../types';
/** Default timeout for HTTP requests */
const DEFAULT_HTTP_TIMEOUT_MS = 30000;
/** Maximum URL length */
const MAX_URL_LENGTH = 8192;
/**
* Resolve HTTP headers
*/
async function resolveHeaders(
headers: HttpHeaders | undefined,
vars: VariableStore,
): Promise<{ ok: true; resolved: Record } | { ok: false; error: string }> {
if (!headers) return { ok: true, resolved: {} };
const resolved: Record = {};
for (const [key, resolvable] of Object.entries(headers)) {
const result = tryResolveString(resolvable, vars);
if (!result.ok) {
return { ok: false, error: `Failed to resolve header "${key}": ${result.error}` };
}
resolved[key] = result.value;
}
return { ok: true, resolved };
}
/**
* Resolve form data
*/
async function resolveFormData(
formData: HttpFormData | undefined,
vars: VariableStore,
): Promise<{ ok: true; resolved: Record } | { ok: false; error: string }> {
if (!formData) return { ok: true, resolved: {} };
const resolved: Record = {};
for (const [key, resolvable] of Object.entries(formData)) {
const result = tryResolveString(resolvable, vars);
if (!result.ok) {
return { ok: false, error: `Failed to resolve form field "${key}": ${result.error}` };
}
resolved[key] = result.value;
}
return { ok: true, resolved };
}
/**
* Resolve HTTP body
*/
async function resolveBody(
body: HttpBody | undefined,
vars: VariableStore,
): Promise<
| { ok: true; contentType: string | undefined; data: string | undefined }
| { ok: false; error: string }
> {
if (!body || body.kind === 'none') {
return { ok: true, contentType: undefined, data: undefined };
}
if (body.kind === 'text') {
const textResult = tryResolveString(body.text, vars);
if (!textResult.ok) {
return { ok: false, error: `Failed to resolve body text: ${textResult.error}` };
}
let contentType = 'text/plain';
if (body.contentType) {
const ctResult = tryResolveString(body.contentType, vars);
if (!ctResult.ok) {
return { ok: false, error: `Failed to resolve content type: ${ctResult.error}` };
}
contentType = ctResult.value;
}
return { ok: true, contentType, data: textResult.value };
}
if (body.kind === 'json') {
const jsonResult = tryResolveValue(body.json, vars);
if (!jsonResult.ok) {
return { ok: false, error: `Failed to resolve JSON body: ${jsonResult.error}` };
}
return {
ok: true,
contentType: 'application/json',
data: JSON.stringify(jsonResult.value),
};
}
return { ok: false, error: `Unknown body kind: ${(body as { kind: string }).kind}` };
}
/**
* Check if status code is considered successful
*/
function isStatusOk(status: number, okStatus: HttpOkStatus | undefined): boolean {
if (!okStatus) {
// Default: 2xx is OK
return status >= 200 && status < 300;
}
if (okStatus.kind === 'range') {
return status >= okStatus.min && status <= okStatus.max;
}
if (okStatus.kind === 'list') {
return okStatus.statuses.includes(status);
}
return false;
}
/**
* Get value from result using dot/bracket path notation
*/
function getValueByPath(obj: unknown, path: string): JsonValue | undefined {
if (!path || typeof obj !== 'object' || obj === null) {
return obj as JsonValue;
}
const segments: Array = [];
const pathRegex = /([^.[\]]+)|\[(\d+)\]/g;
let match: RegExpExecArray | null;
while ((match = pathRegex.exec(path)) !== null) {
if (match[1]) {
segments.push(match[1]);
} else if (match[2]) {
segments.push(parseInt(match[2], 10));
}
}
let current: unknown = obj;
for (const segment of segments) {
if (current === null || current === undefined) return undefined;
if (typeof current !== 'object') return undefined;
current = (current as Record)[segment];
}
return current as JsonValue;
}
/**
* Apply assignments from response to variables
*/
function applyAssignments(
response: HttpResponse,
assignments: Assignments,
vars: VariableStore,
): void {
for (const [varName, path] of Object.entries(assignments)) {
const value = getValueByPath(response, path);
if (value !== undefined) {
vars[varName] = value;
}
}
}
export const httpHandler: ActionHandler<'http'> = {
type: 'http',
validate: (action) => {
const params = action.params;
if (params.url === undefined) {
return invalid('HTTP action requires a URL');
}
if (params.method !== undefined) {
const validMethods: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
if (!validMethods.includes(params.method)) {
return invalid(`Invalid HTTP method: ${String(params.method)}`);
}
}
return ok();
},
describe: (action) => {
const method = action.params.method || 'GET';
const url = typeof action.params.url === 'string' ? action.params.url : '(dynamic)';
const displayUrl = url.length > 40 ? url.slice(0, 40) + '...' : url;
return `${method} ${displayUrl}`;
},
run: async (ctx, action) => {
const params = action.params;
const method: HttpMethod = params.method || 'GET';
// Resolve URL
const urlResult = tryResolveString(params.url, ctx.vars);
if (!urlResult.ok) {
return failed('VALIDATION_ERROR', `Failed to resolve URL: ${urlResult.error}`);
}
const url = urlResult.value.trim();
if (!url) {
return failed('VALIDATION_ERROR', 'URL is empty');
}
if (url.length > MAX_URL_LENGTH) {
return failed('VALIDATION_ERROR', `URL exceeds maximum length of ${MAX_URL_LENGTH}`);
}
// Validate URL format
try {
new URL(url);
} catch {
return failed('VALIDATION_ERROR', `Invalid URL format: ${url}`);
}
// Resolve headers
const headersResult = await resolveHeaders(params.headers, ctx.vars);
if (!headersResult.ok) {
return failed('VALIDATION_ERROR', headersResult.error);
}
// Resolve body
const bodyResult = await resolveBody(params.body, ctx.vars);
if (!bodyResult.ok) {
return failed('VALIDATION_ERROR', bodyResult.error);
}
// Resolve form data (alternative to body)
const formDataResult = await resolveFormData(params.formData, ctx.vars);
if (!formDataResult.ok) {
return failed('VALIDATION_ERROR', formDataResult.error);
}
// Build request
const headers: Record = { ...headersResult.resolved };
let requestBody: string | FormData | undefined;
if (Object.keys(formDataResult.resolved).length > 0) {
// Use form data
const formData = new FormData();
for (const [key, value] of Object.entries(formDataResult.resolved)) {
formData.append(key, value);
}
requestBody = formData as unknown as string; // FormData handled by fetch
} else if (bodyResult.data !== undefined) {
// Use body
requestBody = bodyResult.data;
if (bodyResult.contentType && !headers['Content-Type']) {
headers['Content-Type'] = bodyResult.contentType;
}
}
// Execute request
const timeoutMs = action.policy?.timeout?.ms ?? DEFAULT_HTTP_TIMEOUT_MS;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const fetchOptions: RequestInit = {
method,
headers,
signal: controller.signal,
};
if (requestBody !== undefined && method !== 'GET' && method !== 'DELETE') {
fetchOptions.body = requestBody;
}
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
// Parse response
const responseHeaders: Record = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
let responseBody: JsonValue | string | null = null;
const contentType = response.headers.get('content-type') || '';
try {
if (contentType.includes('application/json')) {
responseBody = (await response.json()) as JsonValue;
} else {
responseBody = await response.text();
}
} catch {
responseBody = null;
}
const httpResponse: HttpResponse = {
url: response.url,
status: response.status,
headers: responseHeaders,
body: responseBody,
};
// Check status
if (!isStatusOk(response.status, params.okStatus)) {
return failed(
'NETWORK_REQUEST_FAILED',
`HTTP ${response.status}: ${response.statusText || 'Request failed'}`,
);
}
// Store response if saveAs specified
if (params.saveAs) {
ctx.vars[params.saveAs] = httpResponse as unknown as JsonValue;
}
// Apply assignments
if (params.assign) {
applyAssignments(httpResponse, params.assign, ctx.vars);
}
return {
status: 'success',
output: { response: httpResponse },
};
} catch (e) {
clearTimeout(timeoutId);
if (e instanceof Error && e.name === 'AbortError') {
return failed('TIMEOUT', `HTTP request timed out after ${timeoutMs}ms`);
}
return failed(
'NETWORK_REQUEST_FAILED',
`HTTP request failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/index.ts
================================================
/**
* Action Handlers Registry
*
* Central registration point for all action handlers.
* Provides factory function to create a fully-configured ActionRegistry
* with all replay handlers registered.
*/
import { ActionRegistry, createActionRegistry } from '../registry';
import { assertHandler } from './assert';
import { clickHandler, dblclickHandler } from './click';
import { foreachHandler, ifHandler, switchFrameHandler, whileHandler } from './control-flow';
import { delayHandler } from './delay';
import { setAttributeHandler, triggerEventHandler } from './dom';
import { dragHandler } from './drag';
import { extractHandler } from './extract';
import { fillHandler } from './fill';
import { httpHandler } from './http';
import { keyHandler } from './key';
import { navigateHandler } from './navigate';
import { screenshotHandler } from './screenshot';
import { scriptHandler } from './script';
import { scrollHandler } from './scroll';
import { closeTabHandler, handleDownloadHandler, openTabHandler, switchTabHandler } from './tabs';
import { waitHandler } from './wait';
// Re-export individual handlers for direct access
export { assertHandler } from './assert';
export { clickHandler, dblclickHandler } from './click';
export { foreachHandler, ifHandler, switchFrameHandler, whileHandler } from './control-flow';
export { delayHandler } from './delay';
export { setAttributeHandler, triggerEventHandler } from './dom';
export { dragHandler } from './drag';
export { extractHandler } from './extract';
export { fillHandler } from './fill';
export { httpHandler } from './http';
export { keyHandler } from './key';
export { navigateHandler } from './navigate';
export { screenshotHandler } from './screenshot';
export { scriptHandler } from './script';
export { scrollHandler } from './scroll';
export { closeTabHandler, handleDownloadHandler, openTabHandler, switchTabHandler } from './tabs';
export { waitHandler } from './wait';
// Re-export common utilities
export * from './common';
/**
* All available action handlers for replay
*
* Organized by category:
* - Navigation: navigate
* - Interaction: click, dblclick, fill, key, scroll, drag
* - Timing: wait, delay
* - Validation: assert
* - Data: extract, script, http, screenshot
* - DOM Tools: triggerEvent, setAttribute
* - Tabs: openTab, switchTab, closeTab, handleDownload
* - Control Flow: if, foreach, while, switchFrame
*
* TODO: Add remaining handlers:
* - loopElements, executeFlow (advanced control flow)
*/
const ALL_HANDLERS = [
// Navigation
navigateHandler,
// Interaction
clickHandler,
dblclickHandler,
fillHandler,
keyHandler,
scrollHandler,
dragHandler,
// Timing
waitHandler,
delayHandler,
// Validation
assertHandler,
// Data
extractHandler,
scriptHandler,
httpHandler,
screenshotHandler,
// DOM Tools
triggerEventHandler,
setAttributeHandler,
// Tabs
openTabHandler,
switchTabHandler,
closeTabHandler,
handleDownloadHandler,
// Control Flow
ifHandler,
foreachHandler,
whileHandler,
switchFrameHandler,
] as const;
/**
* Register all replay handlers to an ActionRegistry instance
*/
export function registerReplayHandlers(registry: ActionRegistry): void {
// Register each handler individually to satisfy TypeScript's type checker
registry.register(navigateHandler, { override: true });
registry.register(clickHandler, { override: true });
registry.register(dblclickHandler, { override: true });
registry.register(fillHandler, { override: true });
registry.register(keyHandler, { override: true });
registry.register(scrollHandler, { override: true });
registry.register(dragHandler, { override: true });
registry.register(waitHandler, { override: true });
registry.register(delayHandler, { override: true });
registry.register(assertHandler, { override: true });
registry.register(extractHandler, { override: true });
registry.register(scriptHandler, { override: true });
registry.register(httpHandler, { override: true });
registry.register(screenshotHandler, { override: true });
registry.register(triggerEventHandler, { override: true });
registry.register(setAttributeHandler, { override: true });
registry.register(openTabHandler, { override: true });
registry.register(switchTabHandler, { override: true });
registry.register(closeTabHandler, { override: true });
registry.register(handleDownloadHandler, { override: true });
registry.register(ifHandler, { override: true });
registry.register(foreachHandler, { override: true });
registry.register(whileHandler, { override: true });
registry.register(switchFrameHandler, { override: true });
}
/**
* Create a new ActionRegistry with all replay handlers registered
*
* This is the primary entry point for creating an action execution context.
*
* @example
* ```ts
* const registry = createReplayActionRegistry();
*
* const result = await registry.execute(ctx, {
* id: 'action-1',
* type: 'click',
* params: { target: { candidates: [...] } },
* });
* ```
*/
export function createReplayActionRegistry(): ActionRegistry {
const registry = createActionRegistry();
registerReplayHandlers(registry);
return registry;
}
/**
* Get list of supported action types
*/
export function getSupportedActionTypes(): ReadonlyArray {
return ALL_HANDLERS.map((h) => h.type);
}
/**
* Check if an action type is supported
*/
export function isActionTypeSupported(type: string): boolean {
return ALL_HANDLERS.some((h) => h.type === type);
}
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/key.ts
================================================
/**
* Key Action Handler
*
* Handles keyboard input:
* - Resolves key sequences via variables/templates
* - Optionally focuses a target element before sending keys
* - Dispatches keyboard events via the keyboard tool
*/
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { failed, invalid, ok } from '../registry';
import type { ActionHandler, ElementTarget } from '../types';
import {
ensureElementVisible,
logSelectorFallback,
resolveString,
selectorLocator,
sendMessageToTab,
toSelectorTarget,
} from './common';
/** Extract error text from tool result */
function extractToolError(result: unknown, fallback: string): string {
const content = (result as { content?: Array<{ text?: string }> })?.content;
return content?.find((c) => typeof c?.text === 'string')?.text || fallback;
}
/** Check if target has valid selector specification */
function hasTargetSpec(target: unknown): boolean {
if (!target || typeof target !== 'object') return false;
const t = target as { ref?: unknown; candidates?: unknown };
const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0;
const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0;
return hasRef || hasCandidates;
}
/** Strip frame prefix from composite selector */
function stripCompositeSelector(selector: string): string {
const raw = String(selector || '').trim();
if (!raw || !raw.includes('|>')) return raw;
const parts = raw
.split('|>')
.map((p) => p.trim())
.filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : raw;
}
export const keyHandler: ActionHandler<'key'> = {
type: 'key',
validate: (action) => {
if (action.params.keys === undefined) {
return invalid('Missing keys parameter');
}
if (action.params.target !== undefined && !hasTargetSpec(action.params.target)) {
return invalid('Target must include a non-empty ref or selector candidates');
}
return ok();
},
describe: (action) => {
const keys = typeof action.params.keys === 'string' ? action.params.keys : '(dynamic)';
const display = keys.length > 30 ? keys.slice(0, 30) + '...' : keys;
return `Keys "${display}"`;
},
run: async (ctx, action) => {
const vars = ctx.vars;
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found for key action');
}
// Resolve keys string
const keysResolved = resolveString(action.params.keys, vars);
if (!keysResolved.ok) {
return failed('VALIDATION_ERROR', keysResolved.error);
}
const keys = keysResolved.value.trim();
if (!keys) {
return failed('VALIDATION_ERROR', 'Keys string is empty');
}
let frameId = ctx.frameId;
let selectorForTool: string | undefined;
let firstCandidateType: string | undefined;
let resolvedBy: string | undefined;
// Handle optional target focusing
const target = action.params.target as ElementTarget | undefined;
if (target) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });
const {
selectorTarget,
firstCandidateType: firstType,
firstCssOrAttr,
} = toSelectorTarget(target, vars);
firstCandidateType = firstType;
const located = await selectorLocator.locate(tabId, selectorTarget, {
frameId: ctx.frameId,
preferRef: false,
});
frameId = located?.frameId ?? ctx.frameId;
const refToUse = located?.ref ?? selectorTarget.ref;
if (!refToUse && !firstCssOrAttr) {
return failed('TARGET_NOT_FOUND', 'Could not locate target element for key action');
}
resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
// Only verify visibility for freshly located refs (not stale refs from payload)
if (located?.ref) {
const visible = await ensureElementVisible(tabId, located.ref, frameId);
if (!visible) {
return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');
}
const focusResult = await sendMessageToTab<{ success?: boolean; error?: string }>(
tabId,
{ action: 'focusByRef', ref: located.ref },
frameId,
);
if (!focusResult.ok || focusResult.value?.success !== true) {
const focusErr = focusResult.ok ? focusResult.value?.error : focusResult.error;
if (!firstCssOrAttr) {
return failed(
'TARGET_NOT_FOUND',
`Failed to focus target element: ${focusErr || 'ref may be stale'}`,
);
}
ctx.log(`focusByRef failed; falling back to selector: ${focusErr}`, 'warn');
}
// Try to resolve ref to CSS selector for tool
const resolved = await sendMessageToTab<{
success?: boolean;
selector?: string;
error?: string;
}>(tabId, { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: located.ref }, frameId);
if (
resolved.ok &&
resolved.value?.success !== false &&
typeof resolved.value?.selector === 'string'
) {
const sel = resolved.value.selector.trim();
if (sel) selectorForTool = sel;
}
}
// Fallback to CSS/attr selector
if (!selectorForTool && firstCssOrAttr) {
const stripped = stripCompositeSelector(firstCssOrAttr);
if (stripped) selectorForTool = stripped;
}
}
// Execute keyboard input
const keyboardResult = await handleCallTool({
name: TOOL_NAMES.BROWSER.KEYBOARD,
args: {
keys,
selector: selectorForTool,
selectorType: selectorForTool ? 'css' : undefined,
tabId,
frameId,
},
});
if ((keyboardResult as { isError?: boolean })?.isError) {
return failed('UNKNOWN', extractToolError(keyboardResult, 'Keyboard input failed'));
}
// Log fallback after successful execution
const fallbackUsed =
resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
if (fallbackUsed) {
logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
}
return { status: 'success' };
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts
================================================
/**
* Navigate Action Handler
*
* Handles page navigation actions:
* - Navigate to URL
* - Page refresh
* - Wait for navigation completion
*/
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { ENGINE_CONSTANTS } from '../../engine/constants';
import { ensureReadPageIfWeb, waitForNavigationDone } from '../../engine/policies/wait';
import { failed, invalid, ok } from '../registry';
import type { ActionHandler } from '../types';
import { clampInt, readTabUrl, resolveString } from './common';
export const navigateHandler: ActionHandler<'navigate'> = {
type: 'navigate',
validate: (action) => {
const hasRefresh = action.params.refresh === true;
const hasUrl = action.params.url !== undefined;
return hasRefresh || hasUrl ? ok() : invalid('Missing url or refresh parameter');
},
describe: (action) => {
if (action.params.refresh) return 'Refresh page';
const url = typeof action.params.url === 'string' ? action.params.url : '(dynamic)';
return `Navigate to ${url}`;
},
run: async (ctx, action) => {
const vars = ctx.vars;
const tabId = ctx.tabId;
// Check if StepRunner owns nav-wait (skip internal nav-wait logic)
const skipNavWait = ctx.execution?.skipNavWait === true;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found');
}
// Only read beforeUrl and calculate waitMs if we need to do nav-wait
const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId);
const waitMs = skipNavWait
? 0
: clampInt(
action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS,
0,
ENGINE_CONSTANTS.MAX_WAIT_MS,
);
// Handle page refresh
if (action.params.refresh) {
const result = await handleCallTool({
name: TOOL_NAMES.BROWSER.NAVIGATE,
args: { refresh: true, tabId },
});
if ((result as { isError?: boolean })?.isError) {
const errorContent = (result as { content?: Array<{ text?: string }> })?.content;
const errorMsg = errorContent?.[0]?.text || 'Page refresh failed';
return failed('NAVIGATION_FAILED', errorMsg);
}
// Skip nav-wait if StepRunner handles it
if (!skipNavWait) {
await waitForNavigationDone(beforeUrl, waitMs);
await ensureReadPageIfWeb();
}
return { status: 'success' };
}
// Handle URL navigation
const urlResolved = resolveString(action.params.url, vars);
if (!urlResolved.ok) {
return failed('VALIDATION_ERROR', urlResolved.error);
}
const url = urlResolved.value.trim();
if (!url) {
return failed('VALIDATION_ERROR', 'URL is empty');
}
const result = await handleCallTool({
name: TOOL_NAMES.BROWSER.NAVIGATE,
args: { url, tabId },
});
if ((result as { isError?: boolean })?.isError) {
const errorContent = (result as { content?: Array<{ text?: string }> })?.content;
const errorMsg = errorContent?.[0]?.text || `Navigation to ${url} failed`;
return failed('NAVIGATION_FAILED', errorMsg);
}
// Skip nav-wait if StepRunner handles it
if (!skipNavWait) {
await waitForNavigationDone(beforeUrl, waitMs);
await ensureReadPageIfWeb();
}
return { status: 'success' };
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/screenshot.ts
================================================
/**
* Screenshot Action Handler
*
* Captures screenshots and optionally stores base64 data in variables.
* Supports full page, selector-based, and viewport screenshots.
*/
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { failed, invalid, ok } from '../registry';
import type { ActionHandler } from '../types';
import { resolveString } from './common';
/** Extract text content from tool result */
function extractToolText(result: unknown): string | undefined {
const content = (result as { content?: Array<{ type?: string; text?: string }> })?.content;
const text = content?.find((c) => c?.type === 'text' && typeof c.text === 'string')?.text;
return typeof text === 'string' && text.trim() ? text : undefined;
}
export const screenshotHandler: ActionHandler<'screenshot'> = {
type: 'screenshot',
validate: (action) => {
const saveAs = action.params.saveAs;
if (saveAs !== undefined && (!saveAs || String(saveAs).trim().length === 0)) {
return invalid('saveAs must be a non-empty variable name when provided');
}
return ok();
},
describe: (action) => {
if (action.params.fullPage) return 'Screenshot (full page)';
if (typeof action.params.selector === 'string') {
const sel =
action.params.selector.length > 30
? action.params.selector.slice(0, 30) + '...'
: action.params.selector;
return `Screenshot: ${sel}`;
}
if (action.params.selector) return 'Screenshot (dynamic selector)';
return 'Screenshot';
},
run: async (ctx, action) => {
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found for screenshot action');
}
// Resolve optional selector
let selector: string | undefined;
if (action.params.selector !== undefined) {
const resolved = resolveString(action.params.selector, ctx.vars);
if (!resolved.ok) return failed('VALIDATION_ERROR', resolved.error);
const s = resolved.value.trim();
if (s) selector = s;
}
// Call screenshot tool
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.SCREENSHOT,
args: {
name: 'workflow',
storeBase64: true,
fullPage: action.params.fullPage === true,
selector,
tabId,
},
});
if ((res as { isError?: boolean })?.isError) {
return failed('UNKNOWN', extractToolText(res) || 'Screenshot failed');
}
// Parse response
const text = extractToolText(res);
if (!text) {
return failed('UNKNOWN', 'Screenshot tool returned an empty response');
}
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
return failed('UNKNOWN', 'Screenshot tool returned invalid JSON');
}
const base64Data = (payload as { base64Data?: unknown })?.base64Data;
if (typeof base64Data !== 'string' || base64Data.length === 0) {
return failed('UNKNOWN', 'Screenshot tool returned empty base64Data');
}
// Store in variables if saveAs specified
if (action.params.saveAs) {
ctx.vars[action.params.saveAs] = base64Data;
}
return { status: 'success', output: { base64Data } };
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/script.ts
================================================
/**
* Script Action Handler
*
* Executes custom JavaScript in the page context.
* Supports:
* - MAIN or ISOLATED world execution
* - Argument passing with variable resolution
* - Result capture to variables
* - Assignment mapping from result paths
*/
import { failed, invalid, ok, tryResolveValue } from '../registry';
import type {
ActionHandler,
Assignments,
BrowserWorld,
JsonValue,
Resolvable,
VariableStore,
} from '../types';
/** Maximum code length to prevent abuse */
const MAX_CODE_LENGTH = 100000;
/**
* Resolve script arguments
*/
function resolveArgs(
args: Record> | undefined,
vars: VariableStore,
): { ok: true; resolved: Record } | { ok: false; error: string } {
if (!args) return { ok: true, resolved: {} };
const resolved: Record = {};
for (const [key, resolvable] of Object.entries(args)) {
const result = tryResolveValue(resolvable, vars);
if (!result.ok) {
return { ok: false, error: `Failed to resolve arg "${key}": ${result.error}` };
}
resolved[key] = result.value;
}
return { ok: true, resolved };
}
/**
* Get value from result using dot/bracket path notation
*/
function getValueByPath(obj: unknown, path: string): JsonValue | undefined {
if (!path || typeof obj !== 'object' || obj === null) {
return obj as JsonValue;
}
// Parse path: supports "data.items[0].name" style
const segments: Array = [];
const pathRegex = /([^.[\]]+)|\[(\d+)\]/g;
let match: RegExpExecArray | null;
while ((match = pathRegex.exec(path)) !== null) {
if (match[1]) {
segments.push(match[1]);
} else if (match[2]) {
segments.push(parseInt(match[2], 10));
}
}
let current: unknown = obj;
for (const segment of segments) {
if (current === null || current === undefined) return undefined;
if (typeof current !== 'object') return undefined;
current = (current as Record)[segment];
}
return current as JsonValue;
}
/**
* Apply assignments from result to variables
*/
function applyAssignments(result: JsonValue, assignments: Assignments, vars: VariableStore): void {
for (const [varName, path] of Object.entries(assignments)) {
const value = getValueByPath(result, path);
if (value !== undefined) {
vars[varName] = value;
}
}
}
/**
* Execute script in page context
*/
async function executeScript(
tabId: number,
frameId: number | undefined,
code: string,
args: Record,
world: BrowserWorld,
): Promise<{ ok: true; result: JsonValue } | { ok: false; error: string }> {
const frameIds = typeof frameId === 'number' ? [frameId] : undefined;
try {
const injected = await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
world: world === 'ISOLATED' ? 'ISOLATED' : 'MAIN',
func: (scriptCode: string, scriptArgs: Record) => {
try {
// Create function with args available
const argNames = Object.keys(scriptArgs);
const argValues = Object.values(scriptArgs);
// Wrap code to return result
const wrappedCode = `
return (function(${argNames.join(', ')}) {
${scriptCode}
})(${argNames.map((_, i) => `arguments[${i}]`).join(', ')});
`;
const fn = new Function(...argNames, wrappedCode);
const result = fn(...argValues);
// Handle promises
if (result instanceof Promise) {
return result.then(
(value: unknown) => ({ success: true, result: value }),
(error: Error) => ({ success: false, error: error?.message || String(error) }),
);
}
return { success: true, result };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
},
args: [code, args],
});
const scriptResult = Array.isArray(injected) ? injected[0]?.result : undefined;
// Handle async result
if (scriptResult instanceof Promise) {
const asyncResult = await scriptResult;
if (!asyncResult || typeof asyncResult !== 'object') {
return { ok: false, error: 'Async script returned invalid result' };
}
if (!asyncResult.success) {
return { ok: false, error: asyncResult.error || 'Script failed' };
}
return { ok: true, result: asyncResult.result as JsonValue };
}
if (!scriptResult || typeof scriptResult !== 'object') {
return { ok: false, error: 'Script returned invalid result' };
}
const typedResult = scriptResult as { success: boolean; result?: unknown; error?: string };
if (!typedResult.success) {
return { ok: false, error: typedResult.error || 'Script failed' };
}
return { ok: true, result: typedResult.result as JsonValue };
} catch (e) {
return {
ok: false,
error: `Script execution failed: ${e instanceof Error ? e.message : String(e)}`,
};
}
}
export const scriptHandler: ActionHandler<'script'> = {
type: 'script',
validate: (action) => {
const params = action.params;
if (!params.code || typeof params.code !== 'string') {
return invalid('Script action requires a code string');
}
if (params.code.length > MAX_CODE_LENGTH) {
return invalid(`Script code exceeds maximum length of ${MAX_CODE_LENGTH} characters`);
}
if (params.world !== undefined && params.world !== 'MAIN' && params.world !== 'ISOLATED') {
return invalid(`Invalid world: ${String(params.world)}`);
}
if (params.when !== undefined && params.when !== 'before' && params.when !== 'after') {
return invalid(`Invalid timing: ${String(params.when)}`);
}
return ok();
},
describe: (action) => {
const world = action.params.world === 'ISOLATED' ? '[isolated]' : '';
const timing = action.params.when ? `(${action.params.when})` : '';
return `Script ${world}${timing}`.trim();
},
run: async (ctx, action) => {
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found for script action');
}
const params = action.params;
const world: BrowserWorld = params.world || 'MAIN';
// Resolve arguments
const argsResult = resolveArgs(params.args, ctx.vars);
if (!argsResult.ok) {
return failed('VALIDATION_ERROR', argsResult.error);
}
// Execute script
const result = await executeScript(tabId, ctx.frameId, params.code, argsResult.resolved, world);
if (!result.ok) {
return failed('SCRIPT_FAILED', result.error);
}
// Store result if saveAs specified
if (params.saveAs) {
ctx.vars[params.saveAs] = result.result;
}
// Apply assignments if specified
if (params.assign) {
applyAssignments(result.result, params.assign, ctx.vars);
}
return {
status: 'success',
output: { result: result.result },
};
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/scroll.ts
================================================
/**
* Scroll Action Handler
*
* Supports three scroll modes:
* - offset: Scroll the window to absolute coordinates
* - element: Scroll an element into view
* - container: Scroll within a container element
*/
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { failed, invalid, ok, tryResolveNumber } from '../registry';
import type { ActionHandler, ElementTarget } from '../types';
import { logSelectorFallback, selectorLocator, sendMessageToTab, toSelectorTarget } from './common';
/** Check if target has valid selector specification */
function hasTargetSpec(target: unknown): boolean {
if (!target || typeof target !== 'object') return false;
const t = target as { ref?: unknown; candidates?: unknown };
const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0;
const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0;
return hasRef || hasCandidates;
}
/** Strip frame prefix from composite selector */
function stripCompositeSelector(selector: string): string {
const raw = String(selector || '').trim();
if (!raw || !raw.includes('|>')) return raw;
const parts = raw
.split('|>')
.map((p) => p.trim())
.filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : raw;
}
/** Format offset value for description */
function describeOffset(v: unknown): string {
return typeof v === 'number' && Number.isFinite(v) ? String(v) : '(dynamic)';
}
export const scrollHandler: ActionHandler<'scroll'> = {
type: 'scroll',
validate: (action) => {
const mode = action.params.mode;
if (mode !== 'offset' && mode !== 'element' && mode !== 'container') {
return invalid(`Unsupported scroll mode: ${String(mode)}`);
}
if ((mode === 'element' || mode === 'container') && !hasTargetSpec(action.params.target)) {
return invalid(`Scroll mode "${mode}" requires a target ref or selector candidates`);
}
return ok();
},
describe: (action) => {
const mode = action.params.mode;
if (mode === 'offset') {
const x = describeOffset(action.params.offset?.x);
const y = describeOffset(action.params.offset?.y);
return `Scroll window to x=${x}, y=${y}`;
}
if (mode === 'container') return 'Scroll container';
return 'Scroll to element';
},
run: async (ctx, action) => {
const vars = ctx.vars;
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found for scroll action');
}
const mode = action.params.mode;
// ----------------------------
// Offset mode: window scroll
// ----------------------------
if (mode === 'offset') {
let top: number | undefined;
let left: number | undefined;
if (action.params.offset?.y !== undefined) {
const yResolved = tryResolveNumber(action.params.offset.y, vars);
if (!yResolved.ok) return failed('VALIDATION_ERROR', yResolved.error);
top = yResolved.value;
}
if (action.params.offset?.x !== undefined) {
const xResolved = tryResolveNumber(action.params.offset.x, vars);
if (!xResolved.ok) return failed('VALIDATION_ERROR', xResolved.error);
left = xResolved.value;
}
const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined;
try {
const injected = await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
world: 'MAIN',
func: (t: number | null, l: number | null) => {
try {
const hasTop = typeof t === 'number' && Number.isFinite(t);
const hasLeft = typeof l === 'number' && Number.isFinite(l);
if (!hasTop && !hasLeft) return true;
window.scrollTo({
top: hasTop ? t : window.scrollY,
left: hasLeft ? l : window.scrollX,
behavior: 'auto',
});
return true;
} catch {
return false;
}
},
args: [top ?? null, left ?? null],
});
const result = Array.isArray(injected) ? injected[0]?.result : undefined;
if (result !== true) {
return failed('SCRIPT_FAILED', 'Window scroll script returned failure');
}
} catch (e) {
return failed(
'SCRIPT_FAILED',
`Failed to scroll window: ${e instanceof Error ? e.message : String(e)}`,
);
}
return { status: 'success' };
}
// ----------------------------
// Element/Container mode
// ----------------------------
const target = action.params.target as ElementTarget | undefined;
if (!target) {
return failed('VALIDATION_ERROR', `Scroll mode "${mode}" requires a target`);
}
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });
const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(target, vars);
const located = await selectorLocator.locate(tabId, selectorTarget, {
frameId: ctx.frameId,
preferRef: false,
});
const frameId = located?.frameId ?? ctx.frameId;
const refToUse = located?.ref ?? selectorTarget.ref;
// Resolve selector from ref or fallback
let selector: string | undefined;
if (refToUse) {
const resolved = await sendMessageToTab<{ success?: boolean; selector?: string }>(
tabId,
{ action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: refToUse },
frameId,
);
if (
resolved.ok &&
resolved.value?.success !== false &&
typeof resolved.value?.selector === 'string'
) {
const sel = resolved.value.selector.trim();
if (sel) selector = sel;
}
}
if (!selector && firstCssOrAttr) {
const stripped = stripCompositeSelector(firstCssOrAttr);
if (stripped) selector = stripped;
}
if (!selector) {
return failed('TARGET_NOT_FOUND', 'Could not resolve a CSS selector for the scroll target');
}
// Resolve offset for container mode
let scrollTop: number | undefined;
let scrollLeft: number | undefined;
if (mode === 'container') {
if (action.params.offset?.y !== undefined) {
const yResolved = tryResolveNumber(action.params.offset.y, vars);
if (!yResolved.ok) return failed('VALIDATION_ERROR', yResolved.error);
scrollTop = yResolved.value;
}
if (action.params.offset?.x !== undefined) {
const xResolved = tryResolveNumber(action.params.offset.x, vars);
if (!xResolved.ok) return failed('VALIDATION_ERROR', xResolved.error);
scrollLeft = xResolved.value;
}
}
// Execute scroll script
try {
const frameIds = typeof frameId === 'number' ? [frameId] : undefined;
const injected = await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
world: 'MAIN',
func: (
sel: string,
scrollMode: 'element' | 'container',
top: number | null,
left: number | null,
) => {
const el = document.querySelector(sel) as HTMLElement | null;
if (!el) return false;
if (scrollMode === 'element') {
el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' });
return true;
}
// Container scroll
const hasTop = typeof top === 'number' && Number.isFinite(top);
const hasLeft = typeof left === 'number' && Number.isFinite(left);
if (typeof el.scrollTo === 'function') {
el.scrollTo({
top: hasTop ? top : el.scrollTop,
left: hasLeft ? left : el.scrollLeft,
behavior: 'instant',
});
} else {
if (hasTop) el.scrollTop = top;
if (hasLeft) el.scrollLeft = left;
}
return true;
},
args: [selector, mode, scrollTop ?? null, scrollLeft ?? null],
});
const result = Array.isArray(injected) ? injected[0]?.result : undefined;
if (result !== true) {
return failed('TARGET_NOT_FOUND', `Scroll target not found: ${selector}`);
}
} catch (e) {
return failed(
'SCRIPT_FAILED',
`Failed to execute scroll: ${e instanceof Error ? e.message : String(e)}`,
);
}
// Log fallback if used
const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
const fallbackUsed =
resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
if (fallbackUsed) {
logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
}
return { status: 'success' };
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/tabs.ts
================================================
/**
* Tab Management Action Handlers
*
* Handles browser tab operations:
* - openTab: Open a new tab or window
* - switchTab: Switch to a different tab
* - closeTab: Close tab(s)
* - handleDownload: Monitor and capture download information
*/
import { failed, invalid, ok, tryResolveString } from '../registry';
import type { ActionHandler, DownloadInfo, DownloadState, VariableStore } from '../types';
/** Default timeout for tab operations */
const DEFAULT_TAB_TIMEOUT_MS = 10000;
/** Default timeout for download operations */
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 60000;
// ================================
// openTab Handler
// ================================
export const openTabHandler: ActionHandler<'openTab'> = {
type: 'openTab',
validate: () => ok(),
describe: (action) => {
const url = typeof action.params.url === 'string' ? action.params.url : undefined;
const displayUrl = url ? (url.length > 30 ? url.slice(0, 30) + '...' : url) : 'blank';
return action.params.newWindow ? `Open window: ${displayUrl}` : `Open tab: ${displayUrl}`;
},
run: async (ctx, action) => {
const params = action.params;
// Resolve URL if provided
let url: string | undefined;
if (params.url !== undefined) {
const urlResult = tryResolveString(params.url, ctx.vars);
if (!urlResult.ok) {
return failed('VALIDATION_ERROR', `Failed to resolve URL: ${urlResult.error}`);
}
url = urlResult.value.trim() || undefined;
}
try {
let tabId: number;
if (params.newWindow) {
// Create new window
const window = await chrome.windows.create({
url: url || 'about:blank',
focused: true,
});
const tab = window?.tabs?.[0];
if (!tab?.id) {
return failed('TAB_NOT_FOUND', 'Failed to create new window');
}
tabId = tab.id;
} else {
// Create new tab in current window
const tab = await chrome.tabs.create({
url: url || 'about:blank',
active: true,
});
if (!tab.id) {
return failed('TAB_NOT_FOUND', 'Failed to create new tab');
}
tabId = tab.id;
}
// Wait for tab to be ready if URL was specified
if (url) {
await waitForTabComplete(tabId, DEFAULT_TAB_TIMEOUT_MS);
}
// Return newTabId for ctx.tabId sync
return { status: 'success', newTabId: tabId };
} catch (e) {
return failed('UNKNOWN', `Failed to open tab: ${e instanceof Error ? e.message : String(e)}`);
}
},
};
// ================================
// switchTab Handler
// ================================
export const switchTabHandler: ActionHandler<'switchTab'> = {
type: 'switchTab',
validate: (action) => {
const params = action.params;
const hasTabId = params.tabId !== undefined;
const hasUrlContains = params.urlContains !== undefined;
const hasTitleContains = params.titleContains !== undefined;
if (!hasTabId && !hasUrlContains && !hasTitleContains) {
return invalid('switchTab requires tabId, urlContains, or titleContains');
}
return ok();
},
describe: (action) => {
if (action.params.tabId !== undefined) {
return `Switch to tab #${action.params.tabId}`;
}
if (action.params.urlContains !== undefined) {
return `Switch tab (URL contains)`;
}
if (action.params.titleContains !== undefined) {
return `Switch tab (title contains)`;
}
return 'Switch tab';
},
run: async (ctx, action) => {
const params = action.params;
try {
let targetTabId: number | undefined;
if (params.tabId !== undefined) {
targetTabId = params.tabId;
} else {
// Find tab by URL or title
const tabs = await chrome.tabs.query({});
if (params.urlContains !== undefined) {
const urlResult = tryResolveString(params.urlContains, ctx.vars);
if (!urlResult.ok) {
return failed('VALIDATION_ERROR', `Failed to resolve urlContains: ${urlResult.error}`);
}
const urlPattern = urlResult.value.trim().toLowerCase();
// Empty pattern is invalid
if (!urlPattern) {
return failed('VALIDATION_ERROR', 'urlContains pattern cannot be empty');
}
const matchingTab = tabs.find(
(tab) => tab.url && tab.url.toLowerCase().includes(urlPattern),
);
targetTabId = matchingTab?.id;
} else if (params.titleContains !== undefined) {
const titleResult = tryResolveString(params.titleContains, ctx.vars);
if (!titleResult.ok) {
return failed(
'VALIDATION_ERROR',
`Failed to resolve titleContains: ${titleResult.error}`,
);
}
const titlePattern = titleResult.value.trim().toLowerCase();
// Empty pattern is invalid
if (!titlePattern) {
return failed('VALIDATION_ERROR', 'titleContains pattern cannot be empty');
}
const matchingTab = tabs.find(
(tab) => tab.title && tab.title.toLowerCase().includes(titlePattern),
);
targetTabId = matchingTab?.id;
}
}
if (targetTabId === undefined) {
return failed('TAB_NOT_FOUND', 'No matching tab found');
}
// Activate the tab
await chrome.tabs.update(targetTabId, { active: true });
// Focus the window containing the tab
const tab = await chrome.tabs.get(targetTabId);
if (tab.windowId) {
await chrome.windows.update(tab.windowId, { focused: true });
}
// Return newTabId for ctx.tabId sync
return { status: 'success', newTabId: targetTabId };
} catch (e) {
return failed(
'UNKNOWN',
`Failed to switch tab: ${e instanceof Error ? e.message : String(e)}`,
);
}
},
};
// ================================
// closeTab Handler
// ================================
export const closeTabHandler: ActionHandler<'closeTab'> = {
type: 'closeTab',
validate: () => ok(),
describe: (action) => {
if (action.params.tabIds && action.params.tabIds.length > 0) {
return `Close ${action.params.tabIds.length} tab(s)`;
}
if (action.params.url !== undefined) {
return 'Close tab (by URL)';
}
return 'Close current tab';
},
run: async (ctx, action) => {
const params = action.params;
try {
let tabIds: number[] = [];
if (params.tabIds && params.tabIds.length > 0) {
// Close specific tabs
tabIds = [...params.tabIds];
} else if (params.url !== undefined) {
// Find and close tabs by URL
const urlResult = tryResolveString(params.url, ctx.vars);
if (!urlResult.ok) {
return failed('VALIDATION_ERROR', `Failed to resolve URL: ${urlResult.error}`);
}
const urlPattern = urlResult.value.trim().toLowerCase();
// Empty pattern is invalid
if (!urlPattern) {
return failed('VALIDATION_ERROR', 'URL pattern cannot be empty');
}
const tabs = await chrome.tabs.query({});
tabIds = tabs
.filter((tab) => tab.url && tab.url.toLowerCase().includes(urlPattern) && tab.id)
.map((tab) => tab.id!);
} else {
// Close current tab
if (typeof ctx.tabId === 'number') {
tabIds = [ctx.tabId];
}
}
if (tabIds.length === 0) {
return failed('TAB_NOT_FOUND', 'No tabs to close');
}
await chrome.tabs.remove(tabIds);
return { status: 'success' };
} catch (e) {
return failed(
'UNKNOWN',
`Failed to close tab: ${e instanceof Error ? e.message : String(e)}`,
);
}
},
};
// ================================
// handleDownload Handler
// ================================
export const handleDownloadHandler: ActionHandler<'handleDownload'> = {
type: 'handleDownload',
validate: () => ok(),
describe: (action) => {
if (action.params.filenameContains !== undefined) {
return 'Handle download (by filename)';
}
return 'Handle download';
},
run: async (ctx, action) => {
const params = action.params;
const timeoutMs = action.policy?.timeout?.ms ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
const waitForComplete = params.waitForComplete !== false;
// Resolve filename pattern if provided
let filenamePattern: string | undefined;
if (params.filenameContains !== undefined) {
const result = tryResolveString(params.filenameContains, ctx.vars);
if (!result.ok) {
return failed('VALIDATION_ERROR', `Failed to resolve filenameContains: ${result.error}`);
}
filenamePattern = result.value.toLowerCase();
}
return new Promise((resolve) => {
const startTime = Date.now();
let downloadId: number | undefined;
let downloadInfo: DownloadInfo | undefined;
let resolved = false;
const cleanup = () => {
chrome.downloads.onCreated.removeListener(onCreated);
chrome.downloads.onChanged.removeListener(onChanged);
};
const finish = (result: Awaited['run']>>) => {
if (!resolved) {
resolved = true;
cleanup();
resolve(result);
}
};
const onCreated = (item: chrome.downloads.DownloadItem) => {
// Check if this download matches our criteria
if (filenamePattern) {
const filename = item.filename.toLowerCase();
if (!filename.includes(filenamePattern)) return;
}
downloadId = item.id;
downloadInfo = {
id: String(item.id),
filename: item.filename,
url: item.url,
state: item.state as DownloadState,
size: item.totalBytes > 0 ? item.totalBytes : undefined,
};
if (!waitForComplete || item.state === 'complete') {
storeAndFinish();
}
};
const onChanged = (delta: chrome.downloads.DownloadDelta) => {
if (delta.id !== downloadId) return;
if (delta.state) {
if (downloadInfo) {
downloadInfo.state = delta.state.current as DownloadState;
}
if (delta.state.current === 'complete') {
storeAndFinish();
} else if (delta.state.current === 'interrupted') {
finish(failed('DOWNLOAD_FAILED', 'Download was interrupted'));
}
}
if (delta.filename && downloadInfo) {
downloadInfo.filename = delta.filename.current || downloadInfo.filename;
}
if (delta.totalBytes && downloadInfo && delta.totalBytes.current) {
downloadInfo.size = delta.totalBytes.current;
}
};
const storeAndFinish = () => {
if (params.saveAs && downloadInfo) {
ctx.vars[params.saveAs] = downloadInfo as unknown as VariableStore[string];
}
finish({
status: 'success',
output: downloadInfo ? { download: downloadInfo } : undefined,
});
};
// Set up listeners
chrome.downloads.onCreated.addListener(onCreated);
chrome.downloads.onChanged.addListener(onChanged);
// Set up timeout
const checkTimeout = () => {
if (resolved) return;
if (Date.now() - startTime > timeoutMs) {
finish(failed('TIMEOUT', `Download timeout after ${timeoutMs}ms`));
} else {
setTimeout(checkTimeout, 500);
}
};
setTimeout(checkTimeout, 500);
});
},
};
// ================================
// Helper Functions
// ================================
/**
* Wait for a tab to complete loading
*/
async function waitForTabComplete(tabId: number, timeoutMs: number): Promise {
const startTime = Date.now();
return new Promise((resolve, reject) => {
const checkStatus = async () => {
try {
const tab = await chrome.tabs.get(tabId);
if (tab.status === 'complete') {
resolve();
return;
}
if (Date.now() - startTime > timeoutMs) {
reject(new Error(`Tab load timeout after ${timeoutMs}ms`));
return;
}
setTimeout(checkStatus, 100);
} catch (e) {
reject(e);
}
};
checkStatus();
});
}
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/handlers/wait.ts
================================================
/**
* Wait Action Handler
*
* Handles various wait conditions:
* - Sleep (fixed delay)
* - Network idle
* - Navigation complete
* - Text appears/disappears
* - Selector visible/hidden
*/
import { ENGINE_CONSTANTS } from '../../engine/constants';
import { waitForNavigation, waitForNetworkIdle } from '../../rr-utils';
import { failed, invalid, ok, tryResolveNumber } from '../registry';
import type { ActionHandler } from '../types';
import { clampInt, resolveString, sendMessageToTab } from './common';
export const waitHandler: ActionHandler<'wait'> = {
type: 'wait',
validate: (action) => {
const condition = action.params.condition;
if (!condition || typeof condition !== 'object') {
return invalid('Missing condition parameter');
}
if (!('kind' in condition)) {
return invalid('Condition must have a kind property');
}
return ok();
},
describe: (action) => {
const condition = action.params.condition;
if (!condition) return 'Wait';
switch (condition.kind) {
case 'sleep': {
const ms = typeof condition.sleep === 'number' ? condition.sleep : '(dynamic)';
return `Wait ${ms}ms`;
}
case 'networkIdle':
return 'Wait for network idle';
case 'navigation':
return 'Wait for navigation';
case 'text': {
const appear = condition.appear !== false;
const text = typeof condition.text === 'string' ? condition.text : '(dynamic)';
const displayText = text.length > 20 ? text.slice(0, 20) + '...' : text;
return `Wait for text "${displayText}" to ${appear ? 'appear' : 'disappear'}`;
}
case 'selector': {
const visible = condition.visible !== false;
return `Wait for selector to be ${visible ? 'visible' : 'hidden'}`;
}
default:
return 'Wait';
}
},
run: async (ctx, action) => {
const vars = ctx.vars;
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found');
}
const timeoutMs = action.policy?.timeout?.ms;
const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined;
const condition = action.params.condition;
// Handle sleep condition
if (condition.kind === 'sleep') {
const msResolved = tryResolveNumber(condition.sleep, vars);
if (!msResolved.ok) {
return failed('VALIDATION_ERROR', msResolved.error);
}
const ms = Math.max(0, Number(msResolved.value ?? 0));
await new Promise((resolve) => setTimeout(resolve, ms));
return { status: 'success' };
}
// Handle network idle condition
if (condition.kind === 'networkIdle') {
const totalMs = clampInt(timeoutMs ?? 5000, 1000, ENGINE_CONSTANTS.MAX_WAIT_MS);
let idleMs: number;
if (condition.idleMs !== undefined) {
const idleResolved = tryResolveNumber(condition.idleMs, vars);
idleMs = idleResolved.ok
? clampInt(idleResolved.value, 200, 5000)
: Math.min(1500, Math.max(500, Math.floor(totalMs / 3)));
} else {
idleMs = Math.min(1500, Math.max(500, Math.floor(totalMs / 3)));
}
await waitForNetworkIdle(totalMs, idleMs);
return { status: 'success' };
}
// Handle navigation condition
if (condition.kind === 'navigation') {
const timeout = timeoutMs === undefined ? undefined : Math.max(0, Number(timeoutMs));
await waitForNavigation(timeout);
return { status: 'success' };
}
// Handle text condition
if (condition.kind === 'text') {
const textResolved = resolveString(condition.text, vars);
if (!textResolved.ok) {
return failed('VALIDATION_ERROR', textResolved.error);
}
const appear = condition.appear !== false;
const timeout = clampInt(timeoutMs ?? 10000, 0, ENGINE_CONSTANTS.MAX_WAIT_MS);
// Inject wait helper script
try {
await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
files: ['inject-scripts/wait-helper.js'],
world: 'ISOLATED',
});
} catch (e) {
return failed('SCRIPT_FAILED', `Failed to inject wait helper: ${(e as Error).message}`);
}
// Execute wait for text
const response = await sendMessageToTab<{ success?: boolean }>(
tabId,
{ action: 'waitForText', text: textResolved.value, appear, timeout },
ctx.frameId,
);
if (!response.ok) {
return failed('TIMEOUT', `Wait for text failed: ${response.error}`);
}
if (response.value?.success !== true) {
return failed(
'TIMEOUT',
`Text "${textResolved.value}" did not ${appear ? 'appear' : 'disappear'} within timeout`,
);
}
return { status: 'success' };
}
// Handle selector condition
if (condition.kind === 'selector') {
const selectorResolved = resolveString(condition.selector, vars);
if (!selectorResolved.ok) {
return failed('VALIDATION_ERROR', selectorResolved.error);
}
const visible = condition.visible !== false;
const timeout = clampInt(timeoutMs ?? 10000, 0, ENGINE_CONSTANTS.MAX_WAIT_MS);
// Inject wait helper script
try {
await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
files: ['inject-scripts/wait-helper.js'],
world: 'ISOLATED',
});
} catch (e) {
return failed('SCRIPT_FAILED', `Failed to inject wait helper: ${(e as Error).message}`);
}
// Execute wait for selector
const response = await sendMessageToTab<{ success?: boolean }>(
tabId,
{ action: 'waitForSelector', selector: selectorResolved.value, visible, timeout },
ctx.frameId,
);
if (!response.ok) {
return failed('TIMEOUT', `Wait for selector failed: ${response.error}`);
}
if (response.value?.success !== true) {
return failed(
'TIMEOUT',
`Selector "${selectorResolved.value}" did not become ${visible ? 'visible' : 'hidden'} within timeout`,
);
}
return { status: 'success' };
}
return failed(
'VALIDATION_ERROR',
`Unsupported wait condition kind: ${(condition as { kind: string }).kind}`,
);
},
};
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/index.ts
================================================
/**
* Action System - 导出模块
*/
// 类型导出
export * from './types';
// 注册表导出
export {
ActionRegistry,
createActionRegistry,
ok,
invalid,
failed,
tryResolveString,
tryResolveNumber,
tryResolveJson,
tryResolveValue,
type BeforeExecuteArgs,
type BeforeExecuteHook,
type AfterExecuteArgs,
type AfterExecuteHook,
type ActionRegistryHooks,
} from './registry';
// 适配器导出
export {
execCtxToActionCtx,
stepToAction,
actionResultToExecResult,
createStepExecutor,
isActionSupported,
getActionType,
type StepExecutionAttempt,
} from './adapter';
// Handler 工厂导出
export {
createReplayActionRegistry,
registerReplayHandlers,
getSupportedActionTypes,
isActionTypeSupported,
} from './handlers';
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/registry.ts
================================================
/**
* Action Registry - Action 执行器注册表和执行管道
*
* 特性:
* - 动态注册/注销 handler
* - 中间件/钩子机制 (beforeExecute, afterExecute)
* - 重试和超时策略
* - 类型安全
*/
import type {
Action,
ActionError,
ActionErrorCode,
ActionExecutionContext,
ActionExecutionResult,
ActionHandler,
EdgeLabel,
ElementTarget,
ExecutableAction,
ExecutableActionType,
FrameTarget,
JsonValue,
NonEmptyArray,
Resolvable,
RetryPolicy,
SelectorCandidate,
TimeoutPolicy,
ValidationResult,
VariablePathSegment,
VariablePointer,
VariableStore,
} from './types';
// ================================
// 类型定义
// ================================
type AnyExecutableAction = {
[T in ExecutableActionType]: ExecutableAction;
}[ExecutableActionType];
type AnyExecutableHandler = { [T in ExecutableActionType]: ActionHandler }[ExecutableActionType];
export interface BeforeExecuteArgs {
ctx: ActionExecutionContext;
action: ExecutableAction;
handler: ActionHandler;
attempt: number;
}
export type BeforeExecuteHook = (
args: BeforeExecuteArgs,
) => void | ActionExecutionResult | Promise>;
export interface AfterExecuteArgs {
ctx: ActionExecutionContext;
action: ExecutableAction;
handler: ActionHandler;
result: ActionExecutionResult;
attempt: number;
}
export type AfterExecuteHook = (
args: AfterExecuteArgs,
) => void | ActionExecutionResult | Promise>;
export interface ActionRegistryHooks {
beforeExecute?: BeforeExecuteHook;
afterExecute?: AfterExecuteHook;
}
// ================================
// 工具函数
// ================================
function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null;
}
function toNonEmptyArray(value: string[], fallback: string): NonEmptyArray {
return (value.length > 0 ? value : [fallback]) as NonEmptyArray;
}
const ACTION_ERROR_CODES: ReadonlyArray = [
'VALIDATION_ERROR',
'TIMEOUT',
'TAB_NOT_FOUND',
'FRAME_NOT_FOUND',
'TARGET_NOT_FOUND',
'ELEMENT_NOT_VISIBLE',
'NAVIGATION_FAILED',
'NETWORK_REQUEST_FAILED',
'DOWNLOAD_FAILED',
'ASSERTION_FAILED',
'SCRIPT_FAILED',
'UNKNOWN',
] as const;
function isActionErrorCode(value: unknown): value is ActionErrorCode {
return typeof value === 'string' && (ACTION_ERROR_CODES as ReadonlyArray).includes(value);
}
function toErrorMessage(e: unknown): string {
if (e instanceof Error) return e.message;
if (typeof e === 'string') return e;
if (isRecord(e) && typeof e.message === 'string') return e.message;
return 'Unknown error';
}
function toActionError(e: unknown, fallbackCode: ActionErrorCode = 'UNKNOWN'): ActionError {
if (isRecord(e) && isActionErrorCode(e.code) && typeof e.message === 'string') {
return { code: e.code, message: e.message, data: undefined };
}
return { code: fallbackCode, message: toErrorMessage(e) };
}
export function ok(): ValidationResult {
return { ok: true };
}
export function invalid(...errors: string[]): ValidationResult {
return { ok: false, errors: toNonEmptyArray(errors.filter(Boolean), 'Validation failed') };
}
export function failed(
code: ActionErrorCode,
message: string,
): ActionExecutionResult {
return { status: 'failed', error: { code, message } };
}
function sleep(ms: number): Promise {
const safe = Math.max(0, Math.floor(ms));
return new Promise((resolve) => setTimeout(resolve, safe));
}
// ================================
// Resolvable 解析器
// ================================
function isVariablePointer(value: unknown): value is VariablePointer {
if (!isRecord(value)) return false;
if (typeof value.name !== 'string' || value.name.length === 0) return false;
if (value.path === undefined) return true;
if (!Array.isArray(value.path)) return false;
return value.path.every((s) => typeof s === 'string' || typeof s === 'number');
}
function isVarValue(
value: unknown,
): value is { kind: 'var'; ref: VariablePointer; default?: unknown } {
if (!isRecord(value)) return false;
if (value.kind !== 'var') return false;
return isVariablePointer(value.ref);
}
function isExprValue(value: unknown): value is { kind: 'expr'; default?: unknown } {
if (!isRecord(value)) return false;
if (value.kind !== 'expr') return false;
return 'expr' in value;
}
function isStringTemplate(value: unknown): value is { kind: 'template'; parts: unknown[] } {
if (!isRecord(value)) return false;
if (value.kind !== 'template') return false;
return Array.isArray(value.parts) && value.parts.length > 0;
}
function readByPath(
value: JsonValue,
path?: ReadonlyArray,
): JsonValue | undefined {
if (!path || path.length === 0) return value;
let cur: JsonValue | undefined = value;
for (const seg of path) {
if (cur === undefined || cur === null) return undefined;
if (typeof seg === 'number') {
if (!Array.isArray(cur)) return undefined;
cur = cur[seg] as JsonValue | undefined;
continue;
}
if (typeof seg === 'string') {
if (!isRecord(cur)) return undefined;
cur = (cur as Record)[seg] as JsonValue | undefined;
continue;
}
return undefined;
}
return cur;
}
export function tryResolveJson(
value: Resolvable,
vars: VariableStore,
): { ok: true; value: JsonValue } | { ok: false; error: string } {
if (isVarValue(value)) {
const ref = value.ref;
const root = vars[ref.name];
const resolved = root === undefined ? undefined : readByPath(root, ref.path);
if (resolved !== undefined) return { ok: true, value: resolved };
if ('default' in value) return { ok: true, value: (value.default ?? null) as JsonValue };
return { ok: true, value: null };
}
if (isExprValue(value)) {
if ('default' in value) return { ok: true, value: (value.default ?? null) as JsonValue };
return { ok: false, error: 'Expression value is not supported by the default resolver' };
}
return { ok: true, value };
}
function formatInserted(value: JsonValue, format?: 'text' | 'json' | 'urlEncoded'): string {
if (format === 'json') return JSON.stringify(value);
const text = value === null ? '' : typeof value === 'string' ? value : String(value);
if (format === 'urlEncoded') return encodeURIComponent(text);
return text;
}
export function tryResolveString(
value: Resolvable,
vars: VariableStore,
): { ok: true; value: string } | { ok: false; error: string } {
if (typeof value === 'string') return { ok: true, value };
if (isVarValue(value)) {
const ref = value.ref;
const root = vars[ref.name];
const resolved = root === undefined ? undefined : readByPath(root, ref.path);
if (resolved !== undefined && resolved !== null) return { ok: true, value: String(resolved) };
if ('default' in value && typeof value.default === 'string')
return { ok: true, value: value.default };
return { ok: true, value: '' };
}
if (isStringTemplate(value)) {
const parts = value.parts;
let out = '';
for (const p of parts) {
if (!isRecord(p) || typeof p.kind !== 'string')
return { ok: false, error: 'Invalid template part' };
if (p.kind === 'text') {
if (typeof p.value !== 'string') return { ok: false, error: 'Invalid template text part' };
out += p.value;
continue;
}
if (p.kind === 'insert') {
const resolved = tryResolveJson(p.value as Resolvable, vars);
if (!resolved.ok) return { ok: false, error: resolved.error };
out += formatInserted(
resolved.value,
(p.format as 'text' | 'json' | 'urlEncoded' | undefined) ?? 'text',
);
continue;
}
return {
ok: false,
error: `Unknown template part kind: ${String((p as { kind: string }).kind)}`,
};
}
return { ok: true, value: out };
}
if (isExprValue(value)) {
if ('default' in value && typeof value.default === 'string')
return { ok: true, value: value.default };
return { ok: false, error: 'Expression value is not supported by the default resolver' };
}
return { ok: false, error: 'Unsupported resolvable string value' };
}
export function tryResolveNumber(
value: Resolvable,
vars: VariableStore,
): { ok: true; value: number } | { ok: false; error: string } {
if (typeof value === 'number' && Number.isFinite(value)) return { ok: true, value };
if (isVarValue(value)) {
const ref = value.ref;
const root = vars[ref.name];
const resolved = root === undefined ? undefined : readByPath(root, ref.path);
if (typeof resolved === 'number' && Number.isFinite(resolved))
return { ok: true, value: resolved };
if (typeof resolved === 'string' && resolved.trim() !== '') {
const n = Number(resolved);
if (Number.isFinite(n)) return { ok: true, value: n };
}
if ('default' in value && typeof value.default === 'number' && Number.isFinite(value.default))
return { ok: true, value: value.default };
return { ok: false, error: `Variable "${ref.name}" is not a finite number` };
}
if (isExprValue(value)) {
if ('default' in value && typeof value.default === 'number' && Number.isFinite(value.default))
return { ok: true, value: value.default };
return { ok: false, error: 'Expression value is not supported by the default resolver' };
}
return { ok: false, error: 'Unsupported resolvable number value' };
}
/**
* Resolve a generic JSON value (alias for tryResolveJson)
* Useful for script/http handlers that work with arbitrary JSON
*/
export const tryResolveValue = tryResolveJson;
// ================================
// 重试和超时逻辑
// ================================
function shouldRetry(policy: RetryPolicy | undefined, error: ActionError | undefined): boolean {
if (!policy) return false;
if (policy.retries <= 0) return false;
if (!error) return false;
if (error.code === 'VALIDATION_ERROR') return false;
if (policy.retryOn && policy.retryOn.length > 0) return policy.retryOn.includes(error.code);
return true;
}
function computeRetryDelayMs(policy: RetryPolicy, retryIndex: number): number {
const base = Math.max(0, Math.floor(policy.intervalMs));
const backoff = policy.backoff ?? 'none';
let delay = base;
if (backoff === 'linear') delay = base * (retryIndex + 1);
if (backoff === 'exp') delay = base * Math.pow(2, retryIndex);
const capped =
policy.maxIntervalMs !== undefined ? Math.min(delay, Math.max(0, policy.maxIntervalMs)) : delay;
if ((policy.jitter ?? 'none') === 'full') return Math.floor(Math.random() * capped);
return capped;
}
async function runWithTimeout(
run: () => Promise,
timeoutMs: number | undefined,
): Promise<{ ok: true; value: T } | { ok: false; error: ActionError }> {
if (timeoutMs === undefined) {
try {
return { ok: true, value: await run() };
} catch (e) {
return { ok: false, error: toActionError(e) };
}
}
const ms = Math.max(0, Math.floor(timeoutMs));
if (ms === 0) return { ok: false, error: { code: 'TIMEOUT', message: 'Timeout reached' } };
return await new Promise((resolve) => {
const timer: ReturnType = setTimeout(() => {
resolve({ ok: false, error: { code: 'TIMEOUT', message: 'Timeout reached' } });
}, ms);
run()
.then((value) => {
clearTimeout(timer);
resolve({ ok: true, value });
})
.catch((e) => {
clearTimeout(timer);
resolve({ ok: false, error: toActionError(e) });
});
});
}
// ================================
// ActionRegistry 类
// ================================
export class ActionRegistry {
private readonly handlers: { [T in ExecutableActionType]?: ActionHandler } = {};
private readonly beforeHooks: BeforeExecuteHook[] = [];
private readonly afterHooks: AfterExecuteHook[] = [];
/**
* 注册 action handler
*/
register(
handler: ActionHandler,
options?: { override?: boolean },
): void {
const override = options?.override !== false;
const existing = this.handlers[handler.type];
if (existing && !override) {
throw new Error(`Handler already registered for type: ${handler.type}`);
}
// Type assertion needed due to TypeScript mapped type limitation
(this.handlers as Record>)[handler.type] = handler;
}
/**
* 注销 action handler
*/
unregister(type: T): boolean {
const exists = this.handlers[type] !== undefined;
delete this.handlers[type];
return exists;
}
/**
* 获取 handler
*/
get(type: T): ActionHandler | undefined {
return this.handlers[type];
}
/**
* 检查是否存在 handler
*/
has(type: ExecutableActionType): boolean {
return this.handlers[type] !== undefined;
}
/**
* 列出所有已注册的 handler
*/
list(): ReadonlyArray {
const arr = Object.values(this.handlers).filter(
(h): h is AnyExecutableHandler => h !== undefined,
);
return arr;
}
/**
* 注册 beforeExecute 钩子
*/
onBeforeExecute(hook: BeforeExecuteHook): () => void {
this.beforeHooks.push(hook);
return () => {
const idx = this.beforeHooks.indexOf(hook);
if (idx >= 0) this.beforeHooks.splice(idx, 1);
};
}
/**
* 注册 afterExecute 钩子
*/
onAfterExecute(hook: AfterExecuteHook): () => void {
this.afterHooks.push(hook);
return () => {
const idx = this.afterHooks.indexOf(hook);
if (idx >= 0) this.afterHooks.splice(idx, 1);
};
}
/**
* 批量注册钩子
*/
use(hooks: ActionRegistryHooks): () => void {
const disposers: Array<() => void> = [];
if (hooks.beforeExecute) disposers.push(this.onBeforeExecute(hooks.beforeExecute));
if (hooks.afterExecute) disposers.push(this.onAfterExecute(hooks.afterExecute));
return () => {
for (const d of disposers) d();
};
}
/**
* 验证 action 配置
*/
validate(action: ExecutableAction): ValidationResult {
const handler = this.get(action.type);
if (!handler) return invalid(`Unsupported action type: ${String(action.type)}`);
if (!handler.validate) return ok();
return handler.validate(action);
}
/**
* 执行 action
*/
async execute(
ctx: ActionExecutionContext,
action: ExecutableAction,
): Promise> {
const startedAt = Date.now();
// 跳过禁用的 action
if (action.disabled) {
return { status: 'skipped', durationMs: Date.now() - startedAt };
}
// 获取 handler
const handler = this.get(action.type);
if (!handler) {
return {
status: 'failed',
error: {
code: 'VALIDATION_ERROR',
message: `Unsupported action type: ${String(action.type)}`,
},
durationMs: Date.now() - startedAt,
};
}
// 验证
const v = this.validate(action);
if (!v.ok) {
let result: ActionExecutionResult = {
status: 'failed',
error: { code: 'VALIDATION_ERROR', message: v.errors.join(', ') },
};
// 调用 afterExecute 钩子
for (const hook of this.afterHooks) {
try {
const maybe = await hook({ ctx, action, handler, result, attempt: 0 });
if (maybe) result = maybe;
} catch (e) {
try {
ctx.log(`afterExecute hook failed: ${toErrorMessage(e)}`, 'warn');
} catch {
// ignore
}
}
}
result.durationMs = Date.now() - startedAt;
return result;
}
// 计算重试和超时参数
const retryPolicy = action.policy?.retry;
const timeoutPolicy = action.policy?.timeout;
const maxAttempts = 1 + Math.max(0, Math.floor(retryPolicy?.retries ?? 0));
const actionDeadline =
timeoutPolicy && timeoutPolicy.ms > 0 && (timeoutPolicy.scope ?? 'attempt') === 'action'
? startedAt + timeoutPolicy.ms
: undefined;
const remainingActionMs = () =>
actionDeadline === undefined ? undefined : Math.max(0, actionDeadline - Date.now());
let last: ActionExecutionResult | undefined;
// 执行循环(支持重试)
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const attemptTimeoutMs: number | undefined = (() => {
if (!timeoutPolicy || timeoutPolicy.ms <= 0) return undefined;
const scope = timeoutPolicy.scope ?? 'attempt';
if (scope === 'attempt') return timeoutPolicy.ms;
return remainingActionMs();
})();
if (attemptTimeoutMs !== undefined && attemptTimeoutMs <= 0) {
last = failed('TIMEOUT', 'Timeout reached');
break;
}
// beforeExecute 钩子(可以短路)
let shortCircuited: ActionExecutionResult | undefined;
for (const hook of this.beforeHooks) {
try {
const maybe = await hook({ ctx, action, handler, attempt });
if (maybe) {
shortCircuited = maybe;
break;
}
} catch (e) {
try {
ctx.log(`beforeExecute hook failed: ${toErrorMessage(e)}`, 'warn');
} catch {
// ignore
}
}
}
// 执行 handler
const runOutcome =
shortCircuited ??
(await (async () => {
const out = await runWithTimeout(() => handler.run(ctx, action), attemptTimeoutMs);
if (!out.ok) return failed(out.error.code, out.error.message);
const result = out.value ?? ({} as ActionExecutionResult);
if (result.status === 'failed' && !result.error) {
return { ...result, error: { code: 'UNKNOWN' as const, message: 'Action failed' } };
}
return result;
})());
let result: ActionExecutionResult = runOutcome;
// afterExecute 钩子(可以替换结果)
for (const hook of this.afterHooks) {
try {
const maybe = await hook({ ctx, action, handler, result, attempt });
if (maybe) result = maybe;
} catch (e) {
try {
ctx.log(`afterExecute hook failed: ${toErrorMessage(e)}`, 'warn');
} catch {
// ignore
}
}
}
last = result;
// 成功则退出
if (result.status !== 'failed') break;
// 判断是否重试
const canRetry = attempt < maxAttempts - 1 && shouldRetry(retryPolicy, result.error);
if (!canRetry) break;
const delay = computeRetryDelayMs(retryPolicy!, attempt);
if (
actionDeadline !== undefined &&
remainingActionMs() !== undefined &&
(remainingActionMs() as number) < delay
) {
break;
}
try {
ctx.log(`Retrying action "${action.type}" (attempt ${attempt + 1}/${maxAttempts})`, 'warn');
} catch {
// ignore
}
if (delay > 0) await sleep(delay);
}
const finalResult: ActionExecutionResult =
last ??
({
status: 'failed',
error: { code: 'UNKNOWN', message: 'Action execution produced no result' },
} as ActionExecutionResult);
finalResult.durationMs = Date.now() - startedAt;
return finalResult;
}
}
// ================================
// 导出工厂函数
// ================================
/**
* 创建默认的 ActionRegistry 实例
*/
export function createActionRegistry(): ActionRegistry {
return new ActionRegistry();
}
================================================
FILE: app/chrome-extension/entrypoints/background/record-replay/actions/types.ts
================================================
/**
* Action Type System for Record & Replay
* 商业级录制回放的核心类型定义
*
* 设计原则:
* - 类型安全,无 any
* - 支持所有操作类型
* - 支持重试、超时、错误处理策略
* - 支持选择器候选列表和稳定性评分
* - 支持变量系统
* - 符合 SOLID 原则(接口可通过声明合并扩展)
*/
// ================================
// 基础类型
// ================================
export type Milliseconds = number;
export type ISODateTimeString = string;
export type NonEmptyArray = [T, ...T[]];
// JSON 类型
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
export interface JsonObject {
[key: string]: JsonValue;
}
export type JsonArray = JsonValue[];
// ID 类型
export type FlowId = string;
export type ActionId = string;
export type SubflowId = string;
export type EdgeId = string;
export type VariableName = string;
// ================================
// Edge Labels
// ================================
export const EDGE_LABELS = {
DEFAULT: 'default',
TRUE: 'true',
FALSE: 'false',
ON_ERROR: 'onError',
} as const;
export type BuiltinEdgeLabel = (typeof EDGE_LABELS)[keyof typeof EDGE_LABELS];
export type EdgeLabel = string;
// ================================
// 错误处理
// ================================
export type ActionErrorCode =
| 'VALIDATION_ERROR'
| 'TIMEOUT'
| 'TAB_NOT_FOUND'
| 'FRAME_NOT_FOUND'
| 'TARGET_NOT_FOUND'
| 'ELEMENT_NOT_VISIBLE'
| 'NAVIGATION_FAILED'
| 'NETWORK_REQUEST_FAILED'
| 'DOWNLOAD_FAILED'
| 'ASSERTION_FAILED'
| 'SCRIPT_FAILED'
| 'UNKNOWN';
export interface ActionError {
code: ActionErrorCode;
message: string;
data?: JsonValue;
}
// ================================
// 执行策略
// ================================
export interface TimeoutPolicy {
ms: Milliseconds;
/** 'attempt' = 每次尝试独立计时, 'action' = 整个 action 总计时 */
scope?: 'attempt' | 'action';
}
export type BackoffKind = 'none' | 'exp' | 'linear';
export interface RetryPolicy {
/** 重试次数(不含首次尝试) */
retries: number;
/** 重试间隔 */
intervalMs: Milliseconds;
/** 退避策略 */
backoff?: BackoffKind;
/** 最大间隔(用于 exp/linear) */
maxIntervalMs?: Milliseconds;
/** 抖动策略 */
jitter?: 'none' | 'full';
/** 仅在这些错误码时重试 */
retryOn?: ReadonlyArray;
}
export type ErrorHandlingStrategy =
| { kind: 'stop' }
| { kind: 'continue'; level?: 'warning' | 'error' }
| { kind: 'goto'; label: EdgeLabel };
export interface ArtifactCapturePolicy {
screenshot?: 'never' | 'onFailure' | 'always';
saveScreenshotAs?: VariableName;
includeConsole?: boolean;
includeNetwork?: boolean;
}
export interface ActionPolicy {
timeout?: TimeoutPolicy;
retry?: RetryPolicy;
onError?: ErrorHandlingStrategy;
artifacts?: ArtifactCapturePolicy;
}
// ================================
// 变量系统
// ================================
export interface VariableDefinitionBase {
name: VariableName;
label?: string;
description?: string;
sensitive?: boolean;
required?: boolean;
}
export interface VariableStringRules {
pattern?: string;
minLength?: number;
maxLength?: number;
}
export interface VariableNumberRules {
min?: number;
max?: number;
integer?: boolean;
}
export type VariableDefinition =
| (VariableDefinitionBase & {
kind: 'string';
default?: string;
rules?: VariableStringRules;
})
| (VariableDefinitionBase & {
kind: 'number';
default?: number;
rules?: VariableNumberRules;
})
| (VariableDefinitionBase & {
kind: 'boolean';
default?: boolean;
})
| (VariableDefinitionBase & {
kind: 'enum';
options: NonEmptyArray;
default?: string;
})
| (VariableDefinitionBase & {
kind: 'array';
item: 'string' | 'number' | 'boolean' | 'json';
default?: JsonValue[];
})
| (VariableDefinitionBase & {
kind: 'json';
default?: JsonValue;
});
export type VariableStore = Record;
export type VariableScope = 'flow' | 'run' | 'env' | 'secret';
export type VariablePathSegment = string | number;
export interface VariablePointer {
scope?: VariableScope;
name: VariableName;
path?: ReadonlyArray;
}
// ================================
// 表达式和模板
// ================================
export type ExpressionLanguage = 'js' | 'rr';
export interface Expression<_T = JsonValue> {
language: ExpressionLanguage;
code: string;
}
export interface VariableValue {
kind: 'var';
ref: VariablePointer;
default?: T;
}
export interface ExpressionValue {
kind: 'expr';
expr: Expression;
default?: T;
}
export type TemplateFormat = 'text' | 'json' | 'urlEncoded';
export type TemplatePart =
| { kind: 'text'; value: string }
| { kind: 'insert'; value: Resolvable; format?: TemplateFormat };
export interface StringTemplate {
kind: 'template';
parts: NonEmptyArray;
}
export type Resolvable =
| T
| VariableValue
| ExpressionValue
| ([T] extends [string] ? StringTemplate : never);
export type DataPath = string; // dot/bracket path: e.g. "data.items[0].id"
export type Assignments = Record;
// ================================
// 条件表达式
// ================================
export type CompareOp =
| 'eq'
| 'eqi'
| 'neq'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'containsI'
| 'notContains'
| 'notContainsI'
| 'startsWith'
| 'endsWith'
| 'regex';
export type Condition =
| { kind: 'expr'; expr: Expression }
| {
kind: 'compare';
left: Resolvable;
op: CompareOp;
right: Resolvable;
}
| { kind: 'truthy'; value: Resolvable }
| { kind: 'falsy'; value: Resolvable }
| { kind: 'not'; condition: Condition }
| { kind: 'and'; conditions: NonEmptyArray }
| { kind: 'or'; conditions: NonEmptyArray };
// ================================
// 选择器系统
// ================================
export type SelectorCandidateSource = 'recorded' | 'user' | 'generated';
export interface SelectorStability {
/** 稳定性评分 0-1 */
score: number;
signals?: {
usesId?: boolean;
usesAria?: boolean;
usesText?: boolean;
usesNthOfType?: boolean;
usesAttributes?: boolean;
usesClass?: boolean;
};
note?: string;
}
export interface SelectorCandidateBase {
weight?: number;
stability?: SelectorStability;
source?: SelectorCandidateSource;
}
export type SelectorCandidate =
| (SelectorCandidateBase & { type: 'css'; selector: Resolvable })
| (SelectorCandidateBase & { type: 'xpath'; xpath: Resolvable })
| (SelectorCandidateBase & { type: 'attr'; selector: Resolvable })
| (SelectorCandidateBase & {
type: 'aria';
role?: Resolvable;
name?: Resolvable;
})
| (SelectorCandidateBase & {
type: 'text';
text: Resolvable;
tagNameHint?: string;
match?: 'exact' | 'contains';
});
export type FrameTarget =
| { kind: 'top' }
| { kind: 'index'; index: Resolvable }
| { kind: 'urlContains'; value: Resolvable };
export interface TargetHint {
tagName?: string;
role?: string;
name?: string;
text?: string;
}
export interface ElementTargetBase {
frame?: FrameTarget;
hint?: TargetHint;
}
export type ElementTarget =
| (ElementTargetBase & {
/** 临时引用(快速路径) */
ref: string;
candidates?: ReadonlyArray;
})
| (ElementTargetBase & {
ref?: string;
candidates: NonEmptyArray;
});
// ================================
// Action 参数定义
// ================================
export type BrowserWorld = 'MAIN' | 'ISOLATED';
// --- 页面交互 ---
export interface ClickParams {
target: ElementTarget;
button?: 'left' | 'middle' | 'right';
before?: { scrollIntoView?: boolean; waitForSelector?: boolean };
after?: { waitForNavigation?: boolean; waitForNetworkIdle?: boolean };
}
export interface FillParams {
target: ElementTarget;
value: Resolvable;
clearFirst?: boolean;
mode?: 'replace' | 'append';
}
export interface KeyParams {
keys: Resolvable; // e.g. "Backspace Enter" or "cmd+a"
target?: ElementTarget;
}
export type ScrollMode = 'element' | 'offset' | 'container';
export interface ScrollOffset {
x?: Resolvable;
y?: Resolvable;
}
export interface ScrollParams {
mode: ScrollMode;
target?: ElementTarget;
offset?: ScrollOffset;
}
export interface Point {
x: number;
y: number;
}
export interface DragParams {
start: ElementTarget;
end: ElementTarget;
path?: ReadonlyArray;
}
// --- 导航 ---
export interface NavigateParams {
url: Resolvable;
refresh?: boolean;
}
// --- 等待和断言 ---
export type WaitCondition =
| { kind: 'sleep'; sleep: Resolvable }
| { kind: 'navigation' }
| { kind: 'networkIdle'; idleMs?: Resolvable }
| { kind: 'text'; text: Resolvable; appear?: boolean }
| { kind: 'selector'; selector: Resolvable; visible?: boolean };
export interface WaitParams {
condition: WaitCondition;
}
export type Assertion =
| { kind: 'exists'; selector: Resolvable }
| { kind: 'visible'; selector: Resolvable }
| { kind: 'textPresent'; text: Resolvable }
| {
kind: 'attribute';
selector: Resolvable;
name: Resolvable;
equals?: Resolvable;
matches?: Resolvable;
};
export type AssertFailStrategy = 'stop' | 'warn' | 'retry';
export interface AssertParams {
assert: Assertion;
failStrategy?: AssertFailStrategy;
}
// --- 数据和脚本 ---
export type ExtractParams =
| {
mode: 'selector';
selector: Resolvable;
attr?: Resolvable; // "text" | "textContent" | attribute name
saveAs: VariableName;
}
| {
mode: 'js';
code: string;
world?: BrowserWorld;
saveAs: VariableName;
};
export type ScriptTiming = 'before' | 'after';
export interface ScriptParams {
world?: BrowserWorld;
code: string;
when?: ScriptTiming;
args?: Record>;
saveAs?: VariableName;
assign?: Assignments;
}
export interface ScreenshotParams {
selector?: Resolvable;
fullPage?: boolean;
saveAs?: VariableName;
}
// --- HTTP ---
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export type HttpHeaders = Record>;
export type HttpFormData = Record>;
export type HttpBody =
| { kind: 'none' }
| { kind: 'text'; text: Resolvable; contentType?: Resolvable }
| { kind: 'json'; json: Resolvable };
export type HttpOkStatus =
| { kind: 'range'; min: number; max: number }
| { kind: 'list'; statuses: NonEmptyArray };
export interface HttpParams {
method?: HttpMethod;
url: Resolvable;
headers?: HttpHeaders;
body?: HttpBody;
formData?: HttpFormData;
okStatus?: HttpOkStatus;
saveAs?: VariableName;
assign?: Assignments;
}
// --- DOM 工具 ---
export interface TriggerEventParams {
target: ElementTarget;
event: Resolvable;
bubbles?: boolean;
cancelable?: boolean;
}
export interface SetAttributeParams {
target: ElementTarget;
name: Resolvable;
value?: Resolvable;
remove?: boolean;
}
export interface SwitchFrameParams {
target: FrameTarget;
}
export interface LoopElementsParams {
selector: Resolvable;
saveAs?: VariableName;
itemVar?: VariableName;
subflowId: SubflowId;
}
// --- 标签页管理 ---
export interface OpenTabParams {
url?: Resolvable;
newWindow?: boolean;
}
export interface SwitchTabParams {
tabId?: number;
urlContains?: Resolvable;
titleContains?: Resolvable;
}
export interface CloseTabParams {
tabIds?: ReadonlyArray;
url?: Resolvable;
}
export interface HandleDownloadParams {
filenameContains?: Resolvable;
waitForComplete?: boolean;
saveAs?: VariableName;
}
// --- 控制流 ---
export interface ExecuteFlowParams {
flowId: FlowId;
inline?: boolean;
args?: Record>;
}
export interface ForeachParams {
listVar: VariableName;
itemVar?: VariableName;
subflowId: SubflowId;
concurrency?: number;
}
export interface WhileParams {
condition: Condition;
subflowId: SubflowId;
maxIterations?: number;
}
export interface IfBranch {
id: string;
label: EdgeLabel;
condition: Condition;
}
export type IfParams =
| {
mode: 'binary';
condition: Condition;
trueLabel?: EdgeLabel;
falseLabel?: EdgeLabel;
}
| {
mode: 'branches';
branches: NonEmptyArray;
elseLabel?: EdgeLabel;
};
export interface DelayParams {
sleep: Resolvable;
}
// --- 触发器 ---
export type TriggerUrlRuleKind = 'url' | 'domain' | 'path';
export interface TriggerUrlRule {
kind: TriggerUrlRuleKind;
value: Resolvable;
}
export interface TriggerUrlConfig {
rules?: ReadonlyArray;
}
export interface TriggerModeConfig {
manual?: boolean;
url?: boolean;
contextMenu?: boolean;
command?: boolean;
dom?: boolean;
schedule?: boolean;
}
export interface TriggerContextMenuConfig {
title?: Resolvable;
enabled?: boolean;
}
export interface TriggerCommandConfig {
commandKey?: Resolvable;
enabled?: boolean;
}
export interface TriggerDomConfig {
selector?: Resolvable;
appear?: boolean;
once?: boolean;
debounceMs?: Milliseconds;
enabled?: boolean;
}
export type TriggerScheduleType = 'once' | 'interval' | 'daily';
export interface TriggerSchedule {
id: string;
type: TriggerScheduleType;
when: Resolvable; // ISO/cron-like string
enabled?: boolean;
}
export interface TriggerParams {
enabled?: boolean;
description?: Resolvable;
modes?: TriggerModeConfig;
url?: TriggerUrlConfig;
contextMenu?: TriggerContextMenuConfig;
command?: TriggerCommandConfig;
dom?: TriggerDomConfig;
schedules?: ReadonlyArray;
}
// ================================
// Action 核心定义
// ================================
/**
* ActionParamsByType 使用 interface 声明
* 允许外部模块通过声明合并扩展 Action 类型(符合 OCP 原则)
*/
export interface ActionParamsByType {
// UI/构建时
trigger: TriggerParams;
delay: DelayParams;
// 页面交互
click: ClickParams;
dblclick: ClickParams;
fill: FillParams;
key: KeyParams;
scroll: ScrollParams;
drag: DragParams;
// 同步和验证
wait: WaitParams;
assert: AssertParams;
// 数据和脚本
extract: ExtractParams;
script: ScriptParams;
http: HttpParams;
screenshot: ScreenshotParams;
// DOM 工具
triggerEvent: TriggerEventParams;
setAttribute: SetAttributeParams;
// 帧和循环
switchFrame: SwitchFrameParams;
loopElements: LoopElementsParams;
// 控制流
if: IfParams;
foreach: ForeachParams;
while: WhileParams;
executeFlow: ExecuteFlowParams;
// 标签页
navigate: NavigateParams;
openTab: OpenTabParams;
switchTab: SwitchTabParams;
closeTab: CloseTabParams;
handleDownload: HandleDownloadParams;
}
export type ActionType = keyof ActionParamsByType;
export interface ActionBase {
id: ActionId;
type: T;
name?: string;
disabled?: boolean;
tags?: ReadonlyArray;
policy?: ActionPolicy;
ui?: { x: number; y: number };
}
export type Action = ActionBase & {
params: ActionParamsByType[T];
};
export type AnyAction = { [T in ActionType]: Action }[ActionType];
export type ExecutableActionType = Exclude;
export type ExecutableAction = Action;
// ================================
// Action 输出
// ================================
export interface HttpResponse {
url: string;
status: number;
headers?: Record;
body?: JsonValue | string | null;
}
export type DownloadState = 'in_progress' | 'complete' | 'interrupted' | 'canceled';
export interface DownloadInfo {
id: string;
filename: string;
url?: string;
state?: DownloadState;
size?: number;
}
/**
* Action 输出类型映射(可通过声明合并扩展)
*/
export interface ActionOutputsByType {
screenshot: { base64Data: string };
extract: { value: JsonValue };
script: { result: JsonValue };
http: { response: HttpResponse };
handleDownload: { download: DownloadInfo };
loopElements: { elements: string[] };
}
export type ActionOutput = T extends keyof ActionOutputsByType
? ActionOutputsByType[T]
: undefined;
// ================================
// 执行接口
// ================================
export type ValidationResult = { ok: true } | { ok: false; errors: NonEmptyArray };
/**
* Execution flags for coordinating with orchestrator policies.
* Used to avoid duplicate retry/nav-wait when StepRunner owns these policies.
*/
export interface ExecutionFlags {
/**
* When true, navigation waiting should be handled by StepRunner.
* Action handlers (click, navigate) should skip their internal nav-wait logic.
*/
skipNavWait?: boolean;
}
export interface ActionExecutionContext {
vars: VariableStore;
tabId: number;
frameId?: number;
runId?: string;
/** 日志记录函数 */
log: (message: string, level?: 'info' | 'warn' | 'error') => void;
/** 截图函数 */
captureScreenshot?: () => Promise;
/**
* Optional structured log sink for replay UIs (legacy RunLogger integration).
* Action handlers may emit richer entries (e.g. selector fallback) via this hook.
*/
pushLog?: (entry: unknown) => void;
/**
* Execution flags provided by the orchestrator.
* Handlers should respect these flags to avoid duplicating StepRunner policies.
*/
execution?: ExecutionFlags;
}
export type ControlDirective =
| {
kind: 'foreach';
listVar: VariableName;
itemVar: VariableName;
subflowId: SubflowId;
concurrency?: number;
}
| {
kind: 'while';
condition: Condition;
subflowId: SubflowId;
maxIterations: number;
};
export interface ActionExecutionResult {
status: 'success' | 'failed' | 'skipped' | 'paused';
output?: ActionOutput;
error?: ActionError;
/** 下一个边的 label(用于条件分支) */
nextLabel?: EdgeLabel;
/** 控制流指令(foreach/while) */
control?: ControlDirective;
/** 执行耗时 */
durationMs?: Milliseconds;
/**
* New tab ID after tab operations (openTab/switchTab).
* Used to update execution context for subsequent steps.
*/
newTabId?: number;
}
/**
* Action 执行器接口
*/
export interface ActionHandler {
type: T;
/** 验证 action 配置 */
validate?: (action: Action) => ValidationResult;
/** 执行 action */
run: (ctx: ActionExecutionContext, action: Action) => Promise>;
/** 生成 action 描述(用于 UI 显示) */
describe?: (action: Action) => string;
}
// ================================
// Flow 图结构
// ================================
export interface ActionEdge {
id: EdgeId;
from: ActionId;
to: ActionId;
label?: EdgeLabel;
}
export interface FlowBinding {
type: 'domain' | 'path' | 'url';
value: string;
}
export interface FlowMeta {
createdAt: ISODateTimeString;
updatedAt: ISODateTimeString;
domain?: string;
tags?: ReadonlyArray;
bindings?: ReadonlyArray;
tool?: { category?: string; description?: string };
exposedOutputs?: ReadonlyArray<{ nodeId: ActionId; as: VariableName }>;
}
export interface Flow {
id: FlowId;
name: string;
description?: string;
version: number;
meta: FlowMeta;
variables?: ReadonlyArray