This is page 6 of 43. Use http://codebase.md/hangwin/mcp-chrome?lines=false&page={x} to view the full context.
# Directory Structure
```
├── .gitattributes
├── .github
│ └── workflows
│ └── build-release.yml
├── .gitignore
├── .husky
│ ├── commit-msg
│ └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── .vscode
│ └── extensions.json
├── app
│ ├── chrome-extension
│ │ ├── _locales
│ │ │ ├── de
│ │ │ │ └── messages.json
│ │ │ ├── en
│ │ │ │ └── messages.json
│ │ │ ├── ja
│ │ │ │ └── messages.json
│ │ │ ├── ko
│ │ │ │ └── messages.json
│ │ │ ├── zh_CN
│ │ │ │ └── messages.json
│ │ │ └── zh_TW
│ │ │ └── messages.json
│ │ ├── .env.example
│ │ ├── assets
│ │ │ └── vue.svg
│ │ ├── common
│ │ │ ├── agent-models.ts
│ │ │ ├── constants.ts
│ │ │ ├── element-marker-types.ts
│ │ │ ├── message-types.ts
│ │ │ ├── node-types.ts
│ │ │ ├── rr-v3-keepalive-protocol.ts
│ │ │ ├── step-types.ts
│ │ │ ├── tool-handler.ts
│ │ │ └── web-editor-types.ts
│ │ ├── entrypoints
│ │ │ ├── background
│ │ │ │ ├── element-marker
│ │ │ │ │ ├── element-marker-storage.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── keepalive-manager.ts
│ │ │ │ ├── native-host.ts
│ │ │ │ ├── quick-panel
│ │ │ │ │ ├── agent-handler.ts
│ │ │ │ │ ├── commands.ts
│ │ │ │ │ └── tabs-handler.ts
│ │ │ │ ├── record-replay
│ │ │ │ │ ├── actions
│ │ │ │ │ │ ├── adapter.ts
│ │ │ │ │ │ ├── handlers
│ │ │ │ │ │ │ ├── assert.ts
│ │ │ │ │ │ │ ├── click.ts
│ │ │ │ │ │ │ ├── common.ts
│ │ │ │ │ │ │ ├── control-flow.ts
│ │ │ │ │ │ │ ├── delay.ts
│ │ │ │ │ │ │ ├── dom.ts
│ │ │ │ │ │ │ ├── drag.ts
│ │ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ │ ├── fill.ts
│ │ │ │ │ │ │ ├── http.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── key.ts
│ │ │ │ │ │ │ ├── navigate.ts
│ │ │ │ │ │ │ ├── screenshot.ts
│ │ │ │ │ │ │ ├── script.ts
│ │ │ │ │ │ │ ├── scroll.ts
│ │ │ │ │ │ │ ├── tabs.ts
│ │ │ │ │ │ │ └── wait.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── registry.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── engine
│ │ │ │ │ │ ├── constants.ts
│ │ │ │ │ │ ├── execution-mode.ts
│ │ │ │ │ │ ├── logging
│ │ │ │ │ │ │ └── run-logger.ts
│ │ │ │ │ │ ├── plugins
│ │ │ │ │ │ │ ├── breakpoint.ts
│ │ │ │ │ │ │ ├── manager.ts
│ │ │ │ │ │ │ └── types.ts
│ │ │ │ │ │ ├── policies
│ │ │ │ │ │ │ ├── retry.ts
│ │ │ │ │ │ │ └── wait.ts
│ │ │ │ │ │ ├── runners
│ │ │ │ │ │ │ ├── after-script-queue.ts
│ │ │ │ │ │ │ ├── control-flow-runner.ts
│ │ │ │ │ │ │ ├── step-executor.ts
│ │ │ │ │ │ │ ├── step-runner.ts
│ │ │ │ │ │ │ └── subflow-runner.ts
│ │ │ │ │ │ ├── scheduler.ts
│ │ │ │ │ │ ├── state-manager.ts
│ │ │ │ │ │ └── utils
│ │ │ │ │ │ └── expression.ts
│ │ │ │ │ ├── flow-runner.ts
│ │ │ │ │ ├── flow-store.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── legacy-types.ts
│ │ │ │ │ ├── nodes
│ │ │ │ │ │ ├── assert.ts
│ │ │ │ │ │ ├── click.ts
│ │ │ │ │ │ ├── conditional.ts
│ │ │ │ │ │ ├── download-screenshot-attr-event-frame-loop.ts
│ │ │ │ │ │ ├── drag.ts
│ │ │ │ │ │ ├── execute-flow.ts
│ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ ├── fill.ts
│ │ │ │ │ │ ├── http.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── key.ts
│ │ │ │ │ │ ├── loops.ts
│ │ │ │ │ │ ├── navigate.ts
│ │ │ │ │ │ ├── script.ts
│ │ │ │ │ │ ├── scroll.ts
│ │ │ │ │ │ ├── tabs.ts
│ │ │ │ │ │ ├── types.ts
│ │ │ │ │ │ └── wait.ts
│ │ │ │ │ ├── recording
│ │ │ │ │ │ ├── browser-event-listener.ts
│ │ │ │ │ │ ├── content-injection.ts
│ │ │ │ │ │ ├── content-message-handler.ts
│ │ │ │ │ │ ├── flow-builder.ts
│ │ │ │ │ │ ├── recorder-manager.ts
│ │ │ │ │ │ └── session-manager.ts
│ │ │ │ │ ├── rr-utils.ts
│ │ │ │ │ ├── selector-engine.ts
│ │ │ │ │ ├── storage
│ │ │ │ │ │ └── indexeddb-manager.ts
│ │ │ │ │ ├── trigger-store.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── record-replay-v3
│ │ │ │ │ ├── bootstrap.ts
│ │ │ │ │ ├── domain
│ │ │ │ │ │ ├── debug.ts
│ │ │ │ │ │ ├── errors.ts
│ │ │ │ │ │ ├── events.ts
│ │ │ │ │ │ ├── flow.ts
│ │ │ │ │ │ ├── ids.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── json.ts
│ │ │ │ │ │ ├── policy.ts
│ │ │ │ │ │ ├── triggers.ts
│ │ │ │ │ │ └── variables.ts
│ │ │ │ │ ├── engine
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── keepalive
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ └── offscreen-keepalive.ts
│ │ │ │ │ │ ├── kernel
│ │ │ │ │ │ │ ├── artifacts.ts
│ │ │ │ │ │ │ ├── breakpoints.ts
│ │ │ │ │ │ │ ├── debug-controller.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── kernel.ts
│ │ │ │ │ │ │ ├── recovery-kernel.ts
│ │ │ │ │ │ │ ├── runner.ts
│ │ │ │ │ │ │ └── traversal.ts
│ │ │ │ │ │ ├── plugins
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── register-v2-replay-nodes.ts
│ │ │ │ │ │ │ ├── registry.ts
│ │ │ │ │ │ │ ├── types.ts
│ │ │ │ │ │ │ └── v2-action-adapter.ts
│ │ │ │ │ │ ├── queue
│ │ │ │ │ │ │ ├── enqueue-run.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── leasing.ts
│ │ │ │ │ │ │ ├── queue.ts
│ │ │ │ │ │ │ └── scheduler.ts
│ │ │ │ │ │ ├── recovery
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ └── recovery-coordinator.ts
│ │ │ │ │ │ ├── storage
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ └── storage-port.ts
│ │ │ │ │ │ ├── transport
│ │ │ │ │ │ │ ├── events-bus.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── rpc-server.ts
│ │ │ │ │ │ │ └── rpc.ts
│ │ │ │ │ │ └── triggers
│ │ │ │ │ │ ├── command-trigger.ts
│ │ │ │ │ │ ├── context-menu-trigger.ts
│ │ │ │ │ │ ├── cron-trigger.ts
│ │ │ │ │ │ ├── dom-trigger.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── interval-trigger.ts
│ │ │ │ │ │ ├── manual-trigger.ts
│ │ │ │ │ │ ├── once-trigger.ts
│ │ │ │ │ │ ├── trigger-handler.ts
│ │ │ │ │ │ ├── trigger-manager.ts
│ │ │ │ │ │ └── url-trigger.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── storage
│ │ │ │ │ ├── db.ts
│ │ │ │ │ ├── events.ts
│ │ │ │ │ ├── flows.ts
│ │ │ │ │ ├── import
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── v2-reader.ts
│ │ │ │ │ │ └── v2-to-v3.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── persistent-vars.ts
│ │ │ │ │ ├── queue.ts
│ │ │ │ │ ├── runs.ts
│ │ │ │ │ └── triggers.ts
│ │ │ │ ├── semantic-similarity.ts
│ │ │ │ ├── storage-manager.ts
│ │ │ │ ├── tools
│ │ │ │ │ ├── base-browser.ts
│ │ │ │ │ ├── browser
│ │ │ │ │ │ ├── bookmark.ts
│ │ │ │ │ │ ├── common.ts
│ │ │ │ │ │ ├── computer.ts
│ │ │ │ │ │ ├── console-buffer.ts
│ │ │ │ │ │ ├── console.ts
│ │ │ │ │ │ ├── dialog.ts
│ │ │ │ │ │ ├── download.ts
│ │ │ │ │ │ ├── element-picker.ts
│ │ │ │ │ │ ├── file-upload.ts
│ │ │ │ │ │ ├── gif-auto-capture.ts
│ │ │ │ │ │ ├── gif-enhanced-renderer.ts
│ │ │ │ │ │ ├── gif-recorder.ts
│ │ │ │ │ │ ├── history.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── inject-script.ts
│ │ │ │ │ │ ├── interaction.ts
│ │ │ │ │ │ ├── javascript.ts
│ │ │ │ │ │ ├── keyboard.ts
│ │ │ │ │ │ ├── network-capture-debugger.ts
│ │ │ │ │ │ ├── network-capture-web-request.ts
│ │ │ │ │ │ ├── network-capture.ts
│ │ │ │ │ │ ├── network-request.ts
│ │ │ │ │ │ ├── performance.ts
│ │ │ │ │ │ ├── read-page.ts
│ │ │ │ │ │ ├── screenshot.ts
│ │ │ │ │ │ ├── userscript.ts
│ │ │ │ │ │ ├── vector-search.ts
│ │ │ │ │ │ ├── web-fetcher.ts
│ │ │ │ │ │ └── window.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── record-replay.ts
│ │ │ │ ├── utils
│ │ │ │ │ └── sidepanel.ts
│ │ │ │ └── web-editor
│ │ │ │ └── index.ts
│ │ │ ├── builder
│ │ │ │ ├── App.vue
│ │ │ │ ├── index.html
│ │ │ │ └── main.ts
│ │ │ ├── content.ts
│ │ │ ├── element-picker.content.ts
│ │ │ ├── offscreen
│ │ │ │ ├── gif-encoder.ts
│ │ │ │ ├── index.html
│ │ │ │ ├── main.ts
│ │ │ │ └── rr-keepalive.ts
│ │ │ ├── options
│ │ │ │ ├── App.vue
│ │ │ │ ├── index.html
│ │ │ │ └── main.ts
│ │ │ ├── popup
│ │ │ │ ├── App.vue
│ │ │ │ ├── components
│ │ │ │ │ ├── builder
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── Canvas.vue
│ │ │ │ │ │ │ ├── EdgePropertyPanel.vue
│ │ │ │ │ │ │ ├── KeyValueEditor.vue
│ │ │ │ │ │ │ ├── nodes
│ │ │ │ │ │ │ │ ├── node-util.ts
│ │ │ │ │ │ │ │ ├── NodeCard.vue
│ │ │ │ │ │ │ │ └── NodeIf.vue
│ │ │ │ │ │ │ ├── properties
│ │ │ │ │ │ │ │ ├── PropertyAssert.vue
│ │ │ │ │ │ │ │ ├── PropertyClick.vue
│ │ │ │ │ │ │ │ ├── PropertyCloseTab.vue
│ │ │ │ │ │ │ │ ├── PropertyDelay.vue
│ │ │ │ │ │ │ │ ├── PropertyDrag.vue
│ │ │ │ │ │ │ │ ├── PropertyExecuteFlow.vue
│ │ │ │ │ │ │ │ ├── PropertyExtract.vue
│ │ │ │ │ │ │ │ ├── PropertyFill.vue
│ │ │ │ │ │ │ │ ├── PropertyForeach.vue
│ │ │ │ │ │ │ │ ├── PropertyFormRenderer.vue
│ │ │ │ │ │ │ │ ├── PropertyFromSpec.vue
│ │ │ │ │ │ │ │ ├── PropertyHandleDownload.vue
│ │ │ │ │ │ │ │ ├── PropertyHttp.vue
│ │ │ │ │ │ │ │ ├── PropertyIf.vue
│ │ │ │ │ │ │ │ ├── PropertyKey.vue
│ │ │ │ │ │ │ │ ├── PropertyLoopElements.vue
│ │ │ │ │ │ │ │ ├── PropertyNavigate.vue
│ │ │ │ │ │ │ │ ├── PropertyOpenTab.vue
│ │ │ │ │ │ │ │ ├── PropertyScreenshot.vue
│ │ │ │ │ │ │ │ ├── PropertyScript.vue
│ │ │ │ │ │ │ │ ├── PropertyScroll.vue
│ │ │ │ │ │ │ │ ├── PropertySetAttribute.vue
│ │ │ │ │ │ │ │ ├── PropertySwitchFrame.vue
│ │ │ │ │ │ │ │ ├── PropertySwitchTab.vue
│ │ │ │ │ │ │ │ ├── PropertyTrigger.vue
│ │ │ │ │ │ │ │ ├── PropertyTriggerEvent.vue
│ │ │ │ │ │ │ │ ├── PropertyWait.vue
│ │ │ │ │ │ │ │ ├── PropertyWhile.vue
│ │ │ │ │ │ │ │ └── SelectorEditor.vue
│ │ │ │ │ │ │ ├── PropertyPanel.vue
│ │ │ │ │ │ │ ├── Sidebar.vue
│ │ │ │ │ │ │ └── TriggerPanel.vue
│ │ │ │ │ │ ├── model
│ │ │ │ │ │ │ ├── form-widget-registry.ts
│ │ │ │ │ │ │ ├── node-spec-registry.ts
│ │ │ │ │ │ │ ├── node-spec.ts
│ │ │ │ │ │ │ ├── node-specs-builtin.ts
│ │ │ │ │ │ │ ├── toast.ts
│ │ │ │ │ │ │ ├── transforms.ts
│ │ │ │ │ │ │ ├── ui-nodes.ts
│ │ │ │ │ │ │ ├── validation.ts
│ │ │ │ │ │ │ └── variables.ts
│ │ │ │ │ │ ├── store
│ │ │ │ │ │ │ └── useBuilderStore.ts
│ │ │ │ │ │ └── widgets
│ │ │ │ │ │ ├── FieldCode.vue
│ │ │ │ │ │ ├── FieldDuration.vue
│ │ │ │ │ │ ├── FieldExpression.vue
│ │ │ │ │ │ ├── FieldKeySequence.vue
│ │ │ │ │ │ ├── FieldSelector.vue
│ │ │ │ │ │ ├── FieldTargetLocator.vue
│ │ │ │ │ │ └── VarInput.vue
│ │ │ │ │ ├── ConfirmDialog.vue
│ │ │ │ │ ├── ElementMarkerManagement.vue
│ │ │ │ │ ├── icons
│ │ │ │ │ │ ├── BoltIcon.vue
│ │ │ │ │ │ ├── CheckIcon.vue
│ │ │ │ │ │ ├── DatabaseIcon.vue
│ │ │ │ │ │ ├── DocumentIcon.vue
│ │ │ │ │ │ ├── EditIcon.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── MarkerIcon.vue
│ │ │ │ │ │ ├── RecordIcon.vue
│ │ │ │ │ │ ├── RefreshIcon.vue
│ │ │ │ │ │ ├── StopIcon.vue
│ │ │ │ │ │ ├── TabIcon.vue
│ │ │ │ │ │ ├── TrashIcon.vue
│ │ │ │ │ │ ├── VectorIcon.vue
│ │ │ │ │ │ └── WorkflowIcon.vue
│ │ │ │ │ ├── LocalModelPage.vue
│ │ │ │ │ ├── ModelCacheManagement.vue
│ │ │ │ │ ├── ProgressIndicator.vue
│ │ │ │ │ └── ScheduleDialog.vue
│ │ │ │ ├── index.html
│ │ │ │ ├── main.ts
│ │ │ │ └── style.css
│ │ │ ├── quick-panel.content.ts
│ │ │ ├── shared
│ │ │ │ ├── composables
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── useRRV3Rpc.ts
│ │ │ │ └── utils
│ │ │ │ ├── index.ts
│ │ │ │ └── rr-flow-convert.ts
│ │ │ ├── sidepanel
│ │ │ │ ├── App.vue
│ │ │ │ ├── components
│ │ │ │ │ ├── agent
│ │ │ │ │ │ ├── AttachmentPreview.vue
│ │ │ │ │ │ ├── ChatInput.vue
│ │ │ │ │ │ ├── CliSettings.vue
│ │ │ │ │ │ ├── ConnectionStatus.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── MessageItem.vue
│ │ │ │ │ │ ├── MessageList.vue
│ │ │ │ │ │ ├── ProjectCreateForm.vue
│ │ │ │ │ │ └── ProjectSelector.vue
│ │ │ │ │ ├── agent-chat
│ │ │ │ │ │ ├── AgentChatShell.vue
│ │ │ │ │ │ ├── AgentComposer.vue
│ │ │ │ │ │ ├── AgentConversation.vue
│ │ │ │ │ │ ├── AgentOpenProjectMenu.vue
│ │ │ │ │ │ ├── AgentProjectMenu.vue
│ │ │ │ │ │ ├── AgentRequestThread.vue
│ │ │ │ │ │ ├── AgentSessionListItem.vue
│ │ │ │ │ │ ├── AgentSessionMenu.vue
│ │ │ │ │ │ ├── AgentSessionSettingsPanel.vue
│ │ │ │ │ │ ├── AgentSessionsView.vue
│ │ │ │ │ │ ├── AgentSettingsMenu.vue
│ │ │ │ │ │ ├── AgentTimeline.vue
│ │ │ │ │ │ ├── AgentTimelineItem.vue
│ │ │ │ │ │ ├── AgentTopBar.vue
│ │ │ │ │ │ ├── ApplyMessageChip.vue
│ │ │ │ │ │ ├── AttachmentCachePanel.vue
│ │ │ │ │ │ ├── ComposerDrawer.vue
│ │ │ │ │ │ ├── ElementChip.vue
│ │ │ │ │ │ ├── FakeCaretOverlay.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── SelectionChip.vue
│ │ │ │ │ │ ├── timeline
│ │ │ │ │ │ │ ├── markstream-thinking.ts
│ │ │ │ │ │ │ ├── ThinkingNode.vue
│ │ │ │ │ │ │ ├── TimelineNarrativeStep.vue
│ │ │ │ │ │ │ ├── TimelineStatusStep.vue
│ │ │ │ │ │ │ ├── TimelineToolCallStep.vue
│ │ │ │ │ │ │ ├── TimelineToolResultCardStep.vue
│ │ │ │ │ │ │ └── TimelineUserPromptStep.vue
│ │ │ │ │ │ └── WebEditorChanges.vue
│ │ │ │ │ ├── AgentChat.vue
│ │ │ │ │ ├── rr-v3
│ │ │ │ │ │ └── DebuggerPanel.vue
│ │ │ │ │ ├── SidepanelNavigator.vue
│ │ │ │ │ └── workflows
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── WorkflowListItem.vue
│ │ │ │ │ └── WorkflowsView.vue
│ │ │ │ ├── composables
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── useAgentChat.ts
│ │ │ │ │ ├── useAgentChatViewRoute.ts
│ │ │ │ │ ├── useAgentInputPreferences.ts
│ │ │ │ │ ├── useAgentProjects.ts
│ │ │ │ │ ├── useAgentServer.ts
│ │ │ │ │ ├── useAgentSessions.ts
│ │ │ │ │ ├── useAgentTheme.ts
│ │ │ │ │ ├── useAgentThreads.ts
│ │ │ │ │ ├── useAttachments.ts
│ │ │ │ │ ├── useFakeCaret.ts
│ │ │ │ │ ├── useFloatingDrag.ts
│ │ │ │ │ ├── useOpenProjectPreference.ts
│ │ │ │ │ ├── useRRV3Debugger.ts
│ │ │ │ │ ├── useRRV3Rpc.ts
│ │ │ │ │ ├── useTextareaAutoResize.ts
│ │ │ │ │ ├── useWebEditorTxState.ts
│ │ │ │ │ └── useWorkflowsV3.ts
│ │ │ │ ├── index.html
│ │ │ │ ├── main.ts
│ │ │ │ ├── styles
│ │ │ │ │ └── agent-chat.css
│ │ │ │ └── utils
│ │ │ │ └── loading-texts.ts
│ │ │ ├── styles
│ │ │ │ └── tailwind.css
│ │ │ ├── web-editor-v2
│ │ │ │ ├── attr-ui-refactor.md
│ │ │ │ ├── constants.ts
│ │ │ │ ├── core
│ │ │ │ │ ├── css-compare.ts
│ │ │ │ │ ├── cssom-styles-collector.ts
│ │ │ │ │ ├── debug-source.ts
│ │ │ │ │ ├── design-tokens
│ │ │ │ │ │ ├── design-tokens-service.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── token-detector.ts
│ │ │ │ │ │ ├── token-resolver.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── editor.ts
│ │ │ │ │ ├── element-key.ts
│ │ │ │ │ ├── event-controller.ts
│ │ │ │ │ ├── execution-tracker.ts
│ │ │ │ │ ├── hmr-consistency.ts
│ │ │ │ │ ├── locator.ts
│ │ │ │ │ ├── message-listener.ts
│ │ │ │ │ ├── payload-builder.ts
│ │ │ │ │ ├── perf-monitor.ts
│ │ │ │ │ ├── position-tracker.ts
│ │ │ │ │ ├── props-bridge.ts
│ │ │ │ │ ├── snap-engine.ts
│ │ │ │ │ ├── transaction-aggregator.ts
│ │ │ │ │ └── transaction-manager.ts
│ │ │ │ ├── drag
│ │ │ │ │ └── drag-reorder-controller.ts
│ │ │ │ ├── overlay
│ │ │ │ │ ├── canvas-overlay.ts
│ │ │ │ │ └── handles-controller.ts
│ │ │ │ ├── selection
│ │ │ │ │ └── selection-engine.ts
│ │ │ │ ├── ui
│ │ │ │ │ ├── breadcrumbs.ts
│ │ │ │ │ ├── floating-drag.ts
│ │ │ │ │ ├── icons.ts
│ │ │ │ │ ├── property-panel
│ │ │ │ │ │ ├── class-editor.ts
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── alignment-grid.ts
│ │ │ │ │ │ │ ├── icon-button-group.ts
│ │ │ │ │ │ │ ├── input-container.ts
│ │ │ │ │ │ │ ├── slider-input.ts
│ │ │ │ │ │ │ └── token-pill.ts
│ │ │ │ │ │ ├── components-tree.ts
│ │ │ │ │ │ ├── controls
│ │ │ │ │ │ │ ├── appearance-control.ts
│ │ │ │ │ │ │ ├── background-control.ts
│ │ │ │ │ │ │ ├── border-control.ts
│ │ │ │ │ │ │ ├── color-field.ts
│ │ │ │ │ │ │ ├── css-helpers.ts
│ │ │ │ │ │ │ ├── effects-control.ts
│ │ │ │ │ │ │ ├── gradient-control.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── layout-control.ts
│ │ │ │ │ │ │ ├── number-stepping.ts
│ │ │ │ │ │ │ ├── position-control.ts
│ │ │ │ │ │ │ ├── size-control.ts
│ │ │ │ │ │ │ ├── spacing-control.ts
│ │ │ │ │ │ │ ├── token-picker.ts
│ │ │ │ │ │ │ └── typography-control.ts
│ │ │ │ │ │ ├── css-defaults.ts
│ │ │ │ │ │ ├── css-panel.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── property-panel.ts
│ │ │ │ │ │ ├── props-panel.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── shadow-host.ts
│ │ │ │ │ └── toolbar.ts
│ │ │ │ └── utils
│ │ │ │ └── disposables.ts
│ │ │ ├── web-editor-v2.ts
│ │ │ └── welcome
│ │ │ ├── App.vue
│ │ │ ├── index.html
│ │ │ └── main.ts
│ │ ├── env.d.ts
│ │ ├── eslint.config.js
│ │ ├── inject-scripts
│ │ │ ├── accessibility-tree-helper.js
│ │ │ ├── click-helper.js
│ │ │ ├── dom-observer.js
│ │ │ ├── element-marker.js
│ │ │ ├── element-picker.js
│ │ │ ├── fill-helper.js
│ │ │ ├── inject-bridge.js
│ │ │ ├── interactive-elements-helper.js
│ │ │ ├── keyboard-helper.js
│ │ │ ├── network-helper.js
│ │ │ ├── props-agent.js
│ │ │ ├── recorder.js
│ │ │ ├── screenshot-helper.js
│ │ │ ├── wait-helper.js
│ │ │ ├── web-editor.js
│ │ │ └── web-fetcher-helper.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── icon
│ │ │ │ ├── 128.png
│ │ │ │ ├── 16.png
│ │ │ │ ├── 32.png
│ │ │ │ ├── 48.png
│ │ │ │ └── 96.png
│ │ │ ├── libs
│ │ │ │ └── ort.min.js
│ │ │ └── wxt.svg
│ │ ├── README.md
│ │ ├── shared
│ │ │ ├── element-picker
│ │ │ │ ├── controller.ts
│ │ │ │ └── index.ts
│ │ │ ├── quick-panel
│ │ │ │ ├── core
│ │ │ │ │ ├── agent-bridge.ts
│ │ │ │ │ ├── search-engine.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── providers
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── tabs-provider.ts
│ │ │ │ └── ui
│ │ │ │ ├── ai-chat-panel.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── markdown-renderer.ts
│ │ │ │ ├── message-renderer.ts
│ │ │ │ ├── panel-shell.ts
│ │ │ │ ├── quick-entries.ts
│ │ │ │ ├── search-input.ts
│ │ │ │ ├── shadow-host.ts
│ │ │ │ └── styles.ts
│ │ │ └── selector
│ │ │ ├── dom-path.ts
│ │ │ ├── fingerprint.ts
│ │ │ ├── generator.ts
│ │ │ ├── index.ts
│ │ │ ├── locator.ts
│ │ │ ├── shadow-dom.ts
│ │ │ ├── stability.ts
│ │ │ ├── strategies
│ │ │ │ ├── anchor-relpath.ts
│ │ │ │ ├── aria.ts
│ │ │ │ ├── css-path.ts
│ │ │ │ ├── css-unique.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── testid.ts
│ │ │ │ └── text.ts
│ │ │ └── types.ts
│ │ ├── tailwind.config.ts
│ │ ├── tests
│ │ │ ├── __mocks__
│ │ │ │ └── hnswlib-wasm-static.ts
│ │ │ ├── record-replay
│ │ │ │ ├── _test-helpers.ts
│ │ │ │ ├── adapter-policy.contract.test.ts
│ │ │ │ ├── flow-store-strip-steps.contract.test.ts
│ │ │ │ ├── high-risk-actions.integration.test.ts
│ │ │ │ ├── hybrid-actions.integration.test.ts
│ │ │ │ ├── script-control-flow.integration.test.ts
│ │ │ │ ├── session-dag-sync.contract.test.ts
│ │ │ │ ├── step-executor.contract.test.ts
│ │ │ │ └── tab-cursor.integration.test.ts
│ │ │ ├── record-replay-v3
│ │ │ │ ├── command-trigger.test.ts
│ │ │ │ ├── context-menu-trigger.test.ts
│ │ │ │ ├── cron-trigger.test.ts
│ │ │ │ ├── debugger.contract.test.ts
│ │ │ │ ├── dom-trigger.test.ts
│ │ │ │ ├── e2e.integration.test.ts
│ │ │ │ ├── events.contract.test.ts
│ │ │ │ ├── interval-trigger.test.ts
│ │ │ │ ├── manual-trigger.test.ts
│ │ │ │ ├── once-trigger.test.ts
│ │ │ │ ├── queue.contract.test.ts
│ │ │ │ ├── recovery.test.ts
│ │ │ │ ├── rpc-api.test.ts
│ │ │ │ ├── runner.onError.contract.test.ts
│ │ │ │ ├── scheduler-integration.test.ts
│ │ │ │ ├── scheduler.test.ts
│ │ │ │ ├── spec-smoke.test.ts
│ │ │ │ ├── trigger-manager.test.ts
│ │ │ │ ├── triggers.test.ts
│ │ │ │ ├── url-trigger.test.ts
│ │ │ │ ├── v2-action-adapter.test.ts
│ │ │ │ ├── v2-adapter-integration.test.ts
│ │ │ │ ├── v2-to-v3-conversion.test.ts
│ │ │ │ └── v3-e2e-harness.ts
│ │ │ ├── vitest.setup.ts
│ │ │ └── web-editor-v2
│ │ │ ├── design-tokens.test.ts
│ │ │ ├── drag-reorder-controller.test.ts
│ │ │ ├── event-controller.test.ts
│ │ │ ├── locator.test.ts
│ │ │ ├── property-panel-live-sync.test.ts
│ │ │ ├── selection-engine.test.ts
│ │ │ ├── snap-engine.test.ts
│ │ │ └── test-utils
│ │ │ └── dom.ts
│ │ ├── tsconfig.json
│ │ ├── types
│ │ │ ├── gifenc.d.ts
│ │ │ └── icons.d.ts
│ │ ├── utils
│ │ │ ├── cdp-session-manager.ts
│ │ │ ├── content-indexer.ts
│ │ │ ├── i18n.ts
│ │ │ ├── image-utils.ts
│ │ │ ├── indexeddb-client.ts
│ │ │ ├── lru-cache.ts
│ │ │ ├── model-cache-manager.ts
│ │ │ ├── offscreen-manager.ts
│ │ │ ├── output-sanitizer.ts
│ │ │ ├── screenshot-context.ts
│ │ │ ├── semantic-similarity-engine.ts
│ │ │ ├── simd-math-engine.ts
│ │ │ ├── text-chunker.ts
│ │ │ └── vector-database.ts
│ │ ├── vitest.config.ts
│ │ ├── workers
│ │ │ ├── ort-wasm-simd-threaded.jsep.mjs
│ │ │ ├── ort-wasm-simd-threaded.jsep.wasm
│ │ │ ├── ort-wasm-simd-threaded.mjs
│ │ │ ├── ort-wasm-simd-threaded.wasm
│ │ │ ├── simd_math_bg.wasm
│ │ │ ├── simd_math.js
│ │ │ └── similarity.worker.js
│ │ └── wxt.config.ts
│ └── native-server
│ ├── .npmignore
│ ├── debug.sh
│ ├── install.md
│ ├── jest.config.js
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── agent
│ │ │ ├── attachment-service.ts
│ │ │ ├── ccr-detector.ts
│ │ │ ├── chat-service.ts
│ │ │ ├── db
│ │ │ │ ├── client.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── schema.ts
│ │ │ ├── directory-picker.ts
│ │ │ ├── engines
│ │ │ │ ├── claude.ts
│ │ │ │ ├── codex.ts
│ │ │ │ └── types.ts
│ │ │ ├── message-service.ts
│ │ │ ├── open-project.ts
│ │ │ ├── project-service.ts
│ │ │ ├── project-types.ts
│ │ │ ├── session-service.ts
│ │ │ ├── storage.ts
│ │ │ ├── stream-manager.ts
│ │ │ ├── tool-bridge.ts
│ │ │ └── types.ts
│ │ ├── cli.ts
│ │ ├── constant
│ │ │ └── index.ts
│ │ ├── file-handler.ts
│ │ ├── index.ts
│ │ ├── mcp
│ │ │ ├── mcp-server-stdio.ts
│ │ │ ├── mcp-server.ts
│ │ │ ├── register-tools.ts
│ │ │ └── stdio-config.json
│ │ ├── native-messaging-host.ts
│ │ ├── scripts
│ │ │ ├── browser-config.ts
│ │ │ ├── build.ts
│ │ │ ├── constant.ts
│ │ │ ├── doctor.ts
│ │ │ ├── postinstall.ts
│ │ │ ├── register-dev.ts
│ │ │ ├── register.ts
│ │ │ ├── report.ts
│ │ │ ├── run_host.bat
│ │ │ ├── run_host.sh
│ │ │ └── utils.ts
│ │ ├── server
│ │ │ ├── index.ts
│ │ │ ├── routes
│ │ │ │ ├── agent.ts
│ │ │ │ └── index.ts
│ │ │ └── server.test.ts
│ │ ├── shims
│ │ │ └── devtools.d.ts
│ │ ├── trace-analyzer.ts
│ │ ├── types
│ │ │ └── devtools-frontend.d.ts
│ │ └── util
│ │ └── logger.ts
│ └── tsconfig.json
├── commitlint.config.cjs
├── docs
│ ├── ARCHITECTURE_zh.md
│ ├── ARCHITECTURE.md
│ ├── CHANGELOG.md
│ ├── CONTRIBUTING_zh.md
│ ├── CONTRIBUTING.md
│ ├── ISSUE.md
│ ├── mcp-cli-config.md
│ ├── TOOLS_zh.md
│ ├── TOOLS.md
│ ├── TROUBLESHOOTING_zh.md
│ ├── TROUBLESHOOTING.md
│ ├── VisualEditor_zh.md
│ ├── VisualEditor.md
│ └── WINDOWS_INSTALL_zh.md
├── eslint.config.js
├── LICENSE
├── package.json
├── packages
│ ├── shared
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── agent-types.ts
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── labels.ts
│ │ │ ├── node-spec-registry.ts
│ │ │ ├── node-spec.ts
│ │ │ ├── node-specs-builtin.ts
│ │ │ ├── rr-graph.ts
│ │ │ ├── step-types.ts
│ │ │ ├── tools.ts
│ │ │ └── types.ts
│ │ └── tsconfig.json
│ └── wasm-simd
│ ├── .gitignore
│ ├── BUILD.md
│ ├── Cargo.toml
│ ├── package.json
│ ├── README.md
│ └── src
│ └── lib.rs
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── prompt
│ ├── content-analize.md
│ ├── excalidraw-prompt.md
│ └── modify-web.md
├── README_zh.md
├── README.md
└── releases
├── chrome-extension
│ └── latest
│ └── chrome-mcp-server-lastest.zip
└── README.md
```
# Files
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/builder/widgets/VarInput.vue:
--------------------------------------------------------------------------------
```vue
<template>
<div class="var-input-wrap">
<input
ref="inputEl"
class="form-input"
:placeholder="placeholder"
:value="modelValue"
@input="onInput"
@keydown="onKeydown"
@blur="onBlur"
@focus="onFocus"
/>
<div
v-if="open && filtered.length"
class="var-suggest"
@mouseenter="hover = true"
@mouseleave="
hover = false;
open = false;
"
>
<div
v-for="(v, i) in filtered"
:key="v.key + ':' + (v.nodeId || '')"
class="var-item"
:class="{ active: i === activeIdx }"
@mousedown.prevent
@click="insertVar(v.key)"
:title="
v.origin === 'node' ? `${v.key} · from ${v.nodeName || v.nodeId}` : `${v.key} · global`
"
>
<span class="var-key">{{ v.key }}</span>
<span class="var-origin" :data-origin="v.origin">{{
v.origin === 'node' ? v.nodeName || v.nodeId || 'node' : 'global'
}}</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import type { VariableOption } from '../model/variables';
import { VAR_PLACEHOLDER, VAR_TOKEN_CLOSE, VAR_TOKEN_OPEN } from '../model/variables';
const props = withDefaults(
defineProps<{
modelValue: string;
variables?: VariableOption[];
placeholder?: string;
// insertion format: "{key}" (mustache) or "workflow.key" (workflowDot)
format?: 'mustache' | 'workflowDot';
}>(),
{ modelValue: '', variables: () => [], format: 'mustache' },
);
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>();
const inputEl = ref<HTMLInputElement | null>(null);
const open = ref(false);
const hover = ref(false);
const activeIdx = ref(0);
const query = computed(() => {
const val = String(props.modelValue || '');
// Extract text after the last '{' up to caret when focused
const el = inputEl.value;
const pos = el?.selectionStart ?? val.length;
const before = val.slice(0, pos);
const lastOpen = before.lastIndexOf(VAR_TOKEN_OPEN);
const lastClose = before.lastIndexOf(VAR_TOKEN_CLOSE);
if (lastOpen >= 0 && lastClose < lastOpen) return before.slice(lastOpen + 1).trim();
// special case: contains '{}' placeholder
if (val.includes(VAR_PLACEHOLDER)) return '';
return '';
});
const filtered = computed<VariableOption[]>(() => {
const all = props.variables || [];
const q = query.value.toLowerCase();
if (!q) return all;
return all.filter((v) => v.key.toLowerCase().startsWith(q));
});
function showSuggestIfNeeded(next: string) {
try {
const el = inputEl.value;
const pos = el?.selectionStart ?? next.length;
const before = next.slice(0, pos);
const shouldOpen = before.endsWith(VAR_TOKEN_OPEN) || next.includes(VAR_PLACEHOLDER);
open.value = shouldOpen;
if (shouldOpen) activeIdx.value = 0;
} catch {
open.value = false;
}
}
function onInput(e: Event) {
const target = e.target as HTMLInputElement;
const v = target?.value ?? '';
emit('update:modelValue', v);
showSuggestIfNeeded(v);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === '{') {
// Defer until input updates
setTimeout(() => showSuggestIfNeeded(String(props.modelValue || '')), 0);
}
// Manual trigger: Ctrl/Cmd+Space opens suggestions
if ((e.ctrlKey || e.metaKey) && e.key === ' ') {
e.preventDefault();
open.value = (props.variables || []).length > 0;
activeIdx.value = 0;
return;
}
if (!open.value) return;
if (e.key === 'Escape') {
open.value = false;
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIdx.value = (activeIdx.value + 1) % Math.max(1, filtered.value.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
activeIdx.value =
(activeIdx.value - 1 + Math.max(1, filtered.value.length)) %
Math.max(1, filtered.value.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
if (!filtered.value.length) return;
e.preventDefault();
insertVar(
filtered.value[Math.max(0, Math.min(activeIdx.value, filtered.value.length - 1))].key,
);
}
}
function onBlur() {
// Close after suggestions click handler
setTimeout(() => (!hover.value ? (open.value = false) : null), 50);
}
function onFocus() {
showSuggestIfNeeded(String(props.modelValue || ''));
}
function insertVar(key: string) {
const el = inputEl.value;
const val = String(props.modelValue || '');
const token =
props.format === 'workflowDot'
? `workflow.${key}`
: `${VAR_TOKEN_OPEN}${key}${VAR_TOKEN_CLOSE}`;
if (!el) {
emit('update:modelValue', `${val}${token}`);
open.value = false;
return;
}
const start = el.selectionStart ?? val.length;
const end = el.selectionEnd ?? start;
const before = val.slice(0, start);
const after = val.slice(end);
const lastOpen = before.lastIndexOf(VAR_TOKEN_OPEN);
const lastClose = before.lastIndexOf(VAR_TOKEN_CLOSE);
let next: string;
if (val.includes(VAR_PLACEHOLDER)) {
const idx = val.indexOf(VAR_PLACEHOLDER);
next = val.slice(0, idx) + token + val.slice(idx + 2);
} else if (lastOpen >= 0 && lastClose < lastOpen) {
// replace incomplete token {xxx| with {key}
next = val.slice(0, lastOpen) + token + after;
} else {
next = before + token + after;
}
emit('update:modelValue', next);
// move caret after inserted token
requestAnimationFrame(() => {
try {
const pos =
props.format === 'workflowDot'
? before.length + token.length
: next.indexOf(VAR_TOKEN_CLOSE, lastOpen >= 0 ? lastOpen : start) + 1 || next.length;
inputEl.value?.setSelectionRange(pos, pos);
} catch {}
});
open.value = false;
}
onMounted(() => {
// best effort: nothing special
});
watch(
() => props.modelValue,
(v) => {
if (document.activeElement === inputEl.value) showSuggestIfNeeded(String(v || ''));
},
);
</script>
<style scoped>
.var-input-wrap {
position: relative;
}
.var-suggest {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 200px;
overflow: auto;
background: var(--rr-bg, #fff);
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
z-index: 1000;
}
.var-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
font-size: 12px;
}
.var-item.active,
.var-item:hover {
background: var(--rr-hover, #f3f4f6);
}
.var-key {
color: var(--rr-text, #111);
}
.var-origin {
color: var(--rr-muted, #666);
}
.var-origin[data-origin='node'] {
color: #2563eb;
}
.var-origin[data-origin='global'] {
color: #059669;
}
</style>
```
--------------------------------------------------------------------------------
/app/native-server/src/agent/db/client.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Database client singleton for Agent storage.
*
* Design principles:
* - Lazy initialization - only connect when first accessed
* - Singleton pattern - single connection throughout the app lifecycle
* - Auto-create tables on first run (no migration tool needed)
* - Configurable path via environment variable
*/
import Database from 'better-sqlite3';
import { drizzle, BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { sql } from 'drizzle-orm';
import * as schema from './schema';
import { getAgentDataDir } from '../storage';
import path from 'node:path';
import { existsSync, mkdirSync } from 'node:fs';
// ============================================================
// Types
// ============================================================
export type DrizzleDB = BetterSQLite3Database<typeof schema>;
// ============================================================
// Singleton State
// ============================================================
let dbInstance: DrizzleDB | null = null;
let sqliteInstance: Database.Database | null = null;
// ============================================================
// Database Path Resolution
// ============================================================
/**
* Get the database file path.
* Environment: CHROME_MCP_AGENT_DB_FILE overrides the default path.
*/
export function getDatabasePath(): string {
const envPath = process.env.CHROME_MCP_AGENT_DB_FILE;
if (envPath && envPath.trim()) {
return path.resolve(envPath.trim());
}
return path.join(getAgentDataDir(), 'agent.db');
}
// ============================================================
// Schema Initialization SQL
// ============================================================
const CREATE_TABLES_SQL = `
-- Projects table
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
root_path TEXT NOT NULL,
preferred_cli TEXT,
selected_model TEXT,
active_claude_session_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_active_at TEXT
);
CREATE INDEX IF NOT EXISTS projects_last_active_idx ON projects(last_active_at);
-- Sessions table
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
engine_name TEXT NOT NULL,
engine_session_id TEXT,
name TEXT,
model TEXT,
permission_mode TEXT NOT NULL DEFAULT 'bypassPermissions',
allow_dangerously_skip_permissions TEXT,
system_prompt_config TEXT,
options_config TEXT,
management_info TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_project_id_idx ON sessions(project_id);
CREATE INDEX IF NOT EXISTS sessions_engine_name_idx ON sessions(engine_name);
-- Messages table
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
session_id TEXT NOT NULL,
conversation_id TEXT,
role TEXT NOT NULL,
content TEXT NOT NULL,
message_type TEXT NOT NULL,
metadata TEXT,
cli_source TEXT,
request_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS messages_project_id_idx ON messages(project_id);
CREATE INDEX IF NOT EXISTS messages_session_id_idx ON messages(session_id);
CREATE INDEX IF NOT EXISTS messages_created_at_idx ON messages(created_at);
CREATE INDEX IF NOT EXISTS messages_request_id_idx ON messages(request_id);
-- Enable foreign key enforcement
PRAGMA foreign_keys = ON;
`;
/**
* Migration SQL to add new columns to existing databases.
* Each migration is idempotent - safe to run multiple times.
*/
const MIGRATION_SQL = `
-- Add active_claude_session_id column if it doesn't exist (for existing databases)
-- SQLite doesn't support IF NOT EXISTS for columns, so we use a workaround
`;
// ============================================================
// Database Initialization
// ============================================================
/**
* Check if a column exists in a table.
*/
function columnExists(sqlite: Database.Database, tableName: string, columnName: string): boolean {
const result = sqlite.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;
return result.some((col) => col.name === columnName);
}
/**
* Run migrations for existing databases.
* Adds new columns that may be missing in older database versions.
*/
function runMigrations(sqlite: Database.Database): void {
// Migration 1: Add active_claude_session_id column to projects table
if (!columnExists(sqlite, 'projects', 'active_claude_session_id')) {
sqlite.exec('ALTER TABLE projects ADD COLUMN active_claude_session_id TEXT');
}
// Migration 2: Add use_ccr column to projects table
if (!columnExists(sqlite, 'projects', 'use_ccr')) {
sqlite.exec('ALTER TABLE projects ADD COLUMN use_ccr TEXT');
}
// Migration 3: Add enable_chrome_mcp column to projects table (default enabled)
if (!columnExists(sqlite, 'projects', 'enable_chrome_mcp')) {
sqlite.exec("ALTER TABLE projects ADD COLUMN enable_chrome_mcp TEXT NOT NULL DEFAULT '1'");
}
}
/**
* Initialize the database schema.
* Safe to call multiple times - uses IF NOT EXISTS.
* Also runs migrations for existing databases.
*/
function initializeSchema(sqlite: Database.Database): void {
sqlite.exec(CREATE_TABLES_SQL);
runMigrations(sqlite);
}
/**
* Ensure the data directory exists.
*/
function ensureDataDir(): void {
const dataDir = getAgentDataDir();
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true });
}
}
// ============================================================
// Public API
// ============================================================
/**
* Get the Drizzle database instance.
* Lazily initializes the connection and schema on first call.
*/
export function getDb(): DrizzleDB {
if (dbInstance) {
return dbInstance;
}
ensureDataDir();
const dbPath = getDatabasePath();
// Create SQLite connection
sqliteInstance = new Database(dbPath);
// Enable WAL mode for better concurrent read performance
sqliteInstance.pragma('journal_mode = WAL');
// Initialize schema
initializeSchema(sqliteInstance);
// Create Drizzle instance
dbInstance = drizzle(sqliteInstance, { schema });
return dbInstance;
}
/**
* Close the database connection.
* Should be called on graceful shutdown.
*/
export function closeDb(): void {
if (sqliteInstance) {
sqliteInstance.close();
sqliteInstance = null;
dbInstance = null;
}
}
/**
* Check if database is initialized.
*/
export function isDbInitialized(): boolean {
return dbInstance !== null;
}
/**
* Execute raw SQL (for advanced use cases).
*/
export function execRawSql(sqlStr: string): void {
if (!sqliteInstance) {
getDb(); // Initialize if not already
}
sqliteInstance!.exec(sqlStr);
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/composables/useAttachments.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Composable for managing file attachments.
* Handles file selection, drag-drop, paste, conversion, preview, and removal.
*/
import { ref, computed } from 'vue';
import type { AgentAttachment } from 'chrome-mcp-shared';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_ATTACHMENTS = 10; // Maximum number of attachments
// Allowed image MIME types (exclude SVG for security)
const ALLOWED_IMAGE_TYPES = new Set([
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
]);
/**
* Extended attachment type with preview URL support.
*/
export interface AttachmentWithPreview extends AgentAttachment {
/** Data URL for image preview (data:xxx;base64,...) */
previewUrl?: string;
}
export function useAttachments() {
const attachments = ref<AttachmentWithPreview[]>([]);
const fileInputRef = ref<HTMLInputElement | null>(null);
const error = ref<string | null>(null);
const isDragOver = ref(false);
// Computed: check if we have any image attachments
const hasImages = computed(() => attachments.value.some((a) => a.type === 'image'));
// Computed: check if we can add more attachments
const canAddMore = computed(() => attachments.value.length < MAX_ATTACHMENTS);
/**
* Open file picker for image selection.
*/
function openFilePicker(): void {
fileInputRef.value?.click();
}
/**
* Convert file to base64 string.
*/
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// Remove data:xxx;base64, prefix
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
/**
* Generate preview URL for image attachments.
*/
function getPreviewUrl(attachment: AttachmentWithPreview): string {
if (attachment.previewUrl) {
return attachment.previewUrl;
}
// Generate data URL from base64
return `data:${attachment.mimeType};base64,${attachment.dataBase64}`;
}
/**
* Process files and add them as attachments.
* This is the core method used by file input, drag-drop, and paste handlers.
*/
async function handleFiles(files: File[]): Promise<void> {
error.value = null;
// Filter to only allowed image types (exclude SVG for security)
const imageFiles = files.filter((file) => ALLOWED_IMAGE_TYPES.has(file.type));
if (imageFiles.length === 0) {
error.value = 'Only PNG, JPEG, GIF, and WebP images are supported.';
return;
}
// Check attachment limit
const remaining = MAX_ATTACHMENTS - attachments.value.length;
if (remaining <= 0) {
error.value = `Maximum ${MAX_ATTACHMENTS} attachments allowed.`;
return;
}
const filesToProcess = imageFiles.slice(0, remaining);
if (filesToProcess.length < imageFiles.length) {
error.value = `Only ${remaining} more attachment(s) allowed. Some files were skipped.`;
}
for (const file of filesToProcess) {
// Validate file size
if (file.size > MAX_FILE_SIZE) {
error.value = `File "${file.name}" is too large. Maximum size is 10MB.`;
continue;
}
try {
const base64 = await fileToBase64(file);
const previewUrl = `data:${file.type};base64,${base64}`;
attachments.value.push({
type: 'image',
name: file.name,
mimeType: file.type || 'image/png',
dataBase64: base64,
previewUrl,
});
} catch (err) {
console.error('Failed to read file:', err);
error.value = `Failed to read file "${file.name}".`;
}
}
}
/**
* Handle file selection from input element.
*/
async function handleFileSelect(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files || files.length === 0) return;
await handleFiles(Array.from(files));
// Clear input to allow selecting the same file again
input.value = '';
}
/**
* Handle drag over event - update visual state.
*/
function handleDragOver(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
isDragOver.value = true;
}
/**
* Handle drag leave event - reset visual state.
*/
function handleDragLeave(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
isDragOver.value = false;
}
/**
* Handle drop event - process dropped files.
*/
async function handleDrop(event: DragEvent): Promise<void> {
event.preventDefault();
event.stopPropagation();
isDragOver.value = false;
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
await handleFiles(Array.from(files));
}
/**
* Handle paste event - extract and process pasted images.
*/
async function handlePaste(event: ClipboardEvent): Promise<void> {
const items = event.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (const item of items) {
// Only allow specific image types (exclude SVG for security)
if (ALLOWED_IMAGE_TYPES.has(item.type)) {
const file = item.getAsFile();
if (file) {
// Generate a name for pasted images (they don't have one)
const ext = item.type.split('/')[1] || 'png';
const namedFile = new File([file], `pasted-image-${Date.now()}.${ext}`, {
type: file.type,
});
imageFiles.push(namedFile);
}
}
}
if (imageFiles.length > 0) {
// Prevent default paste behavior for images
event.preventDefault();
await handleFiles(imageFiles);
}
// Let text paste through normally
}
/**
* Remove attachment by index.
*/
function removeAttachment(index: number): void {
attachments.value.splice(index, 1);
error.value = null;
}
/**
* Clear all attachments.
*/
function clearAttachments(): void {
attachments.value = [];
error.value = null;
}
/**
* Get attachments for sending (strips preview URLs).
*/
function getAttachments(): AgentAttachment[] | undefined {
if (attachments.value.length === 0) return undefined;
return attachments.value.map(({ type, name, mimeType, dataBase64 }) => ({
type,
name,
mimeType,
dataBase64,
}));
}
return {
// State
attachments,
fileInputRef,
error,
isDragOver,
// Computed
hasImages,
canAddMore,
// Methods
openFilePicker,
handleFileSelect,
handleFiles,
handleDragOver,
handleDragLeave,
handleDrop,
handlePaste,
removeAttachment,
clearAttachments,
getAttachments,
getPreviewUrl,
};
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/builder/model/transforms.ts:
--------------------------------------------------------------------------------
```typescript
import type {
Flow as FlowV2,
NodeBase,
Edge as EdgeV2,
} from '@/entrypoints/background/record-replay/types';
import {
nodesToSteps as sharedNodesToSteps,
stepsToNodes as sharedStepsToNodes,
topoOrder as sharedTopoOrder,
} from 'chrome-mcp-shared';
import { STEP_TYPES } from 'chrome-mcp-shared';
import { EDGE_LABELS } from 'chrome-mcp-shared';
export function newId(prefix: string) {
return `${prefix}_${Math.random().toString(36).slice(2, 8)}`;
}
export type NodeType = NodeBase['type'];
export function defaultConfigFor(t: NodeType): any {
if ((t as any) === 'trigger') return { type: 'manual', description: '' };
if (t === STEP_TYPES.CLICK || t === STEP_TYPES.FILL)
return { target: { candidates: [] }, value: t === STEP_TYPES.FILL ? '' : undefined };
if (t === STEP_TYPES.IF)
return { branches: [{ id: newId('case'), name: '', expr: '' }], else: true };
if (t === STEP_TYPES.NAVIGATE) return { url: '' };
if (t === STEP_TYPES.WAIT) return { condition: { text: '', appear: true } };
if (t === STEP_TYPES.ASSERT) return { assert: { exists: '' } };
if (t === STEP_TYPES.KEY) return { keys: '' };
if (t === STEP_TYPES.DELAY) return { ms: 1000 };
if (t === STEP_TYPES.HTTP) return { method: 'GET', url: '', headers: {}, body: null, saveAs: '' };
if (t === STEP_TYPES.EXTRACT) return { selector: '', attr: 'text', js: '', saveAs: '' };
if (t === STEP_TYPES.SCREENSHOT) return { selector: '', fullPage: false, saveAs: 'shot' };
if (t === STEP_TYPES.DRAG)
return { start: { candidates: [] }, end: { candidates: [] }, path: [] };
if (t === STEP_TYPES.SCROLL)
return { mode: 'offset', offset: { x: 0, y: 300 }, target: { candidates: [] } };
if (t === STEP_TYPES.TRIGGER_EVENT)
return { target: { candidates: [] }, event: 'input', bubbles: true, cancelable: false };
if (t === STEP_TYPES.SET_ATTRIBUTE) return { target: { candidates: [] }, name: '', value: '' };
if (t === STEP_TYPES.LOOP_ELEMENTS)
return { selector: '', saveAs: 'elements', itemVar: 'item', subflowId: '' };
if (t === STEP_TYPES.SWITCH_FRAME) return { frame: { index: 0, urlContains: '' } };
if (t === STEP_TYPES.HANDLE_DOWNLOAD)
return { filenameContains: '', waitForComplete: true, timeoutMs: 60000, saveAs: 'download' };
if (t === STEP_TYPES.EXECUTE_FLOW) return { flowId: '', inline: true, args: {} };
if (t === STEP_TYPES.OPEN_TAB) return { url: '', newWindow: false };
if (t === STEP_TYPES.SWITCH_TAB) return { tabId: null, urlContains: '', titleContains: '' };
if (t === STEP_TYPES.CLOSE_TAB) return { tabIds: [], url: '' };
if (t === STEP_TYPES.SCRIPT) return { world: 'ISOLATED', code: '', saveAs: '', assign: {} };
return {};
}
export function stepsToNodes(steps: any[]): NodeBase[] {
const base = sharedStepsToNodes(steps) as unknown as NodeBase[];
// add simple UI positions
base.forEach((n, i) => {
(n as any).ui = (n as any).ui || { x: 200, y: 120 + i * 120 };
});
return base;
}
export function topoOrder(nodes: NodeBase[], edges: EdgeV2[]): NodeBase[] {
const filtered = (edges || []).filter((e) => !e.label || e.label === EDGE_LABELS.DEFAULT);
return sharedTopoOrder(nodes as any, filtered as any) as any;
}
export function nodesToSteps(nodes: NodeBase[], edges: EdgeV2[]): any[] {
// Exclude non-executable nodes like 'trigger' and cut edges from them
const execNodes = (nodes || []).filter((n) => n.type !== ('trigger' as any));
const filtered = (edges || []).filter(
(e) =>
(!e.label || e.label === EDGE_LABELS.DEFAULT) && !execNodes.every((n) => n.id !== e.from),
);
return sharedNodesToSteps(execNodes as any, filtered as any);
}
export function autoChainEdges(nodes: NodeBase[]): EdgeV2[] {
const arr: EdgeV2[] = [];
for (let i = 0; i < nodes.length - 1; i++)
arr.push({
id: newId('e'),
from: nodes[i].id,
to: nodes[i + 1].id,
label: EDGE_LABELS.DEFAULT,
});
return arr;
}
export function summarizeNode(n?: NodeBase | null): string {
if (!n) return '';
if (n.type === STEP_TYPES.CLICK || n.type === STEP_TYPES.FILL)
return n.config?.target?.candidates?.[0]?.value || '未配置选择器';
if (n.type === STEP_TYPES.NAVIGATE) return n.config?.url || '';
if (n.type === STEP_TYPES.KEY) return n.config?.keys || '';
if (n.type === STEP_TYPES.DELAY) return `${Number(n.config?.ms || 0)}ms`;
if (n.type === STEP_TYPES.HTTP) return `${n.config?.method || 'GET'} ${n.config?.url || ''}`;
if (n.type === STEP_TYPES.EXTRACT)
return `${n.config?.selector || ''} -> ${n.config?.saveAs || ''}`;
if (n.type === STEP_TYPES.SCREENSHOT)
return n.config?.selector
? `el(${n.config.selector}) -> ${n.config?.saveAs || ''}`
: `fullPage -> ${n.config?.saveAs || ''}`;
if (n.type === STEP_TYPES.TRIGGER_EVENT)
return `${n.config?.event || ''} ${n.config?.target?.candidates?.[0]?.value || ''}`;
if (n.type === STEP_TYPES.SET_ATTRIBUTE)
return `${n.config?.name || ''}=${n.config?.value ?? ''}`;
if (n.type === STEP_TYPES.LOOP_ELEMENTS)
return `${n.config?.selector || ''} as ${n.config?.itemVar || 'item'} -> ${n.config?.subflowId || ''}`;
if (n.type === STEP_TYPES.SWITCH_FRAME)
return n.config?.frame?.urlContains
? `url~${n.config.frame.urlContains}`
: `index=${Number(n.config?.frame?.index ?? 0)}`;
if (n.type === STEP_TYPES.OPEN_TAB) return `open ${n.config?.url || ''}`;
if (n.type === STEP_TYPES.SWITCH_TAB)
return `switch ${n.config?.tabId || n.config?.urlContains || n.config?.titleContains || ''}`;
if (n.type === STEP_TYPES.CLOSE_TAB) return `close ${n.config?.url || ''}`;
if (n.type === STEP_TYPES.HANDLE_DOWNLOAD) return `download ${n.config?.filenameContains || ''}`;
if (n.type === STEP_TYPES.WAIT) return JSON.stringify(n.config?.condition || {});
if (n.type === STEP_TYPES.ASSERT) return JSON.stringify(n.config?.assert || {});
if (n.type === STEP_TYPES.IF) {
const cnt = Array.isArray(n.config?.branches) ? n.config.branches.length : 0;
return `if/else 分支数 ${cnt}${n.config?.else === false ? '' : ' + else'}`;
}
if (n.type === STEP_TYPES.SCRIPT) return (n.config?.code || '').slice(0, 30);
if (n.type === STEP_TYPES.DRAG) {
const a = n.config?.start?.candidates?.[0]?.value || '';
const b = n.config?.end?.candidates?.[0]?.value || '';
return a || b ? `${a} -> ${b}` : '拖拽';
}
if (n.type === STEP_TYPES.SCROLL) {
const mode = n.config?.mode || 'offset';
if (mode === 'offset' || mode === 'container') {
const x = Number(n.config?.offset?.x ?? 0);
const y = Number(n.config?.offset?.y ?? 0);
return `${mode} (${x}, ${y})`;
}
const sel = n.config?.target?.candidates?.[0]?.value || '';
return sel ? `element ${sel}` : 'element';
}
if (n.type === STEP_TYPES.EXECUTE_FLOW) return `exec ${n.config?.flowId || ''}`;
return '';
}
export function cloneFlow(flow: FlowV2): FlowV2 {
return JSON.parse(JSON.stringify(flow));
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/ThinkingNode.vue:
--------------------------------------------------------------------------------
```vue
<template>
<!-- Use span-based structure to avoid invalid DOM when rendered inside <p> -->
<span class="thinking-section">
<button
type="button"
class="thinking-header"
:class="{ 'thinking-header--expandable': canExpand }"
:aria-expanded="canExpand ? expanded : undefined"
:disabled="!canExpand"
@click="toggle"
>
<svg
class="thinking-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
<span v-if="isLoading" class="thinking-loading">
<span class="thinking-pulse" aria-hidden="true" />
Thinking...
</span>
<template v-else>
<span class="thinking-summary" v-html="formatLine(firstLine)" />
<span v-if="canExpand" class="thinking-toggle">
<svg
:class="{ 'thinking-toggle--expanded': expanded }"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
{{ moreCount }} more {{ moreCount === 1 ? 'line' : 'lines' }}
</span>
</template>
</button>
<Transition name="thinking-expand">
<span v-if="expanded && !isLoading && restLines.length > 0" class="thinking-content">
<template v-for="(line, idx) in restLines" :key="idx">
<span v-html="formatLine(line)" />
<br v-if="idx < restLines.length - 1" />
</template>
</span>
</Transition>
</span>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
/**
* Node type from markstream-vue for custom HTML tags.
* When customHtmlTags=['thinking'] is set, the parser produces nodes with type='thinking'.
*/
interface ThinkingNodeType {
type: 'thinking';
tag?: string;
content: string;
raw: string;
loading?: boolean;
autoClosed?: boolean;
attrs?: Array<[string, string]>;
}
const props = defineProps<{
node: ThinkingNodeType;
loading?: boolean;
indexKey?: string;
customId?: string;
isDark?: boolean;
typewriter?: boolean;
}>();
const expanded = ref(false);
/** Whether the node is still loading (streaming, tag not closed yet) */
const isLoading = computed(() => props.loading ?? props.node.loading ?? false);
/**
* Extract inner text from the thinking node.
* Prefer node.raw over node.content as content may lose line breaks in some cases.
*/
const innerText = computed(() => {
// Try raw first (more reliable for preserving line breaks)
const rawSrc = String(props.node.raw ?? '');
if (rawSrc) {
const rawMatch = rawSrc.match(/<thinking\b[^>]*>([\s\S]*?)<\/thinking>/i);
if (rawMatch) {
return rawMatch[1].trim();
}
}
// Fallback to content
const src = String(props.node.content ?? '');
const match = src.match(/<thinking\b[^>]*>([\s\S]*?)<\/thinking>/i);
if (match) {
return match[1].trim();
}
// Strip opening/closing tags if present
return src
.replace(/^<thinking\b[^>]*>/i, '')
.replace(/<\/thinking>\s*$/i, '')
.trim();
});
/** Split content into lines, filtering empty ones */
const lines = computed(() => {
return innerText.value.split('\n').filter((line) => line.trim());
});
/** First line shown as summary */
const firstLine = computed(() => {
const line = lines.value[0] ?? '';
// Strip leading/trailing ** for cleaner display
return line.replace(/^\*\*/, '').replace(/\*\*$/, '');
});
/** Remaining lines for expanded view */
const restLines = computed(() => lines.value.slice(1));
/** Number of additional lines */
const moreCount = computed(() => restLines.value.length);
/** Whether the section can be expanded */
const canExpand = computed(() => !isLoading.value && moreCount.value > 0);
function toggle(): void {
if (canExpand.value) {
expanded.value = !expanded.value;
}
}
/**
* Format a line for display, converting **text** to <strong> tags.
* Used with v-html for both summary and expanded content.
*/
function formatLine(text: string): string {
// Escape HTML entities first
const escaped = text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
// Convert **text** to <strong>text</strong>
return escaped.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
}
</script>
<style scoped>
.thinking-section {
display: block;
margin: 8px 0;
padding-left: 12px;
background: var(--ac-surface-muted);
border-radius: var(--ac-radius-inner);
}
.thinking-header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px;
border: none;
background: transparent;
color: var(--ac-text-muted);
font-size: 13px;
font-style: italic;
font-family: inherit;
text-align: left;
cursor: default;
}
.thinking-header--expandable {
cursor: pointer;
transition: color 0.15s ease;
}
.thinking-header--expandable:hover {
color: var(--ac-text);
}
.thinking-header--expandable:focus-visible {
outline: 2px solid var(--ac-accent);
outline-offset: -2px;
border-radius: var(--ac-radius-inner);
}
.thinking-icon {
flex-shrink: 0;
opacity: 0.7;
}
.thinking-loading {
display: flex;
align-items: center;
gap: 6px;
}
.thinking-pulse {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ac-accent);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.4;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
}
.thinking-summary {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thinking-summary :deep(strong) {
font-weight: 600;
color: var(--ac-text-muted);
}
.thinking-toggle {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
font-size: 11px;
color: var(--ac-text-subtle);
}
.thinking-toggle svg {
transition: transform 0.2s ease;
}
.thinking-toggle--expanded {
transform: rotate(180deg);
}
.thinking-content {
display: block;
padding: 0 8px 8px;
color: var(--ac-text-subtle);
font-size: 13px;
font-style: italic;
line-height: 1.6;
}
.thinking-content :deep(strong) {
font-weight: 600;
color: var(--ac-text-muted);
}
/* Expand animation */
.thinking-expand-enter-active,
.thinking-expand-leave-active {
transition:
opacity 0.2s ease,
max-height 0.2s ease;
overflow: hidden;
}
.thinking-expand-enter-from,
.thinking-expand-leave-to {
opacity: 0;
max-height: 0;
}
.thinking-expand-enter-to,
.thinking-expand-leave-from {
opacity: 1;
max-height: 500px;
}
</style>
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/url-trigger.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview URL Trigger Handler (P4-03)
* @description
* Listens to `chrome.webNavigation.onCompleted` and fires installed URL triggers.
*
* URL matching semantics:
* - kind:'url' - Full URL prefix match (allows query/hash variations)
* - kind:'domain' - Safe subdomain match (hostname === domain OR hostname.endsWith('.' + domain))
* - kind:'path' - Pathname prefix match
*
* Design rationale:
* - No regex/wildcards for performance and auditability
* - Domain matching uses safe subdomain logic to avoid false positives (e.g. 'notexample.com')
* - Single listener instance manages multiple triggers efficiently
*/
import type { TriggerId } from '../../domain/ids';
import type { TriggerSpecByKind, UrlMatchRule } from '../../domain/triggers';
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
// ==================== Types ====================
export interface UrlTriggerHandlerDeps {
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
}
type UrlTriggerSpec = TriggerSpecByKind<'url'>;
/**
* Compiled URL match rules for efficient matching
*/
interface CompiledUrlRules {
/** Full URL prefixes */
urlPrefixes: string[];
/** Normalized domains (lowercase, no leading/trailing dots) */
domains: string[];
/** Normalized path prefixes (always starts with '/') */
pathPrefixes: string[];
}
interface InstalledUrlTrigger {
spec: UrlTriggerSpec;
rules: CompiledUrlRules;
}
// ==================== Normalization Utilities ====================
/**
* Normalize domain value
* - Trim whitespace
* - Convert to lowercase
* - Remove leading/trailing dots
*/
function normalizeDomain(value: string): string | null {
const normalized = value.trim().toLowerCase().replace(/^\.+/, '').replace(/\.+$/, '');
return normalized || null;
}
/**
* Normalize path prefix
* - Trim whitespace
* - Ensure starts with '/'
*/
function normalizePathPrefix(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
}
/**
* Normalize URL prefix
* - Trim whitespace only
*/
function normalizeUrlPrefix(value: string): string | null {
const trimmed = value.trim();
return trimmed || null;
}
/**
* Compile URL match rules from spec
*/
function compileUrlMatchRules(match: UrlMatchRule[] | undefined): CompiledUrlRules {
const urlPrefixes: string[] = [];
const domains: string[] = [];
const pathPrefixes: string[] = [];
for (const rule of match ?? []) {
const { kind } = rule;
const raw = typeof rule.value === 'string' ? rule.value : String(rule.value ?? '');
switch (kind) {
case 'url': {
const normalized = normalizeUrlPrefix(raw);
if (normalized) urlPrefixes.push(normalized);
break;
}
case 'domain': {
const normalized = normalizeDomain(raw);
if (normalized) domains.push(normalized);
break;
}
case 'path': {
const normalized = normalizePathPrefix(raw);
if (normalized) pathPrefixes.push(normalized);
break;
}
}
}
return { urlPrefixes, domains, pathPrefixes };
}
// ==================== Matching Logic ====================
/**
* Check if hostname matches domain (exact or subdomain)
*/
function hostnameMatchesDomain(hostname: string, domain: string): boolean {
if (hostname === domain) return true;
return hostname.endsWith(`.${domain}`);
}
/**
* Check if URL matches any of the compiled rules
*/
function matchesRules(compiled: CompiledUrlRules, urlString: string, parsed: URL): boolean {
// URL prefix match
for (const prefix of compiled.urlPrefixes) {
if (urlString.startsWith(prefix)) return true;
}
// Domain match
const hostname = parsed.hostname.toLowerCase();
for (const domain of compiled.domains) {
if (hostnameMatchesDomain(hostname, domain)) return true;
}
// Path prefix match
const pathname = parsed.pathname || '/';
for (const prefix of compiled.pathPrefixes) {
if (pathname.startsWith(prefix)) return true;
}
return false;
}
// ==================== Handler Implementation ====================
/**
* Create URL trigger handler factory
*/
export function createUrlTriggerHandlerFactory(
deps?: UrlTriggerHandlerDeps,
): TriggerHandlerFactory<'url'> {
return (fireCallback) => createUrlTriggerHandler(fireCallback, deps);
}
/**
* Create URL trigger handler
*/
export function createUrlTriggerHandler(
fireCallback: TriggerFireCallback,
deps?: UrlTriggerHandlerDeps,
): TriggerHandler<'url'> {
const logger = deps?.logger ?? console;
const installed = new Map<TriggerId, InstalledUrlTrigger>();
let listening = false;
/**
* Handle webNavigation.onCompleted event
*/
const onCompleted = (details: chrome.webNavigation.WebNavigationFramedCallbackDetails): void => {
// Only handle main frame navigations
if (details.frameId !== 0) return;
const urlString = details.url;
// Parse URL
let parsed: URL;
try {
parsed = new URL(urlString);
} catch {
return; // Invalid URL, skip
}
if (installed.size === 0) return;
// Snapshot to avoid iteration hazards during concurrent install/uninstall
const snapshot = Array.from(installed.entries());
for (const [triggerId, trigger] of snapshot) {
if (!matchesRules(trigger.rules, urlString, parsed)) continue;
// Fire and forget: chrome event listeners should not block navigation
Promise.resolve(
fireCallback.onFire(triggerId, {
sourceTabId: details.tabId,
sourceUrl: urlString,
}),
).catch((e) => {
logger.error(`[UrlTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
});
}
};
/**
* Ensure listener is registered
*/
function ensureListening(): void {
if (listening) return;
if (!chrome.webNavigation?.onCompleted?.addListener) {
logger.warn('[UrlTriggerHandler] chrome.webNavigation.onCompleted is unavailable');
return;
}
chrome.webNavigation.onCompleted.addListener(onCompleted);
listening = true;
}
/**
* Stop listening
*/
function stopListening(): void {
if (!listening) return;
try {
chrome.webNavigation.onCompleted.removeListener(onCompleted);
} catch (e) {
logger.debug('[UrlTriggerHandler] removeListener failed:', e);
} finally {
listening = false;
}
}
return {
kind: 'url',
async install(trigger: UrlTriggerSpec): Promise<void> {
installed.set(trigger.id, {
spec: trigger,
rules: compileUrlMatchRules(trigger.match),
});
ensureListening();
},
async uninstall(triggerId: string): Promise<void> {
installed.delete(triggerId as TriggerId);
if (installed.size === 0) {
stopListening();
}
},
async uninstallAll(): Promise<void> {
installed.clear();
stopListening();
},
getInstalledIds(): string[] {
return Array.from(installed.keys());
},
};
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/script.ts:
--------------------------------------------------------------------------------
```typescript
/**
* 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<string, Resolvable<JsonValue>> | undefined,
vars: VariableStore,
): { ok: true; resolved: Record<string, JsonValue> } | { ok: false; error: string } {
if (!args) return { ok: true, resolved: {} };
const resolved: Record<string, JsonValue> = {};
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<string | number> = [];
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<string | number, unknown>)[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<string, JsonValue>,
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<string, JsonValue>) => {
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 },
};
},
};
```
--------------------------------------------------------------------------------
/app/chrome-extension/tests/web-editor-v2/property-panel-live-sync.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Unit tests for Web Editor V2 Property Panel Live Style Sync.
*
* These tests focus on:
* - MutationObserver setup for style attribute changes (Bug 3 fix)
* - rAF throttling of refresh calls
* - Proper cleanup on target change and dispose
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// =============================================================================
// Test Setup
// =============================================================================
// Mock MutationObserver
let mockObserverCallback: MutationCallback | null = null;
let mockObserverDisconnect: ReturnType<typeof vi.fn>;
class MockMutationObserver {
callback: MutationCallback;
constructor(callback: MutationCallback) {
this.callback = callback;
mockObserverCallback = callback;
}
observe = vi.fn();
disconnect = vi.fn(() => {
mockObserverDisconnect?.();
});
takeRecords = vi.fn(() => []);
}
beforeEach(() => {
mockObserverCallback = null;
mockObserverDisconnect = vi.fn();
// Install mock MutationObserver
vi.stubGlobal('MutationObserver', MockMutationObserver);
// Mock requestAnimationFrame
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((cb: FrameRequestCallback) => {
// Execute immediately for testing
cb(performance.now());
return 1;
}),
);
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
// =============================================================================
// MutationObserver Integration Tests
// =============================================================================
describe('property-panel: live style sync', () => {
it('should observe style attribute changes on target element', () => {
// This is a conceptual test for the MutationObserver setup
// The actual implementation is in property-panel.ts
const target = document.createElement('div');
const observer = new MockMutationObserver(() => {});
observer.observe(target, {
attributes: true,
attributeFilter: ['style'],
});
expect(observer.observe).toHaveBeenCalledWith(target, {
attributes: true,
attributeFilter: ['style'],
});
});
it('should trigger callback when style changes', () => {
const callback = vi.fn();
const observer = new MockMutationObserver(callback);
const target = document.createElement('div');
observer.observe(target, { attributes: true, attributeFilter: ['style'] });
// Simulate style mutation with a minimal MutationRecord-like object
if (mockObserverCallback) {
mockObserverCallback(
[
{
type: 'attributes',
target,
attributeName: 'style',
attributeNamespace: null,
oldValue: null,
addedNodes: { length: 0 } as unknown as NodeList,
removedNodes: { length: 0 } as unknown as NodeList,
previousSibling: null,
nextSibling: null,
} as MutationRecord,
],
observer as unknown as MutationObserver,
);
}
expect(callback).toHaveBeenCalled();
});
it('should disconnect observer when target changes', () => {
const observer = new MockMutationObserver(() => {});
observer.disconnect();
expect(observer.disconnect).toHaveBeenCalled();
});
});
// =============================================================================
// rAF Throttling Tests
// =============================================================================
describe('property-panel: rAF throttling', () => {
it('should coalesce multiple style changes into single refresh', () => {
let rafCallCount = 0;
let scheduledCallback: FrameRequestCallback | null = null;
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCallCount++;
scheduledCallback = cb;
return rafCallCount;
});
// Simulate the throttling logic
let rafId: number | null = null;
const refreshCalls: number[] = [];
function scheduleRefresh(): void {
if (rafId !== null) return; // Already scheduled
rafId = requestAnimationFrame(() => {
rafId = null;
refreshCalls.push(Date.now());
});
}
// Schedule multiple refreshes
scheduleRefresh();
scheduleRefresh();
scheduleRefresh();
// Only one rAF should be scheduled
expect(rafCallCount).toBe(1);
// Execute the callback
if (scheduledCallback) {
scheduledCallback(performance.now());
}
// Only one refresh should have occurred
expect(refreshCalls.length).toBe(1);
});
it('should cancel pending rAF on cleanup', () => {
const cancelRaf = vi.fn();
vi.stubGlobal('cancelAnimationFrame', cancelRaf);
let rafId: number | null = requestAnimationFrame(() => {});
// Cleanup
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
expect(cancelRaf).toHaveBeenCalled();
});
});
// =============================================================================
// Lifecycle Tests
// =============================================================================
describe('property-panel: observer lifecycle', () => {
it('should disconnect old observer before connecting new one', () => {
const disconnectCalls: string[] = [];
class TrackedObserver {
id: string;
constructor(id: string) {
this.id = id;
}
observe = vi.fn();
disconnect = vi.fn(() => {
disconnectCalls.push(this.id);
});
}
// Simulate target change
const observer1 = new TrackedObserver('observer1');
const observer2 = new TrackedObserver('observer2');
// First target
const target1 = document.createElement('div');
observer1.observe(target1, { attributes: true });
// Change target - should disconnect old observer first
observer1.disconnect();
observer2.observe(document.createElement('div'), { attributes: true });
expect(disconnectCalls).toContain('observer1');
});
it('should handle null target gracefully', () => {
// When target is null, should disconnect and not create new observer
const observer = new MockMutationObserver(() => {});
// Simulate setTarget(null)
observer.disconnect();
expect(observer.disconnect).toHaveBeenCalled();
});
it('should handle disconnected target gracefully', () => {
const callback = vi.fn();
const observer = new MockMutationObserver(callback);
const target = document.createElement('div');
// Target not connected to DOM
expect(target.isConnected).toBe(false);
// Should still be able to observe (MutationObserver allows this)
observer.observe(target, { attributes: true });
// Callback should check isConnected before processing
if (mockObserverCallback) {
// Simulate mutation on disconnected element
mockObserverCallback([], observer as unknown as MutationObserver);
}
// In real implementation, the callback should guard against disconnected elements
});
});
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/engine/execution-mode.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Execution Mode Configuration
*
* Controls whether step execution uses the legacy node system or the new ActionRegistry.
* Provides a migration path from legacy to actions with hybrid mode for gradual rollout.
*
* Modes:
* - 'legacy': Use the existing executeStep from nodes/index.ts (default, safest)
* - 'actions': Use ActionRegistry exclusively (strict mode, throws on unsupported)
* - 'hybrid': Try ActionRegistry first, fall back to legacy for unsupported types
*/
import type { Step } from '../types';
/**
* Execution mode determines how steps are executed
*/
export type ExecutionMode = 'legacy' | 'actions' | 'hybrid';
/**
* Configuration for execution mode
*/
export interface ExecutionModeConfig {
/**
* The execution mode to use
* @default 'legacy'
*/
mode: ExecutionMode;
/**
* Step types that should always use legacy execution (denylist for actions)
* Only applies in hybrid mode
*/
legacyOnlyTypes?: Set<string>;
/**
* Step types that should use actions execution (allowlist)
* Only applies in hybrid mode.
* - If undefined: uses MINIMAL_HYBRID_ACTION_TYPES (safest default)
* - If empty Set (size=0): falls back to MIGRATED_ACTION_TYPES policy
* - If non-empty Set: only these types use actions
*/
actionsAllowlist?: Set<string>;
/**
* Whether to log when falling back from actions to legacy in hybrid mode
* @default true
*/
logFallbacks?: boolean;
/**
* Skip ActionRegistry's built-in retry policy.
* When true, action.policy.retry is removed before execution.
* @default true - StepRunner already handles retry via withRetry()
*
* Note: ActionRegistry timeout is NOT disabled (provides per-action timeout safety).
*/
skipActionsRetry?: boolean;
/**
* Skip ActionRegistry's navigation waiting when StepRunner handles it
* @default true - StepRunner already handles navigation waiting
*/
skipActionsNavWait?: boolean;
}
/**
* Default execution mode configuration
* Starts with legacy mode for maximum safety during migration
*/
export const DEFAULT_EXECUTION_MODE_CONFIG: ExecutionModeConfig = {
mode: 'legacy',
logFallbacks: true,
skipActionsRetry: true,
skipActionsNavWait: true,
};
/**
* Minimal allowlist for initial hybrid rollout.
*
* This keeps high-risk step types (navigation/click/tab management) on legacy
* until policy (retry/timeout/nav-wait) and tab cursor semantics are unified.
*
* These types are chosen for their low risk:
* - No navigation side effects
* - No tab management
* - No complex timing requirements
* - Simple input/output semantics
*/
export const MINIMAL_HYBRID_ACTION_TYPES = new Set<string>([
'fill', // Form input - no navigation
'key', // Keyboard input - no navigation
'scroll', // Viewport manipulation - no navigation
'drag', // Drag and drop - local operation
'wait', // Condition waiting - no side effects
'delay', // Simple delay - no side effects
'screenshot', // Capture only - no side effects
'assert', // Validation only - no side effects
]);
/**
* Step types that are fully migrated and tested with ActionRegistry
* These are safe to run in actions mode
*
* NOTE: Start conservative and expand gradually as testing confirms equivalence.
* Types NOT included here will fall back to legacy in hybrid mode.
*
* Criteria for inclusion:
* 1. Handler implementation matches legacy behavior exactly
* 2. Step data structure is compatible (no complex transformation needed)
* 3. No timing-sensitive dependencies (like script when:'after' defer)
*/
export const MIGRATED_ACTION_TYPES = new Set<string>([
// Navigation - well tested, simple mapping
'navigate',
// Interaction - well tested, core functionality
'click',
'dblclick',
'fill',
'key',
'scroll',
'drag',
// Timing - simple logic, no complex state
'wait',
'delay',
// Screenshot - simple, no side effects
'screenshot',
// Assert - validation only, no state changes
'assert',
]);
/**
* Step types that need more validation before migration
* These are supported by ActionRegistry but may have behavior differences
*/
export const NEEDS_VALIDATION_TYPES = new Set<string>([
// Data extraction - need to verify selector/js mode equivalence
'extract',
// HTTP - body type handling may differ
'http',
// Script - when:'after' defer semantics differ from legacy
'script',
// Tabs - tabId tracking needs careful integration
'openTab',
'switchTab',
'closeTab',
'handleDownload',
// Control flow - condition evaluation may differ
'if',
'foreach',
'while',
'switchFrame',
]);
/**
* Step types that must use legacy execution
* These have complex integration requirements not yet supported by ActionRegistry
*/
export const LEGACY_ONLY_TYPES = new Set<string>([
// Complex legacy types not yet migrated
'triggerEvent',
'setAttribute',
'loopElements',
'executeFlow',
]);
/**
* Determine whether a step should use actions execution based on config
*/
export function shouldUseActions(step: Step, config: ExecutionModeConfig): boolean {
if (config.mode === 'legacy') {
return false;
}
if (config.mode === 'actions') {
return true;
}
// Hybrid mode: check allowlist/denylist
const stepType = step.type;
// Denylist takes precedence
if (config.legacyOnlyTypes?.has(stepType)) {
return false;
}
// If allowlist is specified and non-empty, step must be in it
if (config.actionsAllowlist && config.actionsAllowlist.size > 0) {
return config.actionsAllowlist.has(stepType);
}
// Default to using actions for supported types
return MIGRATED_ACTION_TYPES.has(stepType);
}
/**
* Create a hybrid execution mode config for gradual migration.
*
* By default uses MINIMAL_HYBRID_ACTION_TYPES as allowlist, which excludes
* high-risk types (navigate/click/tab management) from actions execution.
*
* @param overrides - Optional overrides for the config
* @param overrides.actionsAllowlist - Set of step types to execute via actions.
* If provided with size > 0, only these types use actions.
* If empty Set, falls back to MIGRATED_ACTION_TYPES.
* If undefined, uses MINIMAL_HYBRID_ACTION_TYPES (safest default).
*/
export function createHybridConfig(overrides?: Partial<ExecutionModeConfig>): ExecutionModeConfig {
return {
...DEFAULT_EXECUTION_MODE_CONFIG,
mode: 'hybrid',
legacyOnlyTypes: new Set(LEGACY_ONLY_TYPES),
actionsAllowlist: new Set(MINIMAL_HYBRID_ACTION_TYPES),
...overrides,
};
}
/**
* Create a strict actions mode config for testing.
* All steps must be handled by ActionRegistry or throw.
*
* Note: Even in actions mode, StepRunner remains the policy authority for
* retry/nav-wait. This ensures consistent behavior across all execution modes
* and avoids double-strategy issues.
*/
export function createActionsOnlyConfig(
overrides?: Partial<ExecutionModeConfig>,
): ExecutionModeConfig {
return {
...DEFAULT_EXECUTION_MODE_CONFIG,
mode: 'actions',
// Keep StepRunner as policy authority - skip ActionRegistry's internal policies
skipActionsRetry: true,
skipActionsNavWait: true,
...overrides,
};
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/tests/web-editor-v2/event-controller.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Unit tests for Web Editor V2 Event Controller.
*
* These tests focus on the selecting mode behavior:
* - Clicking within selection subtree prepares drag candidate
* - Clicking outside selection triggers reselection (Bug 1 fix)
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createEventController,
type EventController,
type EventControllerOptions,
type Modifiers,
} from '@/entrypoints/web-editor-v2/core/event-controller';
import type { RestoreFn } from './test-utils/dom';
import { mockBoundingClientRect } from './test-utils/dom';
// =============================================================================
// Test Utilities
// =============================================================================
const NO_MODIFIERS: Modifiers = { alt: false, shift: false, ctrl: false, meta: false };
/**
* Check if an element is part of the editor overlay.
*/
function isOverlayElement(node: unknown): boolean {
return node instanceof Element && node.getAttribute('data-overlay') === 'true';
}
/**
* Create a minimal mock PointerEvent for testing.
* jsdom doesn't support PointerEvent, so we create a MouseEvent and extend it.
*/
function createPointerEvent(
type: string,
options: {
clientX?: number;
clientY?: number;
button?: number;
pointerId?: number;
target?: EventTarget | null;
} = {},
): MouseEvent & { pointerId: number } {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientX: options.clientX ?? 0,
clientY: options.clientY ?? 0,
button: options.button ?? 0,
});
// Add pointerId property (jsdom doesn't have PointerEvent)
Object.defineProperty(event, 'pointerId', {
value: options.pointerId ?? 1,
writable: false,
});
// Mock composedPath to return target path
if (options.target) {
vi.spyOn(event, 'composedPath').mockReturnValue([options.target as EventTarget]);
}
return event as MouseEvent & { pointerId: number };
}
// =============================================================================
// Test Setup
// =============================================================================
let restores: RestoreFn[] = [];
let controller: EventController | null = null;
beforeEach(() => {
restores = [];
document.body.innerHTML = '';
});
afterEach(() => {
controller?.dispose();
controller = null;
for (let i = restores.length - 1; i >= 0; i--) {
restores[i]!();
}
restores = [];
vi.restoreAllMocks();
});
// =============================================================================
// Selecting Mode Tests (Bug 1 Fix)
// =============================================================================
describe('event-controller: selecting mode click behavior', () => {
it('clicking within selection subtree prepares drag candidate (does not trigger onSelect)', () => {
// Setup DOM
const selected = document.createElement('div');
selected.id = 'selected';
const child = document.createElement('span');
child.id = 'child';
selected.appendChild(child);
document.body.appendChild(selected);
// Mock rect for selected element
restores.push(mockBoundingClientRect(selected, { left: 0, top: 0, width: 100, height: 100 }));
restores.push(mockBoundingClientRect(child, { left: 10, top: 10, width: 50, height: 50 }));
// Setup callbacks
const onSelect = vi.fn();
const onStartDrag = vi.fn().mockReturnValue(true);
const options: EventControllerOptions = {
isOverlayElement,
isEditorUiElement: () => false,
getSelectedElement: () => selected,
getEditingElement: () => null,
findTargetForSelect: () => child,
onHover: vi.fn(),
onSelect,
onDeselect: vi.fn(),
onStartDrag,
};
controller = createEventController(options);
controller.setMode('selecting');
// Simulate pointerdown within selected element
const event = createPointerEvent('pointerdown', {
clientX: 20,
clientY: 20,
target: child,
});
document.dispatchEvent(event);
// onSelect should NOT be called (we're preparing drag instead)
expect(onSelect).not.toHaveBeenCalled();
});
it('clicking outside selection triggers reselection (Bug 1 fix)', () => {
// Setup DOM
const selected = document.createElement('div');
selected.id = 'selected';
document.body.appendChild(selected);
const other = document.createElement('div');
other.id = 'other';
document.body.appendChild(other);
// Mock rects
restores.push(mockBoundingClientRect(selected, { left: 0, top: 0, width: 100, height: 100 }));
restores.push(mockBoundingClientRect(other, { left: 200, top: 0, width: 100, height: 100 }));
// Setup callbacks
const onSelect = vi.fn();
const onStartDrag = vi.fn().mockReturnValue(true);
const options: EventControllerOptions = {
isOverlayElement,
isEditorUiElement: () => false,
getSelectedElement: () => selected,
getEditingElement: () => null,
findTargetForSelect: () => other, // Returns the "other" element as target
onHover: vi.fn(),
onSelect,
onDeselect: vi.fn(),
onStartDrag,
};
controller = createEventController(options);
controller.setMode('selecting');
// Simulate mousedown outside selected element (on "other")
// Use mousedown since jsdom doesn't support PointerEvent
const event = new MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
clientX: 250, // Outside selected (0-100), inside other (200-300)
clientY: 50,
button: 0,
});
// Mock composedPath to return a path that does NOT include "selected"
// This simulates clicking outside the selection
vi.spyOn(event, 'composedPath').mockReturnValue([other, document.body, document]);
document.dispatchEvent(event);
// onSelect SHOULD be called with the new element
expect(onSelect).toHaveBeenCalledWith(other, expect.any(Object));
});
it('clicking outside with no valid target does not trigger onSelect', () => {
// Setup DOM
const selected = document.createElement('div');
selected.id = 'selected';
document.body.appendChild(selected);
// Mock rect
restores.push(mockBoundingClientRect(selected, { left: 0, top: 0, width: 100, height: 100 }));
// Setup callbacks
const onSelect = vi.fn();
const options: EventControllerOptions = {
isOverlayElement,
isEditorUiElement: () => false,
getSelectedElement: () => selected,
getEditingElement: () => null,
findTargetForSelect: () => null, // No valid target found
onHover: vi.fn(),
onSelect,
onDeselect: vi.fn(),
onStartDrag: vi.fn(),
};
controller = createEventController(options);
controller.setMode('selecting');
// Simulate pointerdown outside selected element
const event = createPointerEvent('pointerdown', {
clientX: 500,
clientY: 500,
target: document.body,
});
document.dispatchEvent(event);
// onSelect should NOT be called (no valid target)
expect(onSelect).not.toHaveBeenCalled();
});
});
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyTrigger.vue:
--------------------------------------------------------------------------------
```vue
<template>
<div class="form-section">
<div class="form-group checkbox-group">
<label class="checkbox-label"
><input type="checkbox" v-model="cfg.enabled" /> 启用触发器</label
>
</div>
<div class="form-group">
<label class="form-label">描述(可选)</label>
<input class="form-input" v-model="cfg.description" placeholder="说明此触发器的用途" />
</div>
</div>
<div class="divider"></div>
<div class="form-section">
<div class="section-header"><span class="section-title">触发方式</span></div>
<div class="form-group checkbox-group">
<label class="checkbox-label"
><input type="checkbox" v-model="cfg.modes.manual" /> 手动</label
>
<label class="checkbox-label"
><input type="checkbox" v-model="cfg.modes.url" /> 访问 URL</label
>
<label class="checkbox-label"
><input type="checkbox" v-model="cfg.modes.contextMenu" /> 右键菜单</label
>
<label class="checkbox-label"
><input type="checkbox" v-model="cfg.modes.command" /> 快捷键</label
>
<label class="checkbox-label"
><input type="checkbox" v-model="cfg.modes.dom" /> DOM 变化</label
>
<label class="checkbox-label"
><input type="checkbox" v-model="cfg.modes.schedule" /> 定时</label
>
</div>
</div>
<div v-if="cfg.modes.url" class="form-section">
<div class="section-title">访问 URL 匹配</div>
<div class="selector-list">
<div v-for="(r, i) in urlRules" :key="i" class="selector-item">
<select class="form-select-sm" v-model="r.kind">
<option value="url">前缀 URL</option>
<option value="domain">域名包含</option>
<option value="path">路径前缀</option>
</select>
<input
class="form-input-sm flex-1"
v-model="r.value"
placeholder="例如 https://example.com/app"
/>
<button class="btn-icon-sm" @click="move(urlRules, i, -1)" :disabled="i === 0">↑</button>
<button
class="btn-icon-sm"
@click="move(urlRules, i, 1)"
:disabled="i === urlRules.length - 1"
>↓</button
>
<button class="btn-icon-sm danger" @click="urlRules.splice(i, 1)">×</button>
</div>
</div>
<button class="btn-sm" @click="urlRules.push({ kind: 'url', value: '' })">+ 添加匹配</button>
</div>
<div v-if="cfg.modes.contextMenu" class="form-section">
<div class="section-title">右键菜单</div>
<div class="form-group">
<label class="form-label">标题</label>
<input class="form-input" v-model="cfg.contextMenu.title" placeholder="菜单标题" />
</div>
<div class="form-group">
<label class="form-label">作用范围</label>
<div class="checkbox-group">
<label class="checkbox-label" v-for="c in menuContexts" :key="c">
<input type="checkbox" :value="c" v-model="cfg.contextMenu.contexts" /> {{ c }}
</label>
</div>
</div>
</div>
<div v-if="cfg.modes.command" class="form-section">
<div class="section-title">快捷键</div>
<div class="form-group">
<label class="form-label">命令键(需预先在 manifest commands 中声明)</label>
<input
class="form-input"
v-model="cfg.command.commandKey"
placeholder="例如 run_quick_trigger_1"
/>
</div>
<div class="text-xs text-slate-500" style="padding: 0 20px"
>提示:Chrome 扩展快捷键需要在 manifest 里固定声明,无法运行时动态添加。</div
>
</div>
<div v-if="cfg.modes.dom" class="form-section">
<div class="section-title">DOM 变化</div>
<div class="form-group">
<label class="form-label">选择器</label>
<input class="form-input" v-model="cfg.dom.selector" placeholder="#app .item" />
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label"
><input type="checkbox" v-model="cfg.dom.appear" /> 出现时触发</label
>
<label class="checkbox-label"
><input type="checkbox" v-model="cfg.dom.once" /> 仅触发一次</label
>
</div>
<div class="form-group">
<label class="form-label">去抖(ms)</label>
<input class="form-input" type="number" min="0" v-model.number="cfg.dom.debounceMs" />
</div>
</div>
<div v-if="cfg.modes.schedule" class="form-section">
<div class="section-title">定时</div>
<div class="selector-list">
<div v-for="(s, i) in schedules" :key="i" class="selector-item">
<select class="form-select-sm" v-model="s.type">
<option value="interval">间隔(分钟)</option>
<option value="daily">每天(HH:mm)</option>
<option value="once">一次(ISO时间)</option>
</select>
<input
class="form-input-sm flex-1"
v-model="s.when"
placeholder="5 或 09:00 或 2025-01-01T10:00:00"
/>
<label class="checkbox-label"><input type="checkbox" v-model="s.enabled" /> 启用</label>
<button class="btn-icon-sm" @click="move(schedules, i, -1)" :disabled="i === 0">↑</button>
<button
class="btn-icon-sm"
@click="move(schedules, i, 1)"
:disabled="i === schedules.length - 1"
>↓</button
>
<button class="btn-icon-sm danger" @click="schedules.splice(i, 1)">×</button>
</div>
</div>
<button class="btn-sm" @click="schedules.push({ type: 'interval', when: '5', enabled: true })"
>+ 添加定时</button
>
</div>
<div class="divider"></div>
<div class="form-section">
<div class="text-xs text-slate-500" style="padding: 0 20px"
>说明:
触发器会在保存工作流时同步到后台触发表(URL/右键/快捷键/DOM)和计划任务(间隔/每天/一次)。
</div>
</div>
</template>
<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
import { computed } from 'vue';
import type { NodeBase } from '@/entrypoints/background/record-replay/types';
const props = defineProps<{ node: NodeBase }>();
function ensure() {
const n: any = props.node;
if (!n.config) n.config = {};
if (!n.config.modes)
n.config.modes = {
manual: true,
url: false,
contextMenu: false,
command: false,
dom: false,
schedule: false,
};
if (!n.config.url) n.config.url = { rules: [] };
if (!n.config.contextMenu)
n.config.contextMenu = { title: '运行工作流', contexts: ['all'], enabled: false };
if (!n.config.command) n.config.command = { commandKey: '', enabled: false };
if (!n.config.dom)
n.config.dom = { selector: '', appear: true, once: true, debounceMs: 800, enabled: false };
if (!Array.isArray(n.config.schedules)) n.config.schedules = [];
}
const cfg = computed<any>({
get() {
ensure();
return (props.node as any).config;
},
set(v) {
(props.node as any).config = v;
},
});
const urlRules = computed({
get() {
ensure();
return (props.node as any).config.url.rules as Array<any>;
},
set(v) {
(props.node as any).config.url.rules = v;
},
});
const schedules = computed({
get() {
ensure();
return (props.node as any).config.schedules as Array<any>;
},
set(v) {
(props.node as any).config.schedules = v;
},
});
const menuContexts = ['all', 'page', 'selection', 'image', 'link', 'video', 'audio'];
function move(arr: any[], i: number, d: number) {
const j = i + d;
if (j < 0 || j >= arr.length) return;
const t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
</script>
<style scoped></style>
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/history.ts:
--------------------------------------------------------------------------------
```typescript
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import {
parseISO,
subDays,
subWeeks,
subMonths,
subYears,
startOfToday,
startOfYesterday,
isValid,
format,
} from 'date-fns';
interface HistoryToolParams {
text?: string;
startTime?: string;
endTime?: string;
maxResults?: number;
excludeCurrentTabs?: boolean;
}
interface HistoryItem {
id: string;
url?: string;
title?: string;
lastVisitTime?: number; // Timestamp in milliseconds
visitCount?: number;
typedCount?: number;
}
interface HistoryResult {
items: HistoryItem[];
totalCount: number;
timeRange: {
startTime: number;
endTime: number;
startTimeFormatted: string;
endTimeFormatted: string;
};
query?: string;
}
class HistoryTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.HISTORY;
private static readonly ONE_DAY_MS = 24 * 60 * 60 * 1000;
/**
* Parse a date string into milliseconds since epoch.
* Returns null if the date string is invalid.
* Supports:
* - ISO date strings (e.g., "2023-10-31", "2023-10-31T14:30:00.000Z")
* - Relative times: "1 day ago", "2 weeks ago", "3 months ago", "1 year ago"
* - Special keywords: "now", "today", "yesterday"
*/
private parseDateString(dateStr: string | undefined | null): number | null {
if (!dateStr) {
// If an empty or null string is passed, it might mean "no specific date",
// depending on how you want to treat it. Returning null is safer.
return null;
}
const now = new Date();
const lowerDateStr = dateStr.toLowerCase().trim();
if (lowerDateStr === 'now') return now.getTime();
if (lowerDateStr === 'today') return startOfToday().getTime();
if (lowerDateStr === 'yesterday') return startOfYesterday().getTime();
const relativeMatch = lowerDateStr.match(
/^(\d+)\s+(day|days|week|weeks|month|months|year|years)\s+ago$/,
);
if (relativeMatch) {
const amount = parseInt(relativeMatch[1], 10);
const unit = relativeMatch[2];
let resultDate: Date;
if (unit.startsWith('day')) resultDate = subDays(now, amount);
else if (unit.startsWith('week')) resultDate = subWeeks(now, amount);
else if (unit.startsWith('month')) resultDate = subMonths(now, amount);
else if (unit.startsWith('year')) resultDate = subYears(now, amount);
else return null; // Should not happen with the regex
return resultDate.getTime();
}
// Try parsing as ISO or other common date string formats
// Native Date constructor can be unreliable for non-standard formats.
// date-fns' parseISO is good for ISO 8601.
// For other formats, date-fns' parse function is more flexible.
let parsedDate = parseISO(dateStr); // Handles "2023-10-31" or "2023-10-31T10:00:00"
if (isValid(parsedDate)) {
return parsedDate.getTime();
}
// Fallback to new Date() for other potential formats, but with caution
parsedDate = new Date(dateStr);
if (isValid(parsedDate) && dateStr.includes(parsedDate.getFullYear().toString())) {
return parsedDate.getTime();
}
console.warn(`Could not parse date string: ${dateStr}`);
return null;
}
/**
* Format a timestamp as a human-readable date string
*/
private formatDate(timestamp: number): string {
// Using date-fns for consistent and potentially localized formatting
return format(timestamp, 'yyyy-MM-dd HH:mm:ss');
}
async execute(args: HistoryToolParams): Promise<ToolResult> {
try {
console.log('Executing HistoryTool with args:', args);
const {
text = '',
maxResults = 100, // Default to 100 results
excludeCurrentTabs = false,
} = args;
const now = Date.now();
let startTimeMs: number;
let endTimeMs: number;
// Parse startTime
if (args.startTime) {
const parsedStart = this.parseDateString(args.startTime);
if (parsedStart === null) {
return createErrorResponse(
`Invalid format for start time: "${args.startTime}". Supported formats: ISO (YYYY-MM-DD), "today", "yesterday", "X days/weeks/months/years ago".`,
);
}
startTimeMs = parsedStart;
} else {
// Default to 24 hours ago if startTime is not provided
startTimeMs = now - HistoryTool.ONE_DAY_MS;
}
// Parse endTime
if (args.endTime) {
const parsedEnd = this.parseDateString(args.endTime);
if (parsedEnd === null) {
return createErrorResponse(
`Invalid format for end time: "${args.endTime}". Supported formats: ISO (YYYY-MM-DD), "today", "yesterday", "X days/weeks/months/years ago".`,
);
}
endTimeMs = parsedEnd;
} else {
// Default to current time if endTime is not provided
endTimeMs = now;
}
// Validate time range
if (startTimeMs > endTimeMs) {
return createErrorResponse('Start time cannot be after end time.');
}
console.log(
`Searching history from ${this.formatDate(startTimeMs)} to ${this.formatDate(endTimeMs)} for query "${text}"`,
);
const historyItems = await chrome.history.search({
text,
startTime: startTimeMs,
endTime: endTimeMs,
maxResults,
});
console.log(`Found ${historyItems.length} history items before filtering current tabs.`);
let filteredItems = historyItems;
if (excludeCurrentTabs && historyItems.length > 0) {
const currentTabs = await chrome.tabs.query({});
const openUrls = new Set<string>();
currentTabs.forEach((tab) => {
if (tab.url) {
openUrls.add(tab.url);
}
});
if (openUrls.size > 0) {
filteredItems = historyItems.filter((item) => !(item.url && openUrls.has(item.url)));
console.log(
`Filtered out ${historyItems.length - filteredItems.length} items that are currently open. ${filteredItems.length} items remaining.`,
);
}
}
const result: HistoryResult = {
items: filteredItems.map((item) => ({
id: item.id,
url: item.url,
title: item.title,
lastVisitTime: item.lastVisitTime,
visitCount: item.visitCount,
typedCount: item.typedCount,
})),
totalCount: filteredItems.length,
timeRange: {
startTime: startTimeMs,
endTime: endTimeMs,
startTimeFormatted: this.formatDate(startTimeMs),
endTimeFormatted: this.formatDate(endTimeMs),
},
};
if (text) {
result.query = text;
}
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
isError: false,
};
} catch (error) {
console.error('Error in HistoryTool.execute:', error);
return createErrorResponse(
`Error retrieving browsing history: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export const historyTool = new HistoryTool();
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/composables/useAgentChatViewRoute.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Composable for managing AgentChat view routing.
*
* Handles navigation between 'sessions' (list) and 'chat' (conversation) views
* without requiring vue-router. Supports URL parameters for deep linking.
*
* URL Parameters:
* - `view`: 'sessions' | 'chat' (default: 'sessions')
* - `sessionId`: Session ID to open directly in chat view
*
* Example URLs:
* - `sidepanel.html?tab=agent-chat` → sessions list
* - `sidepanel.html?tab=agent-chat&view=chat&sessionId=xxx` → direct to chat
*/
import { ref, computed } from 'vue';
// =============================================================================
// Types
// =============================================================================
/** Available view modes */
export type AgentChatView = 'sessions' | 'chat';
/** Route state */
export interface AgentChatRouteState {
view: AgentChatView;
sessionId: string | null;
}
/** Options for useAgentChatViewRoute */
export interface UseAgentChatViewRouteOptions {
/**
* Callback when route changes.
* Called after internal state is updated.
*/
onRouteChange?: (state: AgentChatRouteState) => void;
}
// =============================================================================
// Constants
// =============================================================================
const DEFAULT_VIEW: AgentChatView = 'sessions';
const URL_PARAM_VIEW = 'view';
const URL_PARAM_SESSION_ID = 'sessionId';
// =============================================================================
// Helpers
// =============================================================================
/**
* Parse view from URL parameter.
* Returns default if invalid.
*/
function parseView(value: string | null): AgentChatView {
if (value === 'sessions' || value === 'chat') {
return value;
}
return DEFAULT_VIEW;
}
/**
* Update URL parameters without page reload.
* Preserves existing parameters (like `tab`).
*/
function updateUrlParams(view: AgentChatView, sessionId: string | null): void {
try {
const url = new URL(window.location.href);
// Update view param
if (view === DEFAULT_VIEW) {
url.searchParams.delete(URL_PARAM_VIEW);
} else {
url.searchParams.set(URL_PARAM_VIEW, view);
}
// Update sessionId param
if (sessionId) {
url.searchParams.set(URL_PARAM_SESSION_ID, sessionId);
} else {
url.searchParams.delete(URL_PARAM_SESSION_ID);
}
// Update URL without reload
window.history.replaceState({}, '', url.toString());
} catch {
// Ignore URL update errors (e.g., in non-browser environment)
}
}
// =============================================================================
// Composable
// =============================================================================
export function useAgentChatViewRoute(options: UseAgentChatViewRouteOptions = {}) {
// ==========================================================================
// State
// ==========================================================================
const currentView = ref<AgentChatView>(DEFAULT_VIEW);
const currentSessionId = ref<string | null>(null);
// ==========================================================================
// Computed
// ==========================================================================
/** Whether currently showing sessions list */
const isSessionsView = computed(() => currentView.value === 'sessions');
/** Whether currently showing chat conversation */
const isChatView = computed(() => currentView.value === 'chat');
/** Current route state */
const routeState = computed<AgentChatRouteState>(() => ({
view: currentView.value,
sessionId: currentSessionId.value,
}));
// ==========================================================================
// Actions
// ==========================================================================
/**
* Navigate to sessions list view.
* Clears sessionId from URL.
*/
function goToSessions(): void {
currentView.value = 'sessions';
// Don't clear sessionId internally - it's used to highlight selected session
updateUrlParams('sessions', null);
options.onRouteChange?.(routeState.value);
}
/**
* Navigate to chat view for a specific session.
* @param sessionId - Session ID to open
*/
function goToChat(sessionId: string): void {
if (!sessionId) {
console.warn('[useAgentChatViewRoute] goToChat called without sessionId');
return;
}
currentView.value = 'chat';
currentSessionId.value = sessionId;
updateUrlParams('chat', sessionId);
options.onRouteChange?.(routeState.value);
}
/**
* Initialize route from URL parameters.
* Should be called on mount.
* @returns Initial route state
*/
function initFromUrl(): AgentChatRouteState {
try {
const params = new URLSearchParams(window.location.search);
const viewParam = params.get(URL_PARAM_VIEW);
const sessionIdParam = params.get(URL_PARAM_SESSION_ID);
const view = parseView(viewParam);
const sessionId = sessionIdParam?.trim() || null;
// If view=chat but no sessionId, fall back to sessions
if (view === 'chat' && !sessionId) {
currentView.value = 'sessions';
currentSessionId.value = null;
} else {
currentView.value = view;
currentSessionId.value = sessionId;
}
} catch {
// Use defaults on error
currentView.value = DEFAULT_VIEW;
currentSessionId.value = null;
}
return routeState.value;
}
/**
* Update session ID without changing view.
* Updates URL based on current view and sessionId:
* - In chat view: always update URL with sessionId
* - In sessions view with null sessionId: clear sessionId from URL (cleanup)
*/
function setSessionId(sessionId: string | null): void {
currentSessionId.value = sessionId;
if (currentView.value === 'chat') {
// In chat view, always sync URL with current sessionId
updateUrlParams('chat', sessionId);
} else if (sessionId === null) {
// In sessions view, clear any stale sessionId from URL
// This handles edge cases like deleting the last session
updateUrlParams('sessions', null);
}
}
// ==========================================================================
// Lifecycle
// ==========================================================================
// Note: We don't call initFromUrl() here because AgentChat.vue needs to
// call it after loading sessions (to verify sessionId exists).
// Caller is responsible for calling initFromUrl() at the right time.
// ==========================================================================
// Return
// ==========================================================================
return {
// State
currentView,
currentSessionId,
// Computed
isSessionsView,
isChatView,
routeState,
// Actions
goToSessions,
goToChat,
initFromUrl,
setSessionId,
};
}
// =============================================================================
// Type Export
// =============================================================================
export type UseAgentChatViewRoute = ReturnType<typeof useAgentChatViewRoute>;
```
--------------------------------------------------------------------------------
/app/native-server/src/agent/stream-manager.ts:
--------------------------------------------------------------------------------
```typescript
import type { ServerResponse } from 'node:http';
import type { RealtimeEvent } from './types';
type WebSocketLike = {
readyState?: number;
send(data: string): void;
close?: () => void;
};
const WEBSOCKET_OPEN_STATE = 1;
/**
* AgentStreamManager manages SSE/WebSocket connections keyed by sessionId.
*
* 中文说明:此实现参考 other/cweb 中的 StreamManager,但适配 Fastify/Node HTTP,
* 使用 ServerResponse 直接写入 SSE 数据,避免在 Node 环境中额外引入 Web Streams 依赖。
*/
export class AgentStreamManager {
private readonly sseClients = new Map<string, Set<ServerResponse>>();
private readonly webSocketClients = new Map<string, Set<WebSocketLike>>();
private heartbeatTimer: NodeJS.Timeout | null = null;
addSseStream(sessionId: string, res: ServerResponse): void {
if (!this.sseClients.has(sessionId)) {
this.sseClients.set(sessionId, new Set());
}
this.sseClients.get(sessionId)!.add(res);
this.ensureHeartbeatTimer();
}
removeSseStream(sessionId: string, res: ServerResponse): void {
const clients = this.sseClients.get(sessionId);
if (!clients) {
return;
}
clients.delete(res);
if (clients.size === 0) {
this.sseClients.delete(sessionId);
}
this.stopHeartbeatTimerIfIdle();
}
addWebSocket(sessionId: string, socket: WebSocketLike): void {
if (!this.webSocketClients.has(sessionId)) {
this.webSocketClients.set(sessionId, new Set());
}
this.webSocketClients.get(sessionId)!.add(socket);
this.ensureHeartbeatTimer();
}
removeWebSocket(sessionId: string, socket: WebSocketLike): void {
const sockets = this.webSocketClients.get(sessionId);
if (!sockets) {
return;
}
sockets.delete(socket);
if (sockets.size === 0) {
this.webSocketClients.delete(sessionId);
}
this.stopHeartbeatTimerIfIdle();
}
publish(event: RealtimeEvent): void {
const payload = JSON.stringify(event);
const ssePayload = `data: ${payload}\n\n`;
// Heartbeat events are broadcast to all connections to keep them alive.
if (event.type === 'heartbeat') {
this.broadcastToAll(ssePayload, payload);
return;
}
// For all other event types, require a sessionId for routing.
const targetSessionId = this.extractSessionId(event);
if (!targetSessionId) {
// Drop events without sessionId to prevent cross-session leakage.
console.warn('[AgentStreamManager] Dropping event without sessionId:', event.type);
return;
}
// Session-scoped routing: only send to clients subscribed to this session.
this.sendToSession(targetSessionId, ssePayload, payload);
}
/**
* Extract sessionId from event based on event type.
*/
private extractSessionId(event: RealtimeEvent): string | undefined {
switch (event.type) {
case 'message':
return event.data?.sessionId;
case 'status':
return event.data?.sessionId;
case 'connected':
return event.data?.sessionId;
case 'error':
return event.data?.sessionId;
case 'usage':
return event.data?.sessionId;
case 'heartbeat':
return undefined;
default:
return undefined;
}
}
/**
* Send event to a specific session's clients only.
*/
private sendToSession(sessionId: string, ssePayload: string, wsPayload: string): void {
// SSE clients
const sseClients = this.sseClients.get(sessionId);
if (sseClients) {
const deadClients: ServerResponse[] = [];
for (const res of sseClients) {
if (this.isResponseDead(res)) {
deadClients.push(res);
continue;
}
try {
res.write(ssePayload);
} catch {
deadClients.push(res);
}
}
for (const res of deadClients) {
this.removeSseStream(sessionId, res);
}
}
// WebSocket clients
const wsSockets = this.webSocketClients.get(sessionId);
if (wsSockets) {
const deadSockets: WebSocketLike[] = [];
for (const socket of wsSockets) {
if (this.isSocketDead(socket)) {
deadSockets.push(socket);
continue;
}
try {
socket.send(wsPayload);
} catch {
deadSockets.push(socket);
}
}
for (const socket of deadSockets) {
this.removeWebSocket(sessionId, socket);
}
}
}
/**
* Broadcast event to all connected clients (used for heartbeat).
*/
private broadcastToAll(ssePayload: string, wsPayload: string): void {
const deadSse: Array<{ sessionId: string; res: ServerResponse }> = [];
for (const [sessionId, clients] of this.sseClients.entries()) {
for (const res of clients) {
if (this.isResponseDead(res)) {
deadSse.push({ sessionId, res });
continue;
}
try {
res.write(ssePayload);
} catch {
deadSse.push({ sessionId, res });
}
}
}
for (const { sessionId, res } of deadSse) {
this.removeSseStream(sessionId, res);
}
const deadSockets: Array<{ sessionId: string; socket: WebSocketLike }> = [];
for (const [sessionId, sockets] of this.webSocketClients.entries()) {
for (const socket of sockets) {
if (this.isSocketDead(socket)) {
deadSockets.push({ sessionId, socket });
continue;
}
try {
socket.send(wsPayload);
} catch {
deadSockets.push({ sessionId, socket });
}
}
}
for (const { sessionId, socket } of deadSockets) {
this.removeWebSocket(sessionId, socket);
}
}
private isResponseDead(res: ServerResponse): boolean {
return (res as any).writableEnded || (res as any).destroyed;
}
private isSocketDead(socket: WebSocketLike): boolean {
return socket.readyState !== undefined && socket.readyState !== WEBSOCKET_OPEN_STATE;
}
closeAll(): void {
for (const [sessionId, clients] of this.sseClients.entries()) {
for (const res of clients) {
try {
res.end();
} catch {
// Ignore errors during shutdown.
}
}
this.sseClients.delete(sessionId);
}
for (const [sessionId, sockets] of this.webSocketClients.entries()) {
for (const socket of sockets) {
try {
socket.close?.();
} catch {
// Ignore errors during shutdown.
}
}
this.webSocketClients.delete(sessionId);
}
this.stopHeartbeatTimer();
}
private ensureHeartbeatTimer(): void {
if (this.heartbeatTimer) {
return;
}
this.heartbeatTimer = setInterval(() => {
if (this.sseClients.size === 0 && this.webSocketClients.size === 0) {
this.stopHeartbeatTimer();
return;
}
const event: RealtimeEvent = {
type: 'heartbeat',
data: { timestamp: new Date().toISOString() },
};
this.publish(event);
}, 30_000);
// Allow Node process to exit naturally even if heartbeat is active.
this.heartbeatTimer.unref?.();
}
private stopHeartbeatTimerIfIdle(): void {
if (this.sseClients.size === 0 && this.webSocketClients.size === 0) {
this.stopHeartbeatTimer();
}
}
private stopHeartbeatTimer(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/css-helpers.ts:
--------------------------------------------------------------------------------
```typescript
/**
* CSS Value Helpers
*
* Shared utilities for parsing and normalizing CSS values.
* Used by control components for input-container suffix management.
*/
// =============================================================================
// Constants
// =============================================================================
/** CSS keywords that should not display a unit suffix */
const LENGTH_KEYWORDS = new Set([
'auto',
'inherit',
'initial',
'unset',
'none',
'fit-content',
'min-content',
'max-content',
'revert',
'revert-layer',
]);
/** Regex to detect CSS function expressions */
const LENGTH_FUNCTION_REGEX = /\b(?:calc|var|clamp|min|max|fit-content)\s*\(/i;
/** Regex to match number with unit (e.g., "20px", "50%") */
const NUMBER_WITH_UNIT_REGEX = /^(-?(?:\d+|\d*\.\d+|\.\d+))\s*([a-zA-Z%]+)$/;
/** Regex to match pure numbers */
const PURE_NUMBER_REGEX = /^-?(?:\d+|\d*\.\d+|\.\d+)$/;
/** Regex to match numbers with trailing dot (e.g., "10.") */
const TRAILING_DOT_NUMBER_REGEX = /^-?\d+\.$/;
// =============================================================================
// Types
// =============================================================================
/** Result of formatting a length value for display */
export interface FormattedLength {
/** The numeric or keyword value to display in the input */
value: string;
/** The unit suffix to display, or null if no suffix should be shown */
suffix: string | null;
}
// =============================================================================
// Functions
// =============================================================================
/**
* Extract CSS unit suffix from a length value.
* Supports px, %, rem, em, vh, vw, etc.
* Falls back to 'px' for pure numbers or unknown patterns.
*
* @example
* extractUnitSuffix('100px') // 'px'
* extractUnitSuffix('50%') // '%'
* extractUnitSuffix('2rem') // 'rem'
* extractUnitSuffix('100') // 'px' (default)
* extractUnitSuffix('auto') // 'px' (fallback)
*/
export function extractUnitSuffix(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return 'px';
// Handle shorthand values by taking first token
const token = trimmed.split(/\s+/)[0] ?? '';
// Match number + unit (including %)
const match = token.match(/^-?(?:\d+|\d*\.\d+)([a-zA-Z%]+)$/);
if (match) return match[1]!;
// Pure number: default to px
if (/^-?(?:\d+|\d*\.\d+)$/.test(token)) return 'px';
if (/^-?\d+\.$/.test(token)) return 'px';
return 'px';
}
/**
* Check if a value has an explicit CSS unit.
* Returns false for unitless numbers (e.g., "1.5" for line-height).
*
* @example
* hasExplicitUnit('100px') // true
* hasExplicitUnit('1.5') // false
* hasExplicitUnit('auto') // false
*/
export function hasExplicitUnit(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
const token = trimmed.split(/\s+/)[0] ?? '';
return /^-?(?:\d+|\d*\.\d+)([a-zA-Z%]+)$/.test(token);
}
/**
* Normalize a length value.
* - Pure numbers (e.g., "100", "10.5") get "px" suffix
* - Values with units or keywords pass through unchanged
* - Empty string clears the inline style
*
* @example
* normalizeLength('100') // '100px'
* normalizeLength('10.5') // '10.5px'
* normalizeLength('50%') // '50%'
* normalizeLength('auto') // 'auto'
* normalizeLength('') // ''
*/
export function normalizeLength(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '';
// Pure number patterns: "10", "-10", "10.5", ".5", "-.5"
if (/^-?(?:\d+|\d*\.\d+)$/.test(trimmed)) {
return `${trimmed}px`;
}
// Trailing dot (e.g., "10.") -> treat as integer px
if (/^-?\d+\.$/.test(trimmed)) {
return `${trimmed.slice(0, -1)}px`;
}
// Keep units/keywords/expressions as-is
return trimmed;
}
/**
* Format a CSS length value for display in an input + suffix UI.
*
* Separates the numeric value from its unit to avoid duplication
* (e.g., displaying "20px" in input and "px" as suffix).
*
* @example
* formatLengthForDisplay('20px') // { value: '20', suffix: 'px' }
* formatLengthForDisplay('50%') // { value: '50', suffix: '%' }
* formatLengthForDisplay('auto') // { value: 'auto', suffix: null }
* formatLengthForDisplay('calc(...)') // { value: 'calc(...)', suffix: null }
* formatLengthForDisplay('20') // { value: '20', suffix: 'px' }
* formatLengthForDisplay('') // { value: '', suffix: 'px' }
*/
export function formatLengthForDisplay(raw: string): FormattedLength {
const trimmed = raw.trim();
// Empty: show default "px" suffix for consistent affordance
if (!trimmed) {
return { value: '', suffix: 'px' };
}
const lower = trimmed.toLowerCase();
// Keywords should not show any unit suffix
if (LENGTH_KEYWORDS.has(lower)) {
return { value: trimmed, suffix: null };
}
// Function expressions (calc, var, etc.) should not show suffix
if (LENGTH_FUNCTION_REGEX.test(trimmed)) {
return { value: trimmed, suffix: null };
}
// Number with unit: separate value and suffix
const unitMatch = trimmed.match(NUMBER_WITH_UNIT_REGEX);
if (unitMatch) {
const value = unitMatch[1] ?? '';
const suffix = unitMatch[2] ?? '';
return { value, suffix: suffix || null };
}
// Pure number: default to "px" suffix
if (PURE_NUMBER_REGEX.test(trimmed)) {
return { value: trimmed, suffix: 'px' };
}
// Trailing dot number (e.g., "10."): treat as integer with "px"
if (TRAILING_DOT_NUMBER_REGEX.test(trimmed)) {
return { value: trimmed.slice(0, -1), suffix: 'px' };
}
// Fallback: unknown value, don't show misleading suffix
return { value: trimmed, suffix: null };
}
/**
* Combine an input value with a unit suffix to form a complete CSS value.
*
* This is the inverse of formatLengthForDisplay - it takes the separated
* value and suffix and combines them for CSS writing.
*
* @param inputValue - The value from the input field
* @param suffix - The current unit suffix (from getSuffixText)
* @returns The complete CSS value ready for style.setProperty()
*
* @example
* combineLengthValue('20', 'px') // '20px'
* combineLengthValue('50', '%') // '50%'
* combineLengthValue('auto', null) // 'auto'
* combineLengthValue('', 'px') // ''
* combineLengthValue('calc(...)', null) // 'calc(...)'
*/
export function combineLengthValue(inputValue: string, suffix: string | null): string {
const trimmed = inputValue.trim();
// Empty value clears the style
if (!trimmed) return '';
const lower = trimmed.toLowerCase();
// Keywords should not have suffix appended
if (LENGTH_KEYWORDS.has(lower)) return trimmed;
// Function expressions should not have suffix appended
if (LENGTH_FUNCTION_REGEX.test(trimmed)) return trimmed;
// If input already has a unit (user typed "20px"), use it as-is
if (NUMBER_WITH_UNIT_REGEX.test(trimmed)) return trimmed;
// Trailing dot number (e.g., "10."): normalize and add suffix
if (TRAILING_DOT_NUMBER_REGEX.test(trimmed)) {
const normalized = trimmed.slice(0, -1);
return suffix ? `${normalized}${suffix}` : `${normalized}px`;
}
// Pure number: append suffix (or default to px)
if (PURE_NUMBER_REGEX.test(trimmed)) {
return suffix ? `${trimmed}${suffix}` : `${trimmed}px`;
}
// Fallback: return as-is (might be invalid, but let browser handle it)
return trimmed;
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/common/agent-models.ts:
--------------------------------------------------------------------------------
```typescript
/**
* 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<string, string> = {
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<AgentCliType, ModelDefinition[]> = {
claude: CLAUDE_MODELS,
codex: CODEX_MODELS,
cursor: CURSOR_MODELS,
qwen: QWEN_MODELS,
glm: GLM_MODELS,
};
export const CLI_DEFAULT_MODELS: Record<AgentCliType, string> = {
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;
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/tests/record-replay/step-executor.contract.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Step Executor Routing Contract Tests
*
* Verifies that step execution routes correctly based on ExecutionModeConfig:
* - legacy mode: always uses legacy executeStep
* - hybrid mode: uses actions for allowlisted types, legacy for others
* - actions mode: always uses ActionRegistry (strict)
*/
import { describe, expect, it, vi, beforeEach } from 'vitest';
// Mock legacy executeStep - must be defined inline in vi.mock factory
vi.mock('@/entrypoints/background/record-replay/nodes', () => ({
executeStep: vi.fn(async () => ({})),
}));
// Mock createStepExecutor from adapter - must be defined inline in vi.mock factory
vi.mock('@/entrypoints/background/record-replay/actions/adapter', () => ({
createStepExecutor: vi.fn(() => vi.fn(async () => ({ supported: true, result: {} }))),
isActionSupported: vi.fn((type: string) => {
const supported = ['fill', 'key', 'scroll', 'click', 'navigate', 'delay', 'wait'];
return supported.includes(type);
}),
}));
import { createMockExecCtx, createMockStep, createMockRegistry } from './_test-helpers';
import {
DEFAULT_EXECUTION_MODE_CONFIG,
createHybridConfig,
createActionsOnlyConfig,
MINIMAL_HYBRID_ACTION_TYPES,
} from '@/entrypoints/background/record-replay/engine/execution-mode';
import {
LegacyStepExecutor,
ActionsStepExecutor,
HybridStepExecutor,
createExecutor,
} from '@/entrypoints/background/record-replay/engine/runners/step-executor';
import { executeStep as legacyExecuteStep } from '@/entrypoints/background/record-replay/nodes';
import { createStepExecutor as createAdapterExecutor } from '@/entrypoints/background/record-replay/actions/adapter';
describe('ExecutionModeConfig contract', () => {
describe('DEFAULT_EXECUTION_MODE_CONFIG', () => {
it('defaults to legacy mode', () => {
expect(DEFAULT_EXECUTION_MODE_CONFIG.mode).toBe('legacy');
});
it('defaults skipActionsRetry to true', () => {
expect(DEFAULT_EXECUTION_MODE_CONFIG.skipActionsRetry).toBe(true);
});
it('defaults skipActionsNavWait to true', () => {
expect(DEFAULT_EXECUTION_MODE_CONFIG.skipActionsNavWait).toBe(true);
});
});
describe('createHybridConfig', () => {
it('sets mode to hybrid', () => {
const config = createHybridConfig();
expect(config.mode).toBe('hybrid');
});
it('uses MINIMAL_HYBRID_ACTION_TYPES as default allowlist', () => {
const config = createHybridConfig();
expect(config.actionsAllowlist).toBeDefined();
expect(config.actionsAllowlist?.has('fill')).toBe(true);
expect(config.actionsAllowlist?.has('key')).toBe(true);
expect(config.actionsAllowlist?.has('scroll')).toBe(true);
// High-risk types should NOT be in minimal allowlist
expect(config.actionsAllowlist?.has('click')).toBe(false);
expect(config.actionsAllowlist?.has('navigate')).toBe(false);
});
it('allows overriding actionsAllowlist', () => {
const config = createHybridConfig({
actionsAllowlist: new Set(['fill', 'click']),
});
expect(config.actionsAllowlist?.has('fill')).toBe(true);
expect(config.actionsAllowlist?.has('click')).toBe(true);
expect(config.actionsAllowlist?.has('key')).toBe(false);
});
});
describe('createActionsOnlyConfig', () => {
it('sets mode to actions', () => {
const config = createActionsOnlyConfig();
expect(config.mode).toBe('actions');
});
it('keeps StepRunner as policy authority (skip flags true)', () => {
const config = createActionsOnlyConfig();
expect(config.skipActionsRetry).toBe(true);
expect(config.skipActionsNavWait).toBe(true);
});
});
});
describe('LegacyStepExecutor', () => {
const mockLegacyExecuteStep = legacyExecuteStep as ReturnType<typeof vi.fn>;
beforeEach(() => {
mockLegacyExecuteStep.mockClear();
});
it('always uses legacy executeStep', async () => {
const executor = new LegacyStepExecutor();
const ctx = createMockExecCtx();
const step = createMockStep('fill');
await executor.execute(ctx, step, { tabId: 1 });
expect(mockLegacyExecuteStep).toHaveBeenCalledWith(ctx, step);
});
it('returns executor type as legacy', async () => {
const executor = new LegacyStepExecutor();
const result = await executor.execute(createMockExecCtx(), createMockStep('click'), {
tabId: 1,
});
expect(result.executor).toBe('legacy');
});
it('supports all step types', () => {
const executor = new LegacyStepExecutor();
expect(executor.supports('fill')).toBe(true);
expect(executor.supports('unknown_type')).toBe(true);
});
});
describe('HybridStepExecutor routing', () => {
const mockLegacyExecuteStep = legacyExecuteStep as ReturnType<typeof vi.fn>;
beforeEach(() => {
mockLegacyExecuteStep.mockClear();
});
it('uses legacy for non-allowlisted types', async () => {
const config = createHybridConfig({ actionsAllowlist: new Set(['fill']) });
const mockReg = createMockRegistry();
const executor = new HybridStepExecutor(mockReg as any, config);
await executor.execute(
createMockExecCtx(),
createMockStep('click', { target: { candidates: [] } }),
{ tabId: 1 },
);
expect(mockLegacyExecuteStep).toHaveBeenCalled();
});
it('returns legacy executor type for non-allowlisted types', async () => {
const config = createHybridConfig({ actionsAllowlist: new Set(['fill']) });
const mockReg = createMockRegistry();
const executor = new HybridStepExecutor(mockReg as any, config);
const result = await executor.execute(
createMockExecCtx(),
createMockStep('navigate', { url: 'https://example.com' }),
{ tabId: 1 },
);
expect(result.executor).toBe('legacy');
});
});
describe('createExecutor factory', () => {
it('creates LegacyStepExecutor for legacy mode', () => {
const executor = createExecutor({ ...DEFAULT_EXECUTION_MODE_CONFIG, mode: 'legacy' });
expect(executor).toBeInstanceOf(LegacyStepExecutor);
});
it('creates ActionsStepExecutor for actions mode', () => {
const mockReg = createMockRegistry();
const executor = createExecutor(createActionsOnlyConfig(), mockReg as any);
expect(executor).toBeInstanceOf(ActionsStepExecutor);
});
it('creates HybridStepExecutor for hybrid mode', () => {
const mockReg = createMockRegistry();
const executor = createExecutor(createHybridConfig(), mockReg as any);
expect(executor).toBeInstanceOf(HybridStepExecutor);
});
it('throws if actions mode has no registry', () => {
expect(() => createExecutor(createActionsOnlyConfig())).toThrow(
'ActionRegistry required for actions execution mode',
);
});
it('throws if hybrid mode has no registry', () => {
expect(() => createExecutor(createHybridConfig())).toThrow(
'ActionRegistry required for hybrid execution mode',
);
});
});
describe('MINIMAL_HYBRID_ACTION_TYPES', () => {
it('contains only low-risk action types', () => {
const expected = ['fill', 'key', 'scroll', 'drag', 'wait', 'delay', 'screenshot', 'assert'];
for (const type of expected) {
expect(MINIMAL_HYBRID_ACTION_TYPES.has(type)).toBe(true);
}
});
it('excludes high-risk types (navigate, click, tab management)', () => {
const excluded = ['navigate', 'click', 'dblclick', 'openTab', 'switchTab', 'closeTab'];
for (const type of excluded) {
expect(MINIMAL_HYBRID_ACTION_TYPES.has(type)).toBe(false);
}
});
});
```
--------------------------------------------------------------------------------
/app/chrome-extension/inject-scripts/wait-helper.js:
--------------------------------------------------------------------------------
```javascript
/* eslint-disable */
// wait-helper.js
// Listen for text appearance/disappearance in the current document using MutationObserver.
// Returns a stable ref (compatible with accessibility-tree-helper) for the first matching element.
(function () {
if (window.__WAIT_HELPER_INITIALIZED__) return;
window.__WAIT_HELPER_INITIALIZED__ = true;
// Ensure ref mapping infra exists (compatible with accessibility-tree-helper.js)
if (!window.__claudeElementMap) window.__claudeElementMap = {};
if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;
function isVisible(el) {
try {
if (!(el instanceof Element)) return false;
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')
return false;
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
return true;
} catch {
return false;
}
}
function normalize(str) {
return String(str || '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
function matchesText(el, needle) {
const t = normalize(needle);
if (!t) return false;
try {
if (!isVisible(el)) return false;
const aria = el.getAttribute('aria-label');
if (aria && normalize(aria).includes(t)) return true;
const title = el.getAttribute('title');
if (title && normalize(title).includes(t)) return true;
const alt = el.getAttribute('alt');
if (alt && normalize(alt).includes(t)) return true;
const placeholder = el.getAttribute('placeholder');
if (placeholder && normalize(placeholder).includes(t)) return true;
// input/textarea value
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
const value = el.value || el.getAttribute('value');
if (value && normalize(value).includes(t)) return true;
}
const text = el.innerText || el.textContent || '';
if (normalize(text).includes(t)) return true;
} catch {}
return false;
}
function findElementByText(text) {
// Fast path: query common interactive elements first
const prioritized = Array.from(
document.querySelectorAll('a,button,input,textarea,select,label,summary,[role]'),
);
for (const el of prioritized) if (matchesText(el, text)) return el;
// Fallback: broader scan with cap to avoid blocking on huge pages
const walker = document.createTreeWalker(
document.body || document.documentElement,
NodeFilter.SHOW_ELEMENT,
);
let count = 0;
while (walker.nextNode()) {
const el = /** @type {Element} */ (walker.currentNode);
if (matchesText(el, text)) return el;
if (++count > 5000) break; // Hard cap to avoid long scans
}
return null;
}
function ensureRefForElement(el) {
// Try to reuse an existing ref
for (const k in window.__claudeElementMap) {
const weak = window.__claudeElementMap[k];
if (weak && typeof weak.deref === 'function' && weak.deref() === el) return k;
}
const refId = `ref_${++window.__claudeRefCounter}`;
window.__claudeElementMap[refId] = new WeakRef(el);
return refId;
}
function centerOf(el) {
const r = el.getBoundingClientRect();
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
}
function waitFor({ text, appear = true, timeout = 5000 }) {
return new Promise((resolve) => {
const start = Date.now();
let resolved = false;
const check = () => {
try {
const match = findElementByText(text);
if (appear) {
if (match) {
const ref = ensureRefForElement(match);
const center = centerOf(match);
done({ success: true, matched: { ref, center }, tookMs: Date.now() - start });
}
} else {
// wait for disappearance
if (!match) {
done({ success: true, matched: null, tookMs: Date.now() - start });
}
}
} catch {}
};
const done = (result) => {
if (resolved) return;
resolved = true;
obs && obs.disconnect();
clearTimeout(timer);
resolve(result);
};
const obs = new MutationObserver(() => check());
try {
obs.observe(document.documentElement || document.body, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
});
} catch {}
// Initial check
check();
const timer = setTimeout(
() => {
done({ success: false, reason: 'timeout', tookMs: Date.now() - start });
},
Math.max(0, timeout),
);
});
}
function waitForSelector({ selector, visible = true, timeout = 5000 }) {
return new Promise((resolve) => {
const start = Date.now();
let resolved = false;
const isMatch = () => {
try {
const el = document.querySelector(selector);
if (!el) return null;
if (!visible) return el;
return isVisible(el) ? el : null;
} catch {
return null;
}
};
const done = (result) => {
if (resolved) return;
resolved = true;
obs && obs.disconnect();
clearTimeout(timer);
resolve(result);
};
const check = () => {
const el = isMatch();
if (el) {
const ref = ensureRefForElement(el);
const center = centerOf(el);
done({ success: true, matched: { ref, center }, tookMs: Date.now() - start });
}
};
const obs = new MutationObserver(check);
try {
obs.observe(document.documentElement || document.body, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
});
} catch {}
// initial check
check();
const timer = setTimeout(
() => done({ success: false, reason: 'timeout', tookMs: Date.now() - start }),
Math.max(0, timeout),
);
});
}
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
try {
if (request && request.action === 'wait_helper_ping') {
sendResponse({ status: 'pong' });
return false;
}
if (request && request.action === 'waitForText') {
const text = String(request.text || '').trim();
const appear = request.appear !== false; // default true
const timeout = Number(request.timeout || 5000);
if (!text) {
sendResponse({ success: false, error: 'text is required' });
return true;
}
waitFor({ text, appear, timeout }).then((res) => sendResponse(res));
return true; // async
}
if (request && request.action === 'waitForSelector') {
const selector = String(request.selector || '').trim();
const visible = request.visible !== false; // default true
const timeout = Number(request.timeout || 5000);
if (!selector) {
sendResponse({ success: false, error: 'selector is required' });
return true;
}
waitForSelector({ selector, visible, timeout }).then((res) => sendResponse(res));
return true; // async
}
} catch (e) {
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
return true;
}
return false;
});
})();
```
--------------------------------------------------------------------------------
/app/chrome-extension/utils/text-chunker.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Text chunking utility
* Based on semantic chunking strategy, splits long text into small chunks suitable for vectorization
*/
export interface TextChunk {
text: string;
source: string;
index: number;
wordCount: number;
}
export interface ChunkingOptions {
maxWordsPerChunk?: number;
overlapSentences?: number;
minChunkLength?: number;
includeTitle?: boolean;
}
export class TextChunker {
private readonly defaultOptions: Required<ChunkingOptions> = {
maxWordsPerChunk: 80,
overlapSentences: 1,
minChunkLength: 20,
includeTitle: true,
};
public chunkText(content: string, title?: string, options?: ChunkingOptions): TextChunk[] {
const opts = { ...this.defaultOptions, ...options };
const chunks: TextChunk[] = [];
if (opts.includeTitle && title?.trim() && title.trim().length > 5) {
chunks.push({
text: title.trim(),
source: 'title',
index: 0,
wordCount: title.trim().split(/\s+/).length,
});
}
const cleanContent = content.trim();
if (!cleanContent) {
return chunks;
}
const sentences = this.splitIntoSentences(cleanContent);
if (sentences.length === 0) {
return this.fallbackChunking(cleanContent, chunks, opts);
}
const hasLongSentences = sentences.some(
(s: string) => s.split(/\s+/).length > opts.maxWordsPerChunk,
);
if (hasLongSentences) {
return this.mixedChunking(sentences, chunks, opts);
}
return this.groupSentencesIntoChunks(sentences, chunks, opts);
}
private splitIntoSentences(content: string): string[] {
const processedContent = content
.replace(/([。!?])\s*/g, '$1\n')
.replace(/([.!?])\s+(?=[A-Z])/g, '$1\n')
.replace(/([.!?]["'])\s+(?=[A-Z])/g, '$1\n')
.replace(/([.!?])\s*$/gm, '$1\n')
.replace(/([。!?][""])\s*/g, '$1\n')
.replace(/\n\s*\n/g, '\n');
const sentences = processedContent
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 15);
if (sentences.length < 3 && content.length > 500) {
return this.aggressiveSentenceSplitting(content);
}
return sentences;
}
private aggressiveSentenceSplitting(content: string): string[] {
const sentences = content
.replace(/([.!?。!?])/g, '$1\n')
.replace(/([;;::])/g, '$1\n')
.replace(/([))])\s*(?=[\u4e00-\u9fa5A-Z])/g, '$1\n')
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 15);
const maxWordsPerChunk = 80;
const finalSentences: string[] = [];
for (const sentence of sentences) {
const words = sentence.split(/\s+/);
if (words.length <= maxWordsPerChunk) {
finalSentences.push(sentence);
} else {
const overlapWords = 5;
for (let i = 0; i < words.length; i += maxWordsPerChunk - overlapWords) {
const chunkWords = words.slice(i, i + maxWordsPerChunk);
const chunkText = chunkWords.join(' ');
if (chunkText.length > 15) {
finalSentences.push(chunkText);
}
}
}
}
return finalSentences;
}
/**
* Group sentences into chunks
*/
private groupSentencesIntoChunks(
sentences: string[],
existingChunks: TextChunk[],
options: Required<ChunkingOptions>,
): TextChunk[] {
const chunks = [...existingChunks];
let chunkIndex = chunks.length;
let i = 0;
while (i < sentences.length) {
let currentChunkText = '';
let currentWordCount = 0;
let sentencesUsed = 0;
while (i + sentencesUsed < sentences.length && currentWordCount < options.maxWordsPerChunk) {
const sentence = sentences[i + sentencesUsed];
const sentenceWords = sentence.split(/\s+/).length;
if (currentWordCount + sentenceWords > options.maxWordsPerChunk && currentWordCount > 0) {
break;
}
currentChunkText += (currentChunkText ? ' ' : '') + sentence;
currentWordCount += sentenceWords;
sentencesUsed++;
}
if (currentChunkText.trim().length > options.minChunkLength) {
chunks.push({
text: currentChunkText.trim(),
source: `content_chunk_${chunkIndex}`,
index: chunkIndex,
wordCount: currentWordCount,
});
chunkIndex++;
}
i += Math.max(1, sentencesUsed - options.overlapSentences);
}
return chunks;
}
/**
* Mixed chunking method (handles long sentences)
*/
private mixedChunking(
sentences: string[],
existingChunks: TextChunk[],
options: Required<ChunkingOptions>,
): TextChunk[] {
const chunks = [...existingChunks];
let chunkIndex = chunks.length;
for (const sentence of sentences) {
const sentenceWords = sentence.split(/\s+/).length;
if (sentenceWords <= options.maxWordsPerChunk) {
chunks.push({
text: sentence.trim(),
source: `sentence_chunk_${chunkIndex}`,
index: chunkIndex,
wordCount: sentenceWords,
});
chunkIndex++;
} else {
const words = sentence.split(/\s+/);
for (let i = 0; i < words.length; i += options.maxWordsPerChunk) {
const chunkWords = words.slice(i, i + options.maxWordsPerChunk);
const chunkText = chunkWords.join(' ');
if (chunkText.length > options.minChunkLength) {
chunks.push({
text: chunkText,
source: `long_sentence_chunk_${chunkIndex}_part_${Math.floor(i / options.maxWordsPerChunk)}`,
index: chunkIndex,
wordCount: chunkWords.length,
});
}
}
chunkIndex++;
}
}
return chunks;
}
/**
* Fallback chunking (when sentence splitting fails)
*/
private fallbackChunking(
content: string,
existingChunks: TextChunk[],
options: Required<ChunkingOptions>,
): TextChunk[] {
const chunks = [...existingChunks];
let chunkIndex = chunks.length;
const paragraphs = content
.split(/\n\s*\n/)
.filter((p) => p.trim().length > options.minChunkLength);
if (paragraphs.length > 1) {
paragraphs.forEach((paragraph, index) => {
const cleanParagraph = paragraph.trim();
if (cleanParagraph.length > 0) {
const words = cleanParagraph.split(/\s+/);
const maxWordsPerChunk = 150;
for (let i = 0; i < words.length; i += maxWordsPerChunk) {
const chunkWords = words.slice(i, i + maxWordsPerChunk);
const chunkText = chunkWords.join(' ');
if (chunkText.length > options.minChunkLength) {
chunks.push({
text: chunkText,
source: `paragraph_${index}_chunk_${Math.floor(i / maxWordsPerChunk)}`,
index: chunkIndex,
wordCount: chunkWords.length,
});
chunkIndex++;
}
}
}
});
} else {
const words = content.trim().split(/\s+/);
const maxWordsPerChunk = 150;
for (let i = 0; i < words.length; i += maxWordsPerChunk) {
const chunkWords = words.slice(i, i + maxWordsPerChunk);
const chunkText = chunkWords.join(' ');
if (chunkText.length > options.minChunkLength) {
chunks.push({
text: chunkText,
source: `content_chunk_${Math.floor(i / maxWordsPerChunk)}`,
index: chunkIndex,
wordCount: chunkWords.length,
});
chunkIndex++;
}
}
}
return chunks;
}
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/offscreen/rr-keepalive.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview Offscreen Keepalive
* @description Keeps the MV3 service worker alive using an Offscreen Document + Port heartbeat.
*
* Architecture:
* - Offscreen connects to Background (Service Worker) via a named Port.
* - Offscreen sends periodic `keepalive.ping` messages while keepalive is enabled.
* - Background replies with `keepalive.pong` to confirm the channel is alive.
*
* Contract:
* - After `stop`, keepalive must fully stop: no ping loop, no Port, and no reconnection attempts.
* - After `start`, keepalive must (re)connect if needed and resume the ping loop.
*/
import {
RR_V3_KEEPALIVE_PORT_NAME,
DEFAULT_KEEPALIVE_PING_INTERVAL_MS,
type KeepaliveMessage,
} from '@/common/rr-v3-keepalive-protocol';
// ==================== Runtime Control Protocol ====================
const KEEPALIVE_CONTROL_MESSAGE_TYPE = 'rr_v3_keepalive.control' as const;
type KeepaliveControlCommand = 'start' | 'stop';
interface KeepaliveControlMessage {
type: typeof KEEPALIVE_CONTROL_MESSAGE_TYPE;
command: KeepaliveControlCommand;
}
function isKeepaliveControlMessage(value: unknown): value is KeepaliveControlMessage {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
if (v.type !== KEEPALIVE_CONTROL_MESSAGE_TYPE) return false;
return v.command === 'start' || v.command === 'stop';
}
// ==================== State ====================
let initialized = false;
let keepalivePort: chrome.runtime.Port | null = null;
let pingTimer: ReturnType<typeof setInterval> | null = null;
/** Whether keepalive is desired (set by start/stop commands from Background) */
let keepaliveDesired = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
// ==================== Type Guards ====================
/**
* Type guard for KeepaliveMessage.
*/
function isKeepaliveMessage(value: unknown): value is KeepaliveMessage {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
const type = v.type;
if (
type !== 'keepalive.ping' &&
type !== 'keepalive.pong' &&
type !== 'keepalive.start' &&
type !== 'keepalive.stop'
) {
return false;
}
return typeof v.timestamp === 'number' && Number.isFinite(v.timestamp);
}
// ==================== Port Management ====================
/**
* Schedule a reconnect attempt to maintain the Port connection.
* Only reconnect while keepalive is desired.
*/
function scheduleReconnect(delayMs = 1000): void {
if (!initialized) return;
if (!keepaliveDesired) return;
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (!initialized) return;
if (!keepaliveDesired) return;
if (!keepalivePort) {
console.log('[rr-keepalive] Attempting scheduled reconnect...');
keepalivePort = connectToBackground();
}
}, delayMs);
}
/**
* Create a Port connection to Background.
*/
function connectToBackground(): chrome.runtime.Port | null {
if (typeof chrome === 'undefined' || !chrome.runtime?.connect) {
console.warn('[rr-keepalive] chrome.runtime.connect not available');
return null;
}
try {
const port = chrome.runtime.connect({ name: RR_V3_KEEPALIVE_PORT_NAME });
port.onMessage.addListener((msg: unknown) => {
if (!isKeepaliveMessage(msg)) return;
if (msg.type === 'keepalive.start') {
console.log('[rr-keepalive] Received start command via Port');
startPingLoop();
} else if (msg.type === 'keepalive.stop') {
console.log('[rr-keepalive] Received stop command via Port');
stopPingLoop();
} else if (msg.type === 'keepalive.pong') {
// Background replied to our ping.
console.debug('[rr-keepalive] Received pong');
}
});
port.onDisconnect.addListener(() => {
console.log('[rr-keepalive] Port disconnected');
keepalivePort = null;
// Only reconnect if keepalive is still desired.
scheduleReconnect(1000);
});
console.log('[rr-keepalive] Connected to background');
return port;
} catch (e) {
console.warn('[rr-keepalive] Failed to connect:', e);
return null;
}
}
// ==================== Ping Loop ====================
/**
* Send a ping message to Background.
*/
function sendPing(): void {
if (!keepalivePort) {
keepalivePort = connectToBackground();
}
if (!keepalivePort) return;
const msg: KeepaliveMessage = {
type: 'keepalive.ping',
timestamp: Date.now(),
};
try {
keepalivePort.postMessage(msg);
console.debug('[rr-keepalive] Sent ping');
} catch (e) {
console.warn('[rr-keepalive] Failed to send ping:', e);
keepalivePort = null;
scheduleReconnect(1000);
}
}
/**
* Start the ping loop.
*/
function startPingLoop(): void {
if (pingTimer) return;
keepaliveDesired = true;
// Ensure we have a Port connection.
if (!keepalivePort) {
keepalivePort = connectToBackground();
}
// Send one ping immediately.
sendPing();
// Start the interval timer.
pingTimer = setInterval(() => {
sendPing();
}, DEFAULT_KEEPALIVE_PING_INTERVAL_MS);
console.log(
`[rr-keepalive] Ping loop started (interval=${DEFAULT_KEEPALIVE_PING_INTERVAL_MS}ms)`,
);
}
/**
* Stop the ping loop.
* This must fully stop keepalive: no timer, no Port, and no reconnection attempts.
*/
function stopPingLoop(): void {
keepaliveDesired = false;
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// Disconnect the Port to fully stop keepalive.
if (keepalivePort) {
try {
keepalivePort.disconnect();
} catch {
// Ignore
}
keepalivePort = null;
}
console.log('[rr-keepalive] Ping loop stopped');
}
// ==================== Public API ====================
/**
* Initialize keepalive control handlers.
* @description Registers the runtime control listener and waits for start/stop commands.
*/
export function initKeepalive(): void {
if (initialized) return;
initialized = true;
// Check Chrome API availability.
if (typeof chrome === 'undefined' || !chrome.runtime?.onMessage) {
console.warn('[rr-keepalive] chrome.runtime.onMessage not available');
return;
}
// Listen for runtime control messages from Background.
// This allows Background to send start/stop even when Port is not connected.
chrome.runtime.onMessage.addListener((msg: unknown, _sender, sendResponse) => {
if (!isKeepaliveControlMessage(msg)) return;
if (msg.command === 'start') {
console.log('[rr-keepalive] Received runtime start command');
startPingLoop();
} else {
console.log('[rr-keepalive] Received runtime stop command');
stopPingLoop();
}
try {
sendResponse({ ok: true });
} catch {
// Ignore
}
});
// Also establish initial Port connection for backwards compatibility.
if (chrome.runtime?.connect) {
keepalivePort = connectToBackground();
}
console.log('[rr-keepalive] Keepalive initialized');
}
/**
* Check whether keepalive is active.
*/
export function isKeepaliveActive(): boolean {
return keepaliveDesired && pingTimer !== null && keepalivePort !== null;
}
/**
* Get the active port count (for debugging).
* @deprecated Use isKeepaliveActive() instead
*/
export function getActivePortCount(): number {
return keepalivePort ? 1 : 0;
}
// Re-export for backwards compatibility
export {
RR_V3_KEEPALIVE_PORT_NAME,
type KeepaliveMessage,
} from '@/common/rr-v3-keepalive-protocol';
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/inject-script.ts:
--------------------------------------------------------------------------------
```typescript
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { ExecutionWorld } from '@/common/constants';
interface InjectScriptParam {
url?: string;
tabId?: number;
windowId?: number;
background?: boolean;
}
interface ScriptConfig {
type: ExecutionWorld;
jsScript: string;
}
interface SendCommandToInjectScriptToolParam {
tabId?: number;
eventName: string;
payload?: string;
}
const injectedTabs = new Map();
class InjectScriptTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.INJECT_SCRIPT;
async execute(args: InjectScriptParam & ScriptConfig): Promise<ToolResult> {
try {
const { url, type, jsScript, tabId, windowId, background } = args;
let tab: chrome.tabs.Tab | undefined;
if (!type || !jsScript) {
return createErrorResponse('Param [type] and [jsScript] is required');
}
if (typeof tabId === 'number') {
tab = await chrome.tabs.get(tabId);
} else if (url) {
// If URL is provided, check if it's already open
console.log(`Checking if URL is already open: ${url}`);
const allTabs = await chrome.tabs.query({});
// Find tab with matching URL
const matchingTabs = allTabs.filter((t) => {
// Normalize URLs for comparison (remove trailing slashes)
const tabUrl = t.url?.endsWith('/') ? t.url.slice(0, -1) : t.url;
const targetUrl = url.endsWith('/') ? url.slice(0, -1) : url;
return tabUrl === targetUrl;
});
if (matchingTabs.length > 0) {
// Use existing tab
tab = matchingTabs[0];
console.log(`Found existing tab with URL: ${url}, tab ID: ${tab.id}`);
} else {
// Create new tab with the URL
console.log(`No existing tab found with URL: ${url}, creating new tab`);
tab = await chrome.tabs.create({
url,
active: background === true ? false : true,
windowId,
});
// Wait for page to load
console.log('Waiting for page to load...');
await new Promise((resolve) => setTimeout(resolve, 3000));
}
} else {
// Use active tab (prefer the specified window)
const tabs =
typeof windowId === 'number'
? await chrome.tabs.query({ active: true, windowId })
: await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0]) {
return createErrorResponse('No active tab found');
}
tab = tabs[0];
}
if (!tab.id) {
return createErrorResponse('Tab has no ID');
}
// Optionally bring tab/window to foreground based on background flag
if (background !== true) {
await chrome.tabs.update(tab.id, { active: true });
await chrome.windows.update(tab.windowId, { focused: true });
}
const res = await handleInject(tab.id!, { ...args });
return {
content: [
{
type: 'text',
text: JSON.stringify(res),
},
],
isError: false,
};
} catch (error) {
console.error('Error in InjectScriptTool.execute:', error);
return createErrorResponse(
`Inject script error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
class SendCommandToInjectScriptTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT;
async execute(args: SendCommandToInjectScriptToolParam): Promise<ToolResult> {
try {
const { tabId, eventName, payload } = args;
if (!eventName) {
return createErrorResponse('Param [eventName] is required');
}
if (tabId) {
const tabExists = await isTabExists(tabId);
if (!tabExists) {
return createErrorResponse('The tab:[tabId] is not exists');
}
}
let finalTabId: number | undefined = tabId;
if (finalTabId === undefined) {
// Use active tab
const tabs = await chrome.tabs.query({ active: true });
if (!tabs[0]) {
return createErrorResponse('No active tab found');
}
finalTabId = tabs[0].id;
}
if (!finalTabId) {
return createErrorResponse('No active tab found');
}
if (!injectedTabs.has(finalTabId)) {
throw new Error('No script injected in this tab.');
}
const result = await chrome.tabs.sendMessage(finalTabId, {
action: eventName,
payload,
targetWorld: injectedTabs.get(finalTabId).type, // The bridge uses this to decide whether to forward to MAIN world.
});
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
isError: false,
};
} catch (error) {
console.error('Error in InjectScriptTool.execute:', error);
return createErrorResponse(
`Inject script error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
async function isTabExists(tabId: number) {
try {
await chrome.tabs.get(tabId);
return true;
} catch (error) {
// An error is thrown if the tab doesn't exist.
return false;
}
}
/**
* @description Handles the injection of user scripts into a specific tab.
* @param {number} tabId - The ID of the target tab.
* @param {object} scriptConfig - The configuration object for the script.
*/
async function handleInject(tabId: number, scriptConfig: ScriptConfig) {
if (injectedTabs.has(tabId)) {
// If already injected, run cleanup first to ensure a clean state.
console.log(`Tab ${tabId} already has injections. Cleaning up first.`);
await handleCleanup(tabId);
}
const { type, jsScript } = scriptConfig;
const hasMain = type === ExecutionWorld.MAIN;
if (hasMain) {
// The bridge is essential for MAIN world communication and cleanup.
await chrome.scripting.executeScript({
target: { tabId },
files: ['inject-scripts/inject-bridge.js'],
world: ExecutionWorld.ISOLATED,
});
await chrome.scripting.executeScript({
target: { tabId },
func: (code) => new Function(code)(),
args: [jsScript],
world: ExecutionWorld.MAIN,
});
} else {
await chrome.scripting.executeScript({
target: { tabId },
func: (code) => new Function(code)(),
args: [jsScript],
world: ExecutionWorld.ISOLATED,
});
}
injectedTabs.set(tabId, scriptConfig);
console.log(`Scripts successfully injected into tab ${tabId}.`);
return { injected: true };
}
/**
* @description Triggers the cleanup process in a specific tab.
* @param {number} tabId - The ID of the target tab.
*/
async function handleCleanup(tabId: number) {
if (!injectedTabs.has(tabId)) return;
// Send cleanup signal. The bridge will forward it to the MAIN world.
chrome.tabs
.sendMessage(tabId, { type: 'chrome-mcp:cleanup' })
.catch((err) =>
console.warn(`Could not send cleanup message to tab ${tabId}. It might have been closed.`),
);
injectedTabs.delete(tabId);
console.log(`Cleanup signal sent to tab ${tabId}. State cleared.`);
}
export const injectScriptTool = new InjectScriptTool();
export const sendCommandToInjectScriptTool = new SendCommandToInjectScriptTool();
// --- Automatic Cleanup Listeners ---
chrome.tabs.onRemoved.addListener((tabId) => {
if (injectedTabs.has(tabId)) {
console.log(`Tab ${tabId} closed. Cleaning up state.`);
injectedTabs.delete(tabId);
}
});
```