#
tokens: 48416/50000 21/574 files (page 6/43)
lines: off (toggle) GitHub
raw markdown copy
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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
  // 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);
  }
});

```
Page 6/43FirstPrevNextLast