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

# Directory Structure

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

# Files

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

```typescript
   1 | import { createErrorResponse, ToolResult } from '@/common/tool-handler';
   2 | import { BaseBrowserToolExecutor } from '../base-browser';
   3 | import { TOOL_NAMES } from 'chrome-mcp-shared';
   4 | import { cdpSessionManager } from '@/utils/cdp-session-manager';
   5 | import { NETWORK_FILTERS } from '@/common/constants';
   6 | 
   7 | interface NetworkDebuggerStartToolParams {
   8 |   url?: string; // URL to navigate to or focus. If not provided, uses active tab.
   9 |   maxCaptureTime?: number;
  10 |   inactivityTimeout?: number; // Inactivity timeout (milliseconds)
  11 |   includeStatic?: boolean; // if include static resources
  12 | }
  13 | 
  14 | // Network request object interface
  15 | interface NetworkRequestInfo {
  16 |   requestId: string;
  17 |   url: string;
  18 |   method: string;
  19 |   requestHeaders?: Record<string, string>; // Will be removed after common headers extraction
  20 |   responseHeaders?: Record<string, string>; // Will be removed after common headers extraction
  21 |   requestTime?: number; // Timestamp of the request
  22 |   responseTime?: number; // Timestamp of the response
  23 |   type: string; // Resource type (e.g., Document, XHR, Fetch, Script, Stylesheet)
  24 |   status: string; // 'pending', 'complete', 'error'
  25 |   statusCode?: number;
  26 |   statusText?: string;
  27 |   requestBody?: string;
  28 |   responseBody?: string;
  29 |   base64Encoded?: boolean; // For responseBody
  30 |   encodedDataLength?: number; // Actual bytes received
  31 |   errorText?: string; // If loading failed
  32 |   canceled?: boolean; // If loading was canceled
  33 |   mimeType?: string;
  34 |   specificRequestHeaders?: Record<string, string>; // Headers unique to this request
  35 |   specificResponseHeaders?: Record<string, string>; // Headers unique to this response
  36 |   [key: string]: any; // Allow other properties from debugger events
  37 | }
  38 | 
  39 | const DEBUGGER_PROTOCOL_VERSION = '1.3';
  40 | const MAX_RESPONSE_BODY_SIZE_BYTES = 1 * 1024 * 1024; // 1MB
  41 | const DEFAULT_MAX_CAPTURE_TIME_MS = 3 * 60 * 1000; // 3 minutes
  42 | const DEFAULT_INACTIVITY_TIMEOUT_MS = 60 * 1000; // 1 minute
  43 | 
  44 | /**
  45 |  * Network capture start tool - uses Chrome Debugger API to start capturing network requests
  46 |  */
  47 | class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
  48 |   name = TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START;
  49 |   private captureData: Map<number, any> = new Map(); // tabId -> capture data
  50 |   private captureTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> max capture timer
  51 |   private inactivityTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> inactivity timer
  52 |   private lastActivityTime: Map<number, number> = new Map(); // tabId -> timestamp of last network activity
  53 |   private pendingResponseBodies: Map<string, Promise<any>> = new Map(); // requestId -> promise for getResponseBody
  54 |   private requestCounters: Map<number, number> = new Map(); // tabId -> count of captured requests (after filtering)
  55 |   private static MAX_REQUESTS_PER_CAPTURE = 100; // Max requests to store to prevent memory issues
  56 |   public static instance: NetworkDebuggerStartTool | null = null;
  57 | 
  58 |   constructor() {
  59 |     super();
  60 |     if (NetworkDebuggerStartTool.instance) {
  61 |       return NetworkDebuggerStartTool.instance;
  62 |     }
  63 |     NetworkDebuggerStartTool.instance = this;
  64 | 
  65 |     chrome.debugger.onEvent.addListener(this.handleDebuggerEvent.bind(this));
  66 |     chrome.debugger.onDetach.addListener(this.handleDebuggerDetach.bind(this));
  67 |     chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this));
  68 |     chrome.tabs.onCreated.addListener(this.handleTabCreated.bind(this));
  69 |   }
  70 | 
  71 |   private handleTabRemoved(tabId: number) {
  72 |     if (this.captureData.has(tabId)) {
  73 |       console.log(`NetworkDebuggerStartTool: Tab ${tabId} was closed, cleaning up resources.`);
  74 |       this.cleanupCapture(tabId);
  75 |     }
  76 |   }
  77 | 
  78 |   /**
  79 |    * Handle tab creation events
  80 |    * If a new tab is opened from a tab that is currently capturing, automatically start capturing the new tab's requests
  81 |    */
  82 |   private async handleTabCreated(tab: chrome.tabs.Tab) {
  83 |     try {
  84 |       // Check if there are any tabs currently capturing
  85 |       if (this.captureData.size === 0) return;
  86 | 
  87 |       // Get the openerTabId of the new tab (ID of the tab that opened this tab)
  88 |       const openerTabId = tab.openerTabId;
  89 |       if (!openerTabId) return;
  90 | 
  91 |       // Check if the opener tab is currently capturing
  92 |       if (!this.captureData.has(openerTabId)) return;
  93 | 
  94 |       // Get the new tab's ID
  95 |       const newTabId = tab.id;
  96 |       if (!newTabId) return;
  97 | 
  98 |       console.log(
  99 |         `NetworkDebuggerStartTool: New tab ${newTabId} created from capturing tab ${openerTabId}, will extend capture to it.`,
 100 |       );
 101 | 
 102 |       // Get the opener tab's capture settings
 103 |       const openerCaptureInfo = this.captureData.get(openerTabId);
 104 |       if (!openerCaptureInfo) return;
 105 | 
 106 |       // Wait a short time to ensure the tab is ready
 107 |       await new Promise((resolve) => setTimeout(resolve, 500));
 108 | 
 109 |       // Start capturing requests for the new tab
 110 |       await this.startCaptureForTab(newTabId, {
 111 |         maxCaptureTime: openerCaptureInfo.maxCaptureTime,
 112 |         inactivityTimeout: openerCaptureInfo.inactivityTimeout,
 113 |         includeStatic: openerCaptureInfo.includeStatic,
 114 |       });
 115 | 
 116 |       console.log(`NetworkDebuggerStartTool: Successfully extended capture to new tab ${newTabId}`);
 117 |     } catch (error) {
 118 |       console.error(`NetworkDebuggerStartTool: Error extending capture to new tab:`, error);
 119 |     }
 120 |   }
 121 | 
 122 |   /**
 123 |    * Start network request capture for specified tab
 124 |    * @param tabId Tab ID
 125 |    * @param options Capture options
 126 |    */
 127 |   private async startCaptureForTab(
 128 |     tabId: number,
 129 |     options: {
 130 |       maxCaptureTime: number;
 131 |       inactivityTimeout: number;
 132 |       includeStatic: boolean;
 133 |     },
 134 |   ): Promise<void> {
 135 |     const { maxCaptureTime, inactivityTimeout, includeStatic } = options;
 136 | 
 137 |     // If already capturing, stop first
 138 |     if (this.captureData.has(tabId)) {
 139 |       console.log(
 140 |         `NetworkDebuggerStartTool: Already capturing on tab ${tabId}. Stopping previous session.`,
 141 |       );
 142 |       await this.stopCapture(tabId);
 143 |     }
 144 | 
 145 |     try {
 146 |       // Get tab information
 147 |       const tab = await chrome.tabs.get(tabId);
 148 | 
 149 |       // Attach via shared manager (handles conflicts and refcount)
 150 |       await cdpSessionManager.attach(tabId, 'network-capture');
 151 | 
 152 |       // Enable network tracking
 153 |       try {
 154 |         await cdpSessionManager.sendCommand(tabId, 'Network.enable');
 155 |       } catch (error: any) {
 156 |         await cdpSessionManager
 157 |           .detach(tabId, 'network-capture')
 158 |           .catch((e) => console.warn('Error detaching after failed enable:', e));
 159 |         throw error;
 160 |       }
 161 | 
 162 |       // Initialize capture data
 163 |       this.captureData.set(tabId, {
 164 |         startTime: Date.now(),
 165 |         tabUrl: tab.url,
 166 |         tabTitle: tab.title,
 167 |         maxCaptureTime,
 168 |         inactivityTimeout,
 169 |         includeStatic,
 170 |         requests: {},
 171 |         limitReached: false,
 172 |       });
 173 | 
 174 |       // Initialize request counter
 175 |       this.requestCounters.set(tabId, 0);
 176 | 
 177 |       // Update last activity time
 178 |       this.updateLastActivityTime(tabId);
 179 | 
 180 |       console.log(
 181 |         `NetworkDebuggerStartTool: Started capture for tab ${tabId} (${tab.url}). Max requests: ${NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE}, Max time: ${maxCaptureTime}ms, Inactivity: ${inactivityTimeout}ms.`,
 182 |       );
 183 | 
 184 |       // Set maximum capture time
 185 |       if (maxCaptureTime > 0) {
 186 |         this.captureTimers.set(
 187 |           tabId,
 188 |           setTimeout(async () => {
 189 |             console.log(
 190 |               `NetworkDebuggerStartTool: Max capture time (${maxCaptureTime}ms) reached for tab ${tabId}.`,
 191 |             );
 192 |             await this.stopCapture(tabId, true); // Auto-stop due to max time
 193 |           }, maxCaptureTime),
 194 |         );
 195 |       }
 196 |     } catch (error: any) {
 197 |       console.error(`NetworkDebuggerStartTool: Error starting capture for tab ${tabId}:`, error);
 198 | 
 199 |       // Clean up resources
 200 |       if (this.captureData.has(tabId)) {
 201 |         await cdpSessionManager
 202 |           .detach(tabId, 'network-capture')
 203 |           .catch((e) => console.warn('Cleanup detach error:', e));
 204 |         this.cleanupCapture(tabId);
 205 |       }
 206 | 
 207 |       throw error;
 208 |     }
 209 |   }
 210 | 
 211 |   private handleDebuggerEvent(source: chrome.debugger.Debuggee, method: string, params?: any) {
 212 |     if (!source.tabId) return;
 213 | 
 214 |     const tabId = source.tabId;
 215 |     const captureInfo = this.captureData.get(tabId);
 216 | 
 217 |     if (!captureInfo) return; // Not capturing for this tab
 218 | 
 219 |     // Update last activity time for any relevant network event
 220 |     this.updateLastActivityTime(tabId);
 221 | 
 222 |     switch (method) {
 223 |       case 'Network.requestWillBeSent':
 224 |         this.handleRequestWillBeSent(tabId, params);
 225 |         break;
 226 |       case 'Network.responseReceived':
 227 |         this.handleResponseReceived(tabId, params);
 228 |         break;
 229 |       case 'Network.loadingFinished':
 230 |         this.handleLoadingFinished(tabId, params);
 231 |         break;
 232 |       case 'Network.loadingFailed':
 233 |         this.handleLoadingFailed(tabId, params);
 234 |         break;
 235 |     }
 236 |   }
 237 | 
 238 |   private handleDebuggerDetach(source: chrome.debugger.Debuggee, reason: string) {
 239 |     if (source.tabId && this.captureData.has(source.tabId)) {
 240 |       console.log(
 241 |         `NetworkDebuggerStartTool: Debugger detached from tab ${source.tabId}, reason: ${reason}. Cleaning up.`,
 242 |       );
 243 |       // Potentially inform the user or log the result if the detachment was unexpected
 244 |       this.cleanupCapture(source.tabId); // Ensure cleanup happens
 245 |     }
 246 |   }
 247 | 
 248 |   private updateLastActivityTime(tabId: number) {
 249 |     this.lastActivityTime.set(tabId, Date.now());
 250 |     const captureInfo = this.captureData.get(tabId);
 251 | 
 252 |     if (captureInfo && captureInfo.inactivityTimeout > 0) {
 253 |       if (this.inactivityTimers.has(tabId)) {
 254 |         clearTimeout(this.inactivityTimers.get(tabId)!);
 255 |       }
 256 |       this.inactivityTimers.set(
 257 |         tabId,
 258 |         setTimeout(() => this.checkInactivity(tabId), captureInfo.inactivityTimeout),
 259 |       );
 260 |     }
 261 |   }
 262 | 
 263 |   private checkInactivity(tabId: number) {
 264 |     const captureInfo = this.captureData.get(tabId);
 265 |     if (!captureInfo) return;
 266 | 
 267 |     const lastActivity = this.lastActivityTime.get(tabId) || captureInfo.startTime; // Use startTime if no activity yet
 268 |     const now = Date.now();
 269 |     const inactiveTime = now - lastActivity;
 270 | 
 271 |     if (inactiveTime >= captureInfo.inactivityTimeout) {
 272 |       console.log(
 273 |         `NetworkDebuggerStartTool: No activity for ${inactiveTime}ms (threshold: ${captureInfo.inactivityTimeout}ms), stopping capture for tab ${tabId}`,
 274 |       );
 275 |       this.stopCaptureByInactivity(tabId);
 276 |     } else {
 277 |       // Reschedule check for the remaining time, this handles system sleep or other interruptions
 278 |       const remainingTime = Math.max(0, captureInfo.inactivityTimeout - inactiveTime);
 279 |       this.inactivityTimers.set(
 280 |         tabId,
 281 |         setTimeout(() => this.checkInactivity(tabId), remainingTime),
 282 |       );
 283 |     }
 284 |   }
 285 | 
 286 |   private async stopCaptureByInactivity(tabId: number) {
 287 |     const captureInfo = this.captureData.get(tabId);
 288 |     if (!captureInfo) return;
 289 | 
 290 |     console.log(`NetworkDebuggerStartTool: Stopping capture due to inactivity for tab ${tabId}.`);
 291 |     // Potentially, we might want to notify the client/user that this happened.
 292 |     // For now, just stop and make the results available if StopTool is called.
 293 |     await this.stopCapture(tabId, true); // Pass a flag indicating it's an auto-stop
 294 |   }
 295 | 
 296 |   /**
 297 |    * Check if URL should be filtered based on EXCLUDED_DOMAINS patterns.
 298 |    * Uses full URL substring match to support patterns like 'facebook.com/tr'.
 299 |    */
 300 |   private shouldFilterRequestByUrl(url: string): boolean {
 301 |     const normalizedUrl = String(url || '').toLowerCase();
 302 |     if (!normalizedUrl) return false;
 303 |     return NETWORK_FILTERS.EXCLUDED_DOMAINS.some((pattern) => normalizedUrl.includes(pattern));
 304 |   }
 305 | 
 306 |   private shouldFilterRequestByExtension(url: string, includeStatic: boolean): boolean {
 307 |     if (includeStatic) return false;
 308 | 
 309 |     try {
 310 |       const urlObj = new URL(url);
 311 |       const path = urlObj.pathname.toLowerCase();
 312 |       return NETWORK_FILTERS.STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext));
 313 |     } catch {
 314 |       return false;
 315 |     }
 316 |   }
 317 | 
 318 |   private shouldFilterByMimeType(mimeType: string, includeStatic: boolean): boolean {
 319 |     if (!mimeType) return false;
 320 | 
 321 |     // Never filter API MIME types
 322 |     if (NETWORK_FILTERS.API_MIME_TYPES.some((apiMime) => mimeType.startsWith(apiMime))) {
 323 |       return false;
 324 |     }
 325 | 
 326 |     // Filter static MIME types when not including static resources
 327 |     if (!includeStatic) {
 328 |       return NETWORK_FILTERS.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) =>
 329 |         mimeType.startsWith(staticMime),
 330 |       );
 331 |     }
 332 | 
 333 |     return false;
 334 |   }
 335 | 
 336 |   private handleRequestWillBeSent(tabId: number, params: any) {
 337 |     const captureInfo = this.captureData.get(tabId);
 338 |     if (!captureInfo) return;
 339 | 
 340 |     const { requestId, request, timestamp, type, loaderId, frameId } = params;
 341 | 
 342 |     // Initial filtering by URL (ads, analytics) and extension (if !includeStatic)
 343 |     if (
 344 |       this.shouldFilterRequestByUrl(request.url) ||
 345 |       this.shouldFilterRequestByExtension(request.url, captureInfo.includeStatic)
 346 |     ) {
 347 |       return;
 348 |     }
 349 | 
 350 |     const currentCount = this.requestCounters.get(tabId) || 0;
 351 |     if (currentCount >= NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE) {
 352 |       // console.log(`NetworkDebuggerStartTool: Request limit (${NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE}) reached for tab ${tabId}. Ignoring: ${request.url}`);
 353 |       captureInfo.limitReached = true; // Mark that limit was hit
 354 |       return;
 355 |     }
 356 | 
 357 |     // Store initial request info
 358 |     // Ensure we don't overwrite if a redirect (same requestId) occurred, though usually loaderId changes
 359 |     if (!captureInfo.requests[requestId]) {
 360 |       // Or check based on loaderId as well if needed
 361 |       captureInfo.requests[requestId] = {
 362 |         requestId,
 363 |         url: request.url,
 364 |         method: request.method,
 365 |         requestHeaders: request.headers, // Temporary, will be processed
 366 |         requestTime: timestamp * 1000, // Convert seconds to milliseconds
 367 |         type: type || 'Other',
 368 |         status: 'pending', // Initial status
 369 |         loaderId, // Useful for tracking redirects
 370 |         frameId, // Useful for context
 371 |       };
 372 | 
 373 |       if (request.postData) {
 374 |         captureInfo.requests[requestId].requestBody = request.postData;
 375 |       }
 376 |       // console.log(`NetworkDebuggerStartTool: Captured request for tab ${tabId}: ${request.method} ${request.url}`);
 377 |     } else {
 378 |       // This could be a redirect. Update URL and other relevant fields.
 379 |       // Chrome often issues a new `requestWillBeSent` for redirects with the same `requestId` but a new `loaderId`.
 380 |       // console.log(`NetworkDebuggerStartTool: Request ${requestId} updated (likely redirect) for tab ${tabId} to URL: ${request.url}`);
 381 |       const existingRequest = captureInfo.requests[requestId];
 382 |       existingRequest.url = request.url; // Update URL due to redirect
 383 |       existingRequest.requestTime = timestamp * 1000; // Update time for the redirected request
 384 |       if (request.headers) existingRequest.requestHeaders = request.headers;
 385 |       if (request.postData) existingRequest.requestBody = request.postData;
 386 |       else delete existingRequest.requestBody;
 387 |     }
 388 |   }
 389 | 
 390 |   private handleResponseReceived(tabId: number, params: any) {
 391 |     const captureInfo = this.captureData.get(tabId);
 392 |     if (!captureInfo) return;
 393 | 
 394 |     const { requestId, response, timestamp, type } = params; // type here is resource type
 395 |     const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId];
 396 | 
 397 |     if (!requestInfo) {
 398 |       // console.warn(`NetworkDebuggerStartTool: Received response for unknown requestId ${requestId} on tab ${tabId}`);
 399 |       return;
 400 |     }
 401 | 
 402 |     // Secondary filtering based on MIME type, now that we have it
 403 |     if (this.shouldFilterByMimeType(response.mimeType, captureInfo.includeStatic)) {
 404 |       // console.log(`NetworkDebuggerStartTool: Filtering request by MIME type (${response.mimeType}): ${requestInfo.url}`);
 405 |       delete captureInfo.requests[requestId]; // Remove from captured data
 406 |       // Note: We don't decrement requestCounter here as it's meant to track how many *potential* requests were processed up to MAX_REQUESTS.
 407 |       // Or, if MAX_REQUESTS is strictly for *stored* requests, then decrement. For now, let's assume it's for stored.
 408 |       // const currentCount = this.requestCounters.get(tabId) || 0;
 409 |       // if (currentCount > 0) this.requestCounters.set(tabId, currentCount -1);
 410 |       return;
 411 |     }
 412 | 
 413 |     // If not filtered by MIME, then increment actual stored request counter
 414 |     const currentStoredCount = Object.keys(captureInfo.requests).length; // A bit inefficient but accurate
 415 |     this.requestCounters.set(tabId, currentStoredCount);
 416 | 
 417 |     requestInfo.status = response.status === 0 ? 'pending' : 'complete'; // status 0 can mean pending or blocked
 418 |     requestInfo.statusCode = response.status;
 419 |     requestInfo.statusText = response.statusText;
 420 |     requestInfo.responseHeaders = response.headers; // Temporary
 421 |     requestInfo.mimeType = response.mimeType;
 422 |     requestInfo.responseTime = timestamp * 1000; // Convert seconds to milliseconds
 423 |     if (type) requestInfo.type = type; // Update resource type if provided by this event
 424 | 
 425 |     // console.log(`NetworkDebuggerStartTool: Received response for ${requestId} on tab ${tabId}: ${response.status}`);
 426 |   }
 427 | 
 428 |   private async handleLoadingFinished(tabId: number, params: any) {
 429 |     const captureInfo = this.captureData.get(tabId);
 430 |     if (!captureInfo) return;
 431 | 
 432 |     const { requestId, encodedDataLength } = params;
 433 |     const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId];
 434 | 
 435 |     if (!requestInfo) {
 436 |       // console.warn(`NetworkDebuggerStartTool: LoadingFinished for unknown requestId ${requestId} on tab ${tabId}`);
 437 |       return;
 438 |     }
 439 | 
 440 |     requestInfo.encodedDataLength = encodedDataLength;
 441 |     if (requestInfo.status === 'pending') requestInfo.status = 'complete'; // Mark as complete if not already
 442 |     // requestInfo.responseTime is usually set by responseReceived, but this timestamp is later.
 443 |     // timestamp here is when the resource finished loading. Could be useful for duration calculation.
 444 | 
 445 |     if (this.shouldCaptureResponseBody(requestInfo)) {
 446 |       try {
 447 |         // console.log(`NetworkDebuggerStartTool: Attempting to get response body for ${requestId} (${requestInfo.url})`);
 448 |         const responseBodyData = await this.getResponseBody(tabId, requestId);
 449 |         if (responseBodyData) {
 450 |           if (
 451 |             responseBodyData.body &&
 452 |             responseBodyData.body.length > MAX_RESPONSE_BODY_SIZE_BYTES
 453 |           ) {
 454 |             requestInfo.responseBody =
 455 |               responseBodyData.body.substring(0, MAX_RESPONSE_BODY_SIZE_BYTES) +
 456 |               `\n\n... [Response truncated, total size: ${responseBodyData.body.length} bytes] ...`;
 457 |           } else {
 458 |             requestInfo.responseBody = responseBodyData.body;
 459 |           }
 460 |           requestInfo.base64Encoded = responseBodyData.base64Encoded;
 461 |           // console.log(`NetworkDebuggerStartTool: Successfully got response body for ${requestId}, size: ${requestInfo.responseBody?.length || 0} bytes`);
 462 |         }
 463 |       } catch (error) {
 464 |         // console.warn(`NetworkDebuggerStartTool: Failed to get response body for ${requestId}:`, error);
 465 |         requestInfo.errorText =
 466 |           (requestInfo.errorText || '') +
 467 |           ` Failed to get body: ${error instanceof Error ? error.message : String(error)}`;
 468 |       }
 469 |     }
 470 |   }
 471 | 
 472 |   private shouldCaptureResponseBody(requestInfo: NetworkRequestInfo): boolean {
 473 |     const mimeType = requestInfo.mimeType || '';
 474 | 
 475 |     // Prioritize API MIME types for body capture
 476 |     if (NETWORK_FILTERS.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) {
 477 |       return true;
 478 |     }
 479 | 
 480 |     // Heuristics for other potential API calls not perfectly matching MIME types
 481 |     const url = requestInfo.url.toLowerCase();
 482 |     if (
 483 |       /\/(api|service|rest|graphql|query|data|rpc|v[0-9]+)\//i.test(url) ||
 484 |       url.includes('.json') ||
 485 |       url.includes('json=') ||
 486 |       url.includes('format=json')
 487 |     ) {
 488 |       // If it looks like an API call by URL structure, try to get body,
 489 |       // unless it's a known non-API MIME type that slipped through (e.g. a script from a /api/ path)
 490 |       if (
 491 |         mimeType &&
 492 |         NETWORK_FILTERS.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) =>
 493 |           mimeType.startsWith(staticMime),
 494 |         )
 495 |       ) {
 496 |         return false; // e.g. a CSS file served from an /api/ path
 497 |       }
 498 |       return true;
 499 |     }
 500 | 
 501 |     return false;
 502 |   }
 503 | 
 504 |   private handleLoadingFailed(tabId: number, params: any) {
 505 |     const captureInfo = this.captureData.get(tabId);
 506 |     if (!captureInfo) return;
 507 | 
 508 |     const { requestId, errorText, canceled, type } = params;
 509 |     const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId];
 510 | 
 511 |     if (!requestInfo) {
 512 |       // console.warn(`NetworkDebuggerStartTool: LoadingFailed for unknown requestId ${requestId} on tab ${tabId}`);
 513 |       return;
 514 |     }
 515 | 
 516 |     requestInfo.status = 'error';
 517 |     requestInfo.errorText = errorText;
 518 |     requestInfo.canceled = canceled;
 519 |     if (type) requestInfo.type = type;
 520 |     // timestamp here is when loading failed.
 521 |     // console.log(`NetworkDebuggerStartTool: Loading failed for ${requestId} on tab ${tabId}: ${errorText}`);
 522 |   }
 523 | 
 524 |   private async getResponseBody(
 525 |     tabId: number,
 526 |     requestId: string,
 527 |   ): Promise<{ body: string; base64Encoded: boolean } | null> {
 528 |     const pendingKey = `${tabId}_${requestId}`;
 529 |     if (this.pendingResponseBodies.has(pendingKey)) {
 530 |       return this.pendingResponseBodies.get(pendingKey)!; // Return existing promise
 531 |     }
 532 | 
 533 |     const responseBodyPromise = (async () => {
 534 |       try {
 535 |         // Will attach temporarily if needed
 536 |         const result = (await cdpSessionManager.sendCommand(tabId, 'Network.getResponseBody', {
 537 |           requestId,
 538 |         })) as { body: string; base64Encoded: boolean };
 539 |         return result;
 540 |       } finally {
 541 |         this.pendingResponseBodies.delete(pendingKey); // Clean up after promise resolves or rejects
 542 |       }
 543 |     })();
 544 | 
 545 |     this.pendingResponseBodies.set(pendingKey, responseBodyPromise);
 546 |     return responseBodyPromise;
 547 |   }
 548 | 
 549 |   private cleanupCapture(tabId: number) {
 550 |     if (this.captureTimers.has(tabId)) {
 551 |       clearTimeout(this.captureTimers.get(tabId)!);
 552 |       this.captureTimers.delete(tabId);
 553 |     }
 554 |     if (this.inactivityTimers.has(tabId)) {
 555 |       clearTimeout(this.inactivityTimers.get(tabId)!);
 556 |       this.inactivityTimers.delete(tabId);
 557 |     }
 558 | 
 559 |     this.lastActivityTime.delete(tabId);
 560 |     this.captureData.delete(tabId);
 561 |     this.requestCounters.delete(tabId);
 562 | 
 563 |     // Abort pending getResponseBody calls for this tab
 564 |     // Note: Promises themselves cannot be "aborted" externally in a standard way once created.
 565 |     // We can delete them from the map, so new calls won't use them,
 566 |     // and the original promise will eventually resolve or reject.
 567 |     const keysToDelete: string[] = [];
 568 |     this.pendingResponseBodies.forEach((_, key) => {
 569 |       if (key.startsWith(`${tabId}_`)) {
 570 |         keysToDelete.push(key);
 571 |       }
 572 |     });
 573 |     keysToDelete.forEach((key) => this.pendingResponseBodies.delete(key));
 574 | 
 575 |     console.log(`NetworkDebuggerStartTool: Cleaned up resources for tab ${tabId}.`);
 576 |   }
 577 | 
 578 |   // isAutoStop is true if stop was triggered by timeout, false if by user/explicit call
 579 |   async stopCapture(tabId: number, isAutoStop: boolean = false): Promise<any> {
 580 |     const captureInfo = this.captureData.get(tabId);
 581 |     if (!captureInfo) {
 582 |       return { success: false, message: 'No capture in progress for this tab.' };
 583 |     }
 584 | 
 585 |     console.log(
 586 |       `NetworkDebuggerStartTool: Stopping capture for tab ${tabId}. Auto-stop: ${isAutoStop}`,
 587 |     );
 588 | 
 589 |     try {
 590 |       // Attempt to disable network and detach via manager; it will no-op if others own the session
 591 |       try {
 592 |         await cdpSessionManager.sendCommand(tabId, 'Network.disable');
 593 |       } catch (e) {
 594 |         console.warn(
 595 |           `NetworkDebuggerStartTool: Error disabling network for tab ${tabId} (possibly already detached):`,
 596 |           e,
 597 |         );
 598 |       }
 599 |       try {
 600 |         await cdpSessionManager.detach(tabId, 'network-capture');
 601 |       } catch (e) {
 602 |         console.warn(
 603 |           `NetworkDebuggerStartTool: Error detaching debugger for tab ${tabId} (possibly already detached):`,
 604 |           e,
 605 |         );
 606 |       }
 607 |     } catch (error: any) {
 608 |       // Catch errors from getTargets or general logic
 609 |       console.error(
 610 |         'NetworkDebuggerStartTool: Error during debugger interaction in stopCapture:',
 611 |         error,
 612 |       );
 613 |       // Proceed to cleanup and data formatting
 614 |     }
 615 | 
 616 |     // Process data even if detach/disable failed, as some data might have been captured.
 617 |     const allRequests = Object.values(captureInfo.requests) as NetworkRequestInfo[];
 618 |     const commonRequestHeaders = this.analyzeCommonHeaders(allRequests, 'requestHeaders');
 619 |     const commonResponseHeaders = this.analyzeCommonHeaders(allRequests, 'responseHeaders');
 620 | 
 621 |     const processedRequests = allRequests.map((req) => {
 622 |       const finalReq: Partial<NetworkRequestInfo> &
 623 |         Pick<NetworkRequestInfo, 'requestId' | 'url' | 'method' | 'type' | 'status'> = { ...req };
 624 | 
 625 |       if (finalReq.requestHeaders) {
 626 |         finalReq.specificRequestHeaders = this.filterOutCommonHeaders(
 627 |           finalReq.requestHeaders,
 628 |           commonRequestHeaders,
 629 |         );
 630 |         delete finalReq.requestHeaders; // Remove original full headers
 631 |       } else {
 632 |         finalReq.specificRequestHeaders = {};
 633 |       }
 634 | 
 635 |       if (finalReq.responseHeaders) {
 636 |         finalReq.specificResponseHeaders = this.filterOutCommonHeaders(
 637 |           finalReq.responseHeaders,
 638 |           commonResponseHeaders,
 639 |         );
 640 |         delete finalReq.responseHeaders; // Remove original full headers
 641 |       } else {
 642 |         finalReq.specificResponseHeaders = {};
 643 |       }
 644 |       return finalReq as NetworkRequestInfo; // Cast back to full type
 645 |     });
 646 | 
 647 |     // Sort requests by requestTime
 648 |     processedRequests.sort((a, b) => (a.requestTime || 0) - (b.requestTime || 0));
 649 | 
 650 |     const resultData = {
 651 |       captureStartTime: captureInfo.startTime,
 652 |       captureEndTime: Date.now(),
 653 |       totalDurationMs: Date.now() - captureInfo.startTime,
 654 |       commonRequestHeaders,
 655 |       commonResponseHeaders,
 656 |       requests: processedRequests,
 657 |       requestCount: processedRequests.length, // Actual stored requests
 658 |       totalRequestsReceivedBeforeLimit: captureInfo.limitReached
 659 |         ? NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE
 660 |         : processedRequests.length,
 661 |       requestLimitReached: !!captureInfo.limitReached,
 662 |       stoppedBy: isAutoStop
 663 |         ? this.lastActivityTime.get(tabId)
 664 |           ? 'inactivity_timeout'
 665 |           : 'max_capture_time'
 666 |         : 'user_request',
 667 |       tabUrl: captureInfo.tabUrl,
 668 |       tabTitle: captureInfo.tabTitle,
 669 |     };
 670 | 
 671 |     console.log(
 672 |       `NetworkDebuggerStartTool: Capture stopped for tab ${tabId}. ${resultData.requestCount} requests processed. Limit reached: ${resultData.requestLimitReached}. Stopped by: ${resultData.stoppedBy}`,
 673 |     );
 674 | 
 675 |     this.cleanupCapture(tabId); // Final cleanup of all internal states for this tab
 676 | 
 677 |     return {
 678 |       success: true,
 679 |       message: `Capture stopped. ${resultData.requestCount} requests.`,
 680 |       data: resultData,
 681 |     };
 682 |   }
 683 | 
 684 |   private analyzeCommonHeaders(
 685 |     requests: NetworkRequestInfo[],
 686 |     headerTypeKey: 'requestHeaders' | 'responseHeaders',
 687 |   ): Record<string, string> {
 688 |     if (!requests || requests.length === 0) return {};
 689 | 
 690 |     const headerValueCounts = new Map<string, Map<string, number>>(); // headerName -> (headerValue -> count)
 691 |     let requestsWithHeadersCount = 0;
 692 | 
 693 |     for (const req of requests) {
 694 |       const headers = req[headerTypeKey] as Record<string, string> | undefined;
 695 |       if (headers && Object.keys(headers).length > 0) {
 696 |         requestsWithHeadersCount++;
 697 |         for (const name in headers) {
 698 |           // Normalize header name to lowercase for consistent counting
 699 |           const lowerName = name.toLowerCase();
 700 |           const value = headers[name];
 701 |           if (!headerValueCounts.has(lowerName)) {
 702 |             headerValueCounts.set(lowerName, new Map());
 703 |           }
 704 |           const values = headerValueCounts.get(lowerName)!;
 705 |           values.set(value, (values.get(value) || 0) + 1);
 706 |         }
 707 |       }
 708 |     }
 709 | 
 710 |     if (requestsWithHeadersCount === 0) return {};
 711 | 
 712 |     const commonHeaders: Record<string, string> = {};
 713 |     headerValueCounts.forEach((values, name) => {
 714 |       values.forEach((count, value) => {
 715 |         if (count === requestsWithHeadersCount) {
 716 |           // This (name, value) pair is present in all requests that have this type of headers.
 717 |           // We need to find the original casing for the header name.
 718 |           // This is tricky as HTTP headers are case-insensitive. Let's pick the first encountered one.
 719 |           // A more robust way would be to store original names, but lowercase comparison is standard.
 720 |           // For simplicity, we'll use the lowercase name for commonHeaders keys.
 721 |           // Or, find one original casing:
 722 |           let originalName = name;
 723 |           for (const req of requests) {
 724 |             const hdrs = req[headerTypeKey] as Record<string, string> | undefined;
 725 |             if (hdrs) {
 726 |               const foundName = Object.keys(hdrs).find((k) => k.toLowerCase() === name);
 727 |               if (foundName) {
 728 |                 originalName = foundName;
 729 |                 break;
 730 |               }
 731 |             }
 732 |           }
 733 |           commonHeaders[originalName] = value;
 734 |         }
 735 |       });
 736 |     });
 737 |     return commonHeaders;
 738 |   }
 739 | 
 740 |   private filterOutCommonHeaders(
 741 |     headers: Record<string, string>,
 742 |     commonHeaders: Record<string, string>,
 743 |   ): Record<string, string> {
 744 |     if (!headers || typeof headers !== 'object') return {};
 745 | 
 746 |     const specificHeaders: Record<string, string> = {};
 747 |     const commonHeadersLower: Record<string, string> = {};
 748 | 
 749 |     // Use Object.keys to avoid ESLint no-prototype-builtins warning
 750 |     Object.keys(commonHeaders).forEach((commonName) => {
 751 |       commonHeadersLower[commonName.toLowerCase()] = commonHeaders[commonName];
 752 |     });
 753 | 
 754 |     // Use Object.keys to avoid ESLint no-prototype-builtins warning
 755 |     Object.keys(headers).forEach((name) => {
 756 |       const lowerName = name.toLowerCase();
 757 |       // If the header (by name, case-insensitively) is not in commonHeaders OR
 758 |       // if its value is different from the common one, then it's specific.
 759 |       if (!(lowerName in commonHeadersLower) || headers[name] !== commonHeadersLower[lowerName]) {
 760 |         specificHeaders[name] = headers[name];
 761 |       }
 762 |     });
 763 | 
 764 |     return specificHeaders;
 765 |   }
 766 | 
 767 |   async execute(args: NetworkDebuggerStartToolParams): Promise<ToolResult> {
 768 |     const {
 769 |       url: targetUrl,
 770 |       maxCaptureTime = DEFAULT_MAX_CAPTURE_TIME_MS,
 771 |       inactivityTimeout = DEFAULT_INACTIVITY_TIMEOUT_MS,
 772 |       includeStatic = false,
 773 |     } = args;
 774 | 
 775 |     console.log(
 776 |       `NetworkDebuggerStartTool: Executing with args: url=${targetUrl}, maxTime=${maxCaptureTime}, inactivityTime=${inactivityTimeout}, includeStatic=${includeStatic}`,
 777 |     );
 778 | 
 779 |     let tabToOperateOn: chrome.tabs.Tab | undefined;
 780 | 
 781 |     try {
 782 |       if (targetUrl) {
 783 |         const existingTabs = await chrome.tabs.query({
 784 |           url: targetUrl.startsWith('http') ? targetUrl : `*://*/*${targetUrl}*`,
 785 |         }); // More specific query
 786 |         if (existingTabs.length > 0 && existingTabs[0]?.id) {
 787 |           tabToOperateOn = existingTabs[0];
 788 |           // Ensure window gets focus and tab is truly activated
 789 |           await chrome.windows.update(tabToOperateOn.windowId, { focused: true });
 790 |           await chrome.tabs.update(tabToOperateOn.id!, { active: true });
 791 |         } else {
 792 |           tabToOperateOn = await chrome.tabs.create({ url: targetUrl, active: true });
 793 |           // Wait for tab to be somewhat ready. A better way is to listen to tabs.onUpdated status='complete'
 794 |           // but for debugger attachment, it just needs the tabId.
 795 |           await new Promise((resolve) => setTimeout(resolve, 500)); // Short delay
 796 |         }
 797 |       } else {
 798 |         const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true });
 799 |         if (activeTabs.length > 0 && activeTabs[0]?.id) {
 800 |           tabToOperateOn = activeTabs[0];
 801 |         } else {
 802 |           return createErrorResponse('No active tab found and no URL provided.');
 803 |         }
 804 |       }
 805 | 
 806 |       if (!tabToOperateOn?.id) {
 807 |         return createErrorResponse('Failed to identify or create a target tab.');
 808 |       }
 809 |       const tabId = tabToOperateOn.id;
 810 | 
 811 |       // Use startCaptureForTab method to start capture
 812 |       try {
 813 |         await this.startCaptureForTab(tabId, {
 814 |           maxCaptureTime,
 815 |           inactivityTimeout,
 816 |           includeStatic,
 817 |         });
 818 |       } catch (error: any) {
 819 |         return createErrorResponse(
 820 |           `Failed to start capture for tab ${tabId}: ${error.message || String(error)}`,
 821 |         );
 822 |       }
 823 | 
 824 |       return {
 825 |         content: [
 826 |           {
 827 |             type: 'text',
 828 |             text: JSON.stringify({
 829 |               success: true,
 830 |               message: `Network capture started on tab ${tabId}. Waiting for stop command or timeout.`,
 831 |               tabId,
 832 |               url: tabToOperateOn.url,
 833 |               maxCaptureTime,
 834 |               inactivityTimeout,
 835 |               includeStatic,
 836 |               maxRequests: NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE,
 837 |             }),
 838 |           },
 839 |         ],
 840 |         isError: false,
 841 |       };
 842 |     } catch (error: any) {
 843 |       console.error('NetworkDebuggerStartTool: Critical error during execute:', error);
 844 |       // If a tabId was involved and debugger might be attached, try to clean up.
 845 |       const tabIdToClean = tabToOperateOn?.id;
 846 |       if (tabIdToClean && this.captureData.has(tabIdToClean)) {
 847 |         await cdpSessionManager
 848 |           .detach(tabIdToClean, 'network-capture')
 849 |           .catch((e) => console.warn('Cleanup detach error:', e));
 850 |         this.cleanupCapture(tabIdToClean);
 851 |       }
 852 |       return createErrorResponse(
 853 |         `Error in NetworkDebuggerStartTool: ${error.message || String(error)}`,
 854 |       );
 855 |     }
 856 |   }
 857 | }
 858 | 
 859 | /**
 860 |  * Network capture stop tool - stops capture and returns results for the active tab
 861 |  */
 862 | class NetworkDebuggerStopTool extends BaseBrowserToolExecutor {
 863 |   name = TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP;
 864 |   public static instance: NetworkDebuggerStopTool | null = null;
 865 | 
 866 |   constructor() {
 867 |     super();
 868 |     if (NetworkDebuggerStopTool.instance) {
 869 |       return NetworkDebuggerStopTool.instance;
 870 |     }
 871 |     NetworkDebuggerStopTool.instance = this;
 872 |   }
 873 | 
 874 |   async execute(): Promise<ToolResult> {
 875 |     console.log(`NetworkDebuggerStopTool: Executing command.`);
 876 | 
 877 |     const startTool = NetworkDebuggerStartTool.instance;
 878 |     if (!startTool) {
 879 |       return createErrorResponse(
 880 |         'NetworkDebuggerStartTool instance not available. Cannot stop capture.',
 881 |       );
 882 |     }
 883 | 
 884 |     // Get all tabs currently capturing
 885 |     const ongoingCaptures = Array.from(startTool['captureData'].keys());
 886 |     console.log(
 887 |       `NetworkDebuggerStopTool: Found ${ongoingCaptures.length} ongoing captures: ${ongoingCaptures.join(', ')}`,
 888 |     );
 889 | 
 890 |     if (ongoingCaptures.length === 0) {
 891 |       return createErrorResponse('No active network captures found in any tab.');
 892 |     }
 893 | 
 894 |     // Get current active tab
 895 |     const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true });
 896 |     const activeTabId = activeTabs[0]?.id;
 897 | 
 898 |     // Determine the primary tab to stop
 899 |     let primaryTabId: number;
 900 | 
 901 |     if (activeTabId && startTool['captureData'].has(activeTabId)) {
 902 |       // If current active tab is capturing, prioritize stopping it
 903 |       primaryTabId = activeTabId;
 904 |       console.log(
 905 |         `NetworkDebuggerStopTool: Active tab ${activeTabId} is capturing, will stop it first.`,
 906 |       );
 907 |     } else if (ongoingCaptures.length === 1) {
 908 |       // If only one tab is capturing, stop it
 909 |       primaryTabId = ongoingCaptures[0];
 910 |       console.log(
 911 |         `NetworkDebuggerStopTool: Only one tab ${primaryTabId} is capturing, stopping it.`,
 912 |       );
 913 |     } else {
 914 |       // If multiple tabs are capturing but current active tab is not among them, stop the first one
 915 |       primaryTabId = ongoingCaptures[0];
 916 |       console.log(
 917 |         `NetworkDebuggerStopTool: Multiple tabs capturing, active tab not among them. Stopping tab ${primaryTabId} first.`,
 918 |       );
 919 |     }
 920 | 
 921 |     // Stop capture for the primary tab
 922 |     const result = await this.performStop(startTool, primaryTabId);
 923 | 
 924 |     // If multiple tabs are capturing, stop other tabs
 925 |     if (ongoingCaptures.length > 1) {
 926 |       const otherTabIds = ongoingCaptures.filter((id) => id !== primaryTabId);
 927 |       console.log(
 928 |         `NetworkDebuggerStopTool: Stopping ${otherTabIds.length} additional captures: ${otherTabIds.join(', ')}`,
 929 |       );
 930 | 
 931 |       for (const tabId of otherTabIds) {
 932 |         try {
 933 |           await startTool.stopCapture(tabId);
 934 |         } catch (error) {
 935 |           console.error(`NetworkDebuggerStopTool: Error stopping capture on tab ${tabId}:`, error);
 936 |         }
 937 |       }
 938 |     }
 939 | 
 940 |     return result;
 941 |   }
 942 | 
 943 |   private async performStop(
 944 |     startTool: NetworkDebuggerStartTool,
 945 |     tabId: number,
 946 |   ): Promise<ToolResult> {
 947 |     console.log(`NetworkDebuggerStopTool: Attempting to stop capture for tab ${tabId}.`);
 948 |     const stopResult = await startTool.stopCapture(tabId);
 949 | 
 950 |     if (!stopResult?.success) {
 951 |       return createErrorResponse(
 952 |         stopResult?.message ||
 953 |           `Failed to stop network capture for tab ${tabId}. It might not have been capturing.`,
 954 |       );
 955 |     }
 956 | 
 957 |     const resultData = stopResult.data || {};
 958 | 
 959 |     // Get all tabs still capturing (there might be other tabs still capturing after stopping)
 960 |     const remainingCaptures = Array.from(startTool['captureData'].keys());
 961 | 
 962 |     // Sort requests by time
 963 |     if (resultData.requests && Array.isArray(resultData.requests)) {
 964 |       resultData.requests.sort(
 965 |         (a: NetworkRequestInfo, b: NetworkRequestInfo) =>
 966 |           (a.requestTime || 0) - (b.requestTime || 0),
 967 |       );
 968 |     }
 969 | 
 970 |     return {
 971 |       content: [
 972 |         {
 973 |           type: 'text',
 974 |           text: JSON.stringify({
 975 |             success: true,
 976 |             message: `Capture for tab ${tabId} (${resultData.tabUrl || 'N/A'}) stopped. ${resultData.requestCount || 0} requests captured.`,
 977 |             tabId: tabId,
 978 |             tabUrl: resultData.tabUrl || 'N/A',
 979 |             tabTitle: resultData.tabTitle || 'Unknown Tab',
 980 |             requestCount: resultData.requestCount || 0,
 981 |             commonRequestHeaders: resultData.commonRequestHeaders || {},
 982 |             commonResponseHeaders: resultData.commonResponseHeaders || {},
 983 |             requests: resultData.requests || [],
 984 |             captureStartTime: resultData.captureStartTime,
 985 |             captureEndTime: resultData.captureEndTime,
 986 |             totalDurationMs: resultData.totalDurationMs,
 987 |             settingsUsed: resultData.settingsUsed || {},
 988 |             remainingCaptures: remainingCaptures,
 989 |             totalRequestsReceived: resultData.totalRequestsReceived || resultData.requestCount || 0,
 990 |             requestLimitReached: resultData.requestLimitReached || false,
 991 |           }),
 992 |         },
 993 |       ],
 994 |       isError: false,
 995 |     };
 996 |   }
 997 | }
 998 | 
 999 | export const networkDebuggerStartTool = new NetworkDebuggerStartTool();
1000 | export const networkDebuggerStopTool = new NetworkDebuggerStopTool();
1001 | 
```

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

```typescript
   1 | /**
   2 |  * GIF Recorder Tool
   3 |  *
   4 |  * Records browser tab activity as an animated GIF.
   5 |  *
   6 |  * Features:
   7 |  * - Two recording modes:
   8 |  *   1. Fixed FPS mode (start): Captures frames at regular intervals
   9 |  *   2. Auto-capture mode (auto_start): Captures frames on tool actions
  10 |  * - Configurable frame rate, duration, and dimensions
  11 |  * - Quality/size optimization options
  12 |  * - CDP-based screenshot capture for background recording
  13 |  * - Offscreen document encoding via gifenc
  14 |  */
  15 | 
  16 | import { createErrorResponse, ToolResult } from '@/common/tool-handler';
  17 | import { BaseBrowserToolExecutor } from '../base-browser';
  18 | import { TOOL_NAMES } from 'chrome-mcp-shared';
  19 | import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
  20 | import {
  21 |   MessageTarget,
  22 |   OFFSCREEN_MESSAGE_TYPES,
  23 |   OffscreenMessageType,
  24 | } from '@/common/message-types';
  25 | import { cdpSessionManager } from '@/utils/cdp-session-manager';
  26 | import { offscreenManager } from '@/utils/offscreen-manager';
  27 | import { createImageBitmapFromUrl } from '@/utils/image-utils';
  28 | import {
  29 |   startAutoCapture,
  30 |   stopAutoCapture,
  31 |   isAutoCaptureActive,
  32 |   getAutoCaptureStatus,
  33 |   captureFrameOnAction,
  34 |   captureInitialFrame,
  35 |   type ActionMetadata,
  36 |   type GifEnhancedRenderingConfig,
  37 | } from './gif-auto-capture';
  38 | 
  39 | // ============================================================================
  40 | // Constants
  41 | // ============================================================================
  42 | 
  43 | const DEFAULT_FPS = 5;
  44 | const DEFAULT_DURATION_MS = 5000;
  45 | const DEFAULT_MAX_FRAMES = 50;
  46 | const DEFAULT_WIDTH = 800;
  47 | const DEFAULT_HEIGHT = 600;
  48 | const DEFAULT_MAX_COLORS = 256;
  49 | const CDP_SESSION_KEY = 'gif-recorder';
  50 | 
  51 | // ============================================================================
  52 | // Types
  53 | // ============================================================================
  54 | 
  55 | type GifRecorderAction =
  56 |   | 'start'
  57 |   | 'stop'
  58 |   | 'status'
  59 |   | 'auto_start'
  60 |   | 'capture'
  61 |   | 'clear'
  62 |   | 'export';
  63 | 
  64 | interface GifRecorderParams {
  65 |   action: GifRecorderAction;
  66 |   tabId?: number;
  67 |   fps?: number;
  68 |   durationMs?: number;
  69 |   maxFrames?: number;
  70 |   width?: number;
  71 |   height?: number;
  72 |   maxColors?: number;
  73 |   filename?: string;
  74 |   // Auto-capture mode specific
  75 |   captureDelayMs?: number;
  76 |   frameDelayCs?: number;
  77 |   enhancedRendering?: GifEnhancedRenderingConfig;
  78 |   // Manual annotation for action="capture"
  79 |   annotation?: string;
  80 |   // Export action specific
  81 |   download?: boolean; // true to download, false to upload via drag&drop
  82 |   coordinates?: { x: number; y: number }; // target position for drag&drop upload
  83 |   ref?: string; // element ref for drag&drop upload (alternative to coordinates)
  84 |   selector?: string; // CSS selector for drag&drop upload (alternative to coordinates)
  85 | }
  86 | 
  87 | interface RecordingState {
  88 |   isRecording: boolean;
  89 |   isStopping: boolean;
  90 |   tabId: number;
  91 |   width: number;
  92 |   height: number;
  93 |   fps: number;
  94 |   durationMs: number;
  95 |   frameIntervalMs: number;
  96 |   frameDelayCs: number;
  97 |   maxFrames: number;
  98 |   maxColors: number;
  99 |   frameCount: number;
 100 |   startTime: number;
 101 |   captureTimer: ReturnType<typeof setTimeout> | null;
 102 |   captureInProgress: Promise<void> | null;
 103 |   canvas: OffscreenCanvas;
 104 |   ctx: OffscreenCanvasRenderingContext2D;
 105 |   filename?: string;
 106 | }
 107 | 
 108 | interface GifResult {
 109 |   success: boolean;
 110 |   action: GifRecorderAction;
 111 |   tabId?: number;
 112 |   frameCount?: number;
 113 |   durationMs?: number;
 114 |   byteLength?: number;
 115 |   downloadId?: number;
 116 |   filename?: string;
 117 |   fullPath?: string;
 118 |   isRecording?: boolean;
 119 |   mode?: 'fixed_fps' | 'auto_capture';
 120 |   actionsCount?: number;
 121 |   error?: string;
 122 |   // Clear action specific
 123 |   clearedAutoCapture?: boolean;
 124 |   clearedFixedFps?: boolean;
 125 |   clearedCache?: boolean;
 126 |   // Export action specific (drag&drop upload)
 127 |   uploadTarget?: {
 128 |     x: number;
 129 |     y: number;
 130 |     tagName?: string;
 131 |     id?: string;
 132 |   };
 133 | }
 134 | 
 135 | // ============================================================================
 136 | // Recording State Management
 137 | // ============================================================================
 138 | 
 139 | let recordingState: RecordingState | null = null;
 140 | let stopPromise: Promise<GifResult> | null = null;
 141 | 
 142 | // Auto-capture mode state
 143 | interface AutoCaptureMetadata {
 144 |   tabId: number;
 145 |   filename?: string;
 146 | }
 147 | let autoCaptureMetadata: AutoCaptureMetadata | null = null;
 148 | 
 149 | // Last recorded GIF cache for export
 150 | interface ExportableGif {
 151 |   gifData: Uint8Array;
 152 |   width: number;
 153 |   height: number;
 154 |   frameCount: number;
 155 |   durationMs: number;
 156 |   tabId: number;
 157 |   filename?: string;
 158 |   actionsCount?: number;
 159 |   mode: 'fixed_fps' | 'auto_capture';
 160 |   createdAt: number;
 161 | }
 162 | let lastRecordedGif: ExportableGif | null = null;
 163 | 
 164 | // Maximum cache lifetime for exportable GIF (5 minutes)
 165 | const EXPORT_CACHE_LIFETIME_MS = 5 * 60 * 1000;
 166 | 
 167 | // ============================================================================
 168 | // Offscreen Document Communication
 169 | // ============================================================================
 170 | 
 171 | type OffscreenResponseBase = { success: boolean; error?: string };
 172 | 
 173 | async function sendToOffscreen<TResponse extends OffscreenResponseBase>(
 174 |   type: OffscreenMessageType,
 175 |   payload: Record<string, unknown> = {},
 176 | ): Promise<TResponse> {
 177 |   await offscreenManager.ensureOffscreenDocument();
 178 | 
 179 |   let lastError: unknown;
 180 |   for (let attempt = 1; attempt <= 3; attempt++) {
 181 |     try {
 182 |       const response = (await chrome.runtime.sendMessage({
 183 |         target: MessageTarget.Offscreen,
 184 |         type,
 185 |         ...payload,
 186 |       })) as TResponse | undefined;
 187 | 
 188 |       if (!response) {
 189 |         throw new Error('No response received from offscreen document');
 190 |       }
 191 |       if (!response.success) {
 192 |         throw new Error(response.error || 'Unknown offscreen error');
 193 |       }
 194 | 
 195 |       return response;
 196 |     } catch (error) {
 197 |       lastError = error;
 198 |       if (attempt < 3) {
 199 |         await new Promise((resolve) => setTimeout(resolve, 50 * attempt));
 200 |         continue;
 201 |       }
 202 |       throw error;
 203 |     }
 204 |   }
 205 | 
 206 |   throw lastError instanceof Error ? lastError : new Error(String(lastError));
 207 | }
 208 | 
 209 | // ============================================================================
 210 | // Frame Capture
 211 | // ============================================================================
 212 | 
 213 | async function captureFrame(
 214 |   tabId: number,
 215 |   width: number,
 216 |   height: number,
 217 |   ctx: OffscreenCanvasRenderingContext2D,
 218 | ): Promise<Uint8ClampedArray> {
 219 |   // Get viewport metrics
 220 |   const metrics: { layoutViewport?: { clientWidth: number; clientHeight: number } } =
 221 |     await cdpSessionManager.sendCommand(tabId, 'Page.getLayoutMetrics', {});
 222 | 
 223 |   const viewportWidth = metrics.layoutViewport?.clientWidth || width;
 224 |   const viewportHeight = metrics.layoutViewport?.clientHeight || height;
 225 | 
 226 |   // Capture screenshot
 227 |   const screenshot: { data: string } = await cdpSessionManager.sendCommand(
 228 |     tabId,
 229 |     'Page.captureScreenshot',
 230 |     {
 231 |       format: 'png',
 232 |       clip: {
 233 |         x: 0,
 234 |         y: 0,
 235 |         width: viewportWidth,
 236 |         height: viewportHeight,
 237 |         scale: 1,
 238 |       },
 239 |     },
 240 |   );
 241 | 
 242 |   const imageBitmap = await createImageBitmapFromUrl(`data:image/png;base64,${screenshot.data}`);
 243 | 
 244 |   // Scale image to target dimensions
 245 |   ctx.clearRect(0, 0, width, height);
 246 |   ctx.drawImage(imageBitmap, 0, 0, width, height);
 247 |   imageBitmap.close();
 248 | 
 249 |   const imageData = ctx.getImageData(0, 0, width, height);
 250 |   return imageData.data;
 251 | }
 252 | 
 253 | async function captureAndEncodeFrame(state: RecordingState): Promise<void> {
 254 |   const frameData = await captureFrame(state.tabId, state.width, state.height, state.ctx);
 255 | 
 256 |   await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, {
 257 |     imageData: Array.from(frameData),
 258 |     width: state.width,
 259 |     height: state.height,
 260 |     delay: state.frameDelayCs,
 261 |     maxColors: state.maxColors,
 262 |   });
 263 | 
 264 |   if (recordingState === state && state.isRecording && !state.isStopping) {
 265 |     state.frameCount += 1;
 266 |   }
 267 | }
 268 | 
 269 | async function captureTick(state: RecordingState): Promise<void> {
 270 |   if (recordingState !== state || !state.isRecording || state.isStopping) {
 271 |     return;
 272 |   }
 273 | 
 274 |   const elapsed = Date.now() - state.startTime;
 275 |   if (elapsed >= state.durationMs || state.frameCount >= state.maxFrames) {
 276 |     await stopRecording();
 277 |     return;
 278 |   }
 279 | 
 280 |   const startedAt = Date.now();
 281 |   state.captureInProgress = captureAndEncodeFrame(state);
 282 | 
 283 |   try {
 284 |     await state.captureInProgress;
 285 |   } catch (error) {
 286 |     console.error('Frame capture error:', error);
 287 |   } finally {
 288 |     if (recordingState === state) {
 289 |       state.captureInProgress = null;
 290 |     }
 291 |   }
 292 | 
 293 |   if (recordingState !== state || !state.isRecording || state.isStopping) {
 294 |     return;
 295 |   }
 296 | 
 297 |   const elapsedAfter = Date.now() - state.startTime;
 298 |   if (elapsedAfter >= state.durationMs || state.frameCount >= state.maxFrames) {
 299 |     await stopRecording();
 300 |     return;
 301 |   }
 302 | 
 303 |   const delayMs = Math.max(0, state.frameIntervalMs - (Date.now() - startedAt));
 304 |   state.captureTimer = setTimeout(() => {
 305 |     void captureTick(state).catch((error) => {
 306 |       console.error('GIF recorder tick error:', error);
 307 |     });
 308 |   }, delayMs);
 309 | }
 310 | 
 311 | // ============================================================================
 312 | // Recording Control
 313 | // ============================================================================
 314 | 
 315 | async function startRecording(
 316 |   tabId: number,
 317 |   fps: number,
 318 |   durationMs: number,
 319 |   maxFrames: number,
 320 |   width: number,
 321 |   height: number,
 322 |   maxColors: number,
 323 |   filename?: string,
 324 | ): Promise<GifResult> {
 325 |   if (stopPromise || recordingState?.isRecording || recordingState?.isStopping) {
 326 |     return {
 327 |       success: false,
 328 |       action: 'start',
 329 |       error: 'Recording already in progress',
 330 |     };
 331 |   }
 332 | 
 333 |   try {
 334 |     await cdpSessionManager.attach(tabId, CDP_SESSION_KEY);
 335 |   } catch (error) {
 336 |     return {
 337 |       success: false,
 338 |       action: 'start',
 339 |       error: error instanceof Error ? error.message : String(error),
 340 |     };
 341 |   }
 342 | 
 343 |   try {
 344 |     await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {});
 345 | 
 346 |     if (typeof OffscreenCanvas === 'undefined') {
 347 |       throw new Error('OffscreenCanvas not available in this context');
 348 |     }
 349 | 
 350 |     const canvas = new OffscreenCanvas(width, height);
 351 |     const ctx = canvas.getContext('2d');
 352 |     if (!ctx) {
 353 |       throw new Error('Failed to get canvas context');
 354 |     }
 355 | 
 356 |     const frameIntervalMs = Math.round(1000 / fps);
 357 |     const frameDelayCs = Math.max(1, Math.round(100 / fps));
 358 | 
 359 |     const state: RecordingState = {
 360 |       isRecording: true,
 361 |       isStopping: false,
 362 |       tabId,
 363 |       width,
 364 |       height,
 365 |       fps,
 366 |       durationMs,
 367 |       frameIntervalMs,
 368 |       frameDelayCs,
 369 |       maxFrames,
 370 |       maxColors,
 371 |       frameCount: 0,
 372 |       startTime: Date.now(),
 373 |       captureTimer: null,
 374 |       captureInProgress: null,
 375 |       canvas,
 376 |       ctx,
 377 |       filename,
 378 |     };
 379 | 
 380 |     recordingState = state;
 381 | 
 382 |     // Capture first frame eagerly so start() fails fast if capture/encoding is broken
 383 |     await captureAndEncodeFrame(state);
 384 | 
 385 |     state.captureTimer = setTimeout(() => {
 386 |       void captureTick(state).catch((error) => {
 387 |         console.error('GIF recorder tick error:', error);
 388 |       });
 389 |     }, frameIntervalMs);
 390 | 
 391 |     return {
 392 |       success: true,
 393 |       action: 'start',
 394 |       tabId,
 395 |       isRecording: true,
 396 |     };
 397 |   } catch (error) {
 398 |     recordingState = null;
 399 |     try {
 400 |       await cdpSessionManager.detach(tabId, CDP_SESSION_KEY);
 401 |     } catch {
 402 |       // ignore
 403 |     }
 404 |     return {
 405 |       success: false,
 406 |       action: 'start',
 407 |       error: error instanceof Error ? error.message : String(error),
 408 |     };
 409 |   }
 410 | }
 411 | 
 412 | async function stopRecording(): Promise<GifResult> {
 413 |   if (stopPromise) {
 414 |     return stopPromise;
 415 |   }
 416 | 
 417 |   if (!recordingState || (!recordingState.isRecording && !recordingState.isStopping)) {
 418 |     return {
 419 |       success: false,
 420 |       action: 'stop',
 421 |       error: 'No recording in progress',
 422 |     };
 423 |   }
 424 | 
 425 |   stopPromise = (async () => {
 426 |     const state = recordingState!;
 427 |     const tabId = state.tabId;
 428 | 
 429 |     // Stop capture timer
 430 |     if (state.captureTimer) {
 431 |       clearTimeout(state.captureTimer);
 432 |       state.captureTimer = null;
 433 |     }
 434 | 
 435 |     state.isStopping = true;
 436 |     state.isRecording = false;
 437 | 
 438 |     try {
 439 |       await state.captureInProgress;
 440 |     } catch {
 441 |       // ignore
 442 |     }
 443 | 
 444 |     // Best-effort final frame capture to preserve end state
 445 |     try {
 446 |       const frameData = await captureFrame(state.tabId, state.width, state.height, state.ctx);
 447 |       await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, {
 448 |         imageData: Array.from(frameData),
 449 |         width: state.width,
 450 |         height: state.height,
 451 |         delay: state.frameDelayCs,
 452 |         maxColors: state.maxColors,
 453 |       });
 454 |       state.frameCount += 1;
 455 |     } catch (error) {
 456 |       console.warn('GIF recorder: Final frame capture error (non-fatal):', error);
 457 |     }
 458 | 
 459 |     const frameCount = state.frameCount;
 460 |     const durationMs = Date.now() - state.startTime;
 461 |     const filename = state.filename;
 462 | 
 463 |     try {
 464 |       if (frameCount <= 0) {
 465 |         try {
 466 |           await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {});
 467 |         } catch {
 468 |           // ignore
 469 |         }
 470 |         return {
 471 |           success: false,
 472 |           action: 'stop' as const,
 473 |           tabId,
 474 |           frameCount,
 475 |           durationMs,
 476 |           error: 'No frames captured',
 477 |         };
 478 |       }
 479 | 
 480 |       const response = await sendToOffscreen<{
 481 |         success: boolean;
 482 |         gifData?: number[];
 483 |         byteLength?: number;
 484 |       }>(OFFSCREEN_MESSAGE_TYPES.GIF_FINISH, {});
 485 | 
 486 |       if (!response.gifData || response.gifData.length === 0) {
 487 |         return {
 488 |           success: false,
 489 |           action: 'stop' as const,
 490 |           tabId,
 491 |           frameCount,
 492 |           durationMs,
 493 |           error: 'No frames captured',
 494 |         };
 495 |       }
 496 | 
 497 |       // Convert to Uint8Array and create blob
 498 |       const gifBytes = new Uint8Array(response.gifData);
 499 | 
 500 |       // Cache for later export
 501 |       lastRecordedGif = {
 502 |         gifData: gifBytes,
 503 |         width: state.width,
 504 |         height: state.height,
 505 |         frameCount,
 506 |         durationMs,
 507 |         tabId,
 508 |         filename,
 509 |         mode: 'fixed_fps',
 510 |         createdAt: Date.now(),
 511 |       };
 512 | 
 513 |       const blob = new Blob([gifBytes], { type: 'image/gif' });
 514 |       const dataUrl = await blobToDataUrl(blob);
 515 | 
 516 |       // Save GIF file
 517 |       const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
 518 |       const outputFilename = filename?.replace(/[^a-z0-9_-]/gi, '_') || `recording_${timestamp}`;
 519 |       const fullFilename = outputFilename.endsWith('.gif')
 520 |         ? outputFilename
 521 |         : `${outputFilename}.gif`;
 522 | 
 523 |       const downloadId = await chrome.downloads.download({
 524 |         url: dataUrl,
 525 |         filename: fullFilename,
 526 |         saveAs: false,
 527 |       });
 528 | 
 529 |       // Wait briefly to get download info
 530 |       await new Promise((resolve) => setTimeout(resolve, 100));
 531 | 
 532 |       let fullPath: string | undefined;
 533 |       try {
 534 |         const [downloadItem] = await chrome.downloads.search({ id: downloadId });
 535 |         fullPath = downloadItem?.filename;
 536 |       } catch {
 537 |         // Ignore path lookup errors
 538 |       }
 539 | 
 540 |       return {
 541 |         success: true,
 542 |         action: 'stop' as const,
 543 |         tabId,
 544 |         frameCount,
 545 |         durationMs,
 546 |         byteLength: response.byteLength ?? gifBytes.byteLength,
 547 |         downloadId,
 548 |         filename: fullFilename,
 549 |         fullPath,
 550 |       };
 551 |     } catch (error) {
 552 |       return {
 553 |         success: false,
 554 |         action: 'stop' as const,
 555 |         error: error instanceof Error ? error.message : String(error),
 556 |       };
 557 |     } finally {
 558 |       try {
 559 |         await cdpSessionManager.detach(tabId, CDP_SESSION_KEY);
 560 |       } catch {
 561 |         // ignore
 562 |       }
 563 |       recordingState = null;
 564 |     }
 565 |   })();
 566 | 
 567 |   return await stopPromise.finally(() => {
 568 |     stopPromise = null;
 569 |   });
 570 | }
 571 | 
 572 | function getRecordingStatus(): GifResult {
 573 |   if (!recordingState) {
 574 |     return {
 575 |       success: true,
 576 |       action: 'status',
 577 |       isRecording: false,
 578 |     };
 579 |   }
 580 | 
 581 |   return {
 582 |     success: true,
 583 |     action: 'status',
 584 |     isRecording: recordingState.isRecording,
 585 |     tabId: recordingState.tabId,
 586 |     frameCount: recordingState.frameCount,
 587 |     durationMs: Date.now() - recordingState.startTime,
 588 |   };
 589 | }
 590 | 
 591 | // ============================================================================
 592 | // Utilities
 593 | // ============================================================================
 594 | 
 595 | function blobToDataUrl(blob: Blob): Promise<string> {
 596 |   return new Promise((resolve, reject) => {
 597 |     const reader = new FileReader();
 598 |     reader.onload = () => resolve(reader.result as string);
 599 |     reader.onerror = () => reject(new Error('Failed to read blob'));
 600 |     reader.readAsDataURL(blob);
 601 |   });
 602 | }
 603 | 
 604 | function normalizePositiveInt(value: unknown, fallback: number, max?: number): number {
 605 |   if (typeof value !== 'number' || !Number.isFinite(value)) {
 606 |     return fallback;
 607 |   }
 608 |   const result = Math.max(1, Math.floor(value));
 609 |   return max !== undefined ? Math.min(result, max) : result;
 610 | }
 611 | 
 612 | // ============================================================================
 613 | // Tool Implementation
 614 | // ============================================================================
 615 | 
 616 | class GifRecorderTool extends BaseBrowserToolExecutor {
 617 |   name = TOOL_NAMES.BROWSER.GIF_RECORDER;
 618 | 
 619 |   async execute(args: GifRecorderParams): Promise<ToolResult> {
 620 |     const action = args.action;
 621 |     const validActions = ['start', 'stop', 'status', 'auto_start', 'capture', 'clear', 'export'];
 622 | 
 623 |     if (!action || !validActions.includes(action)) {
 624 |       return createErrorResponse(
 625 |         `Parameter [action] is required and must be one of: ${validActions.join(', ')}`,
 626 |       );
 627 |     }
 628 | 
 629 |     try {
 630 |       switch (action) {
 631 |         case 'start': {
 632 |           // Fixed-FPS mode: captures frames at regular intervals
 633 |           const tab = await this.resolveTargetTab(args.tabId);
 634 |           if (!tab?.id) {
 635 |             return createErrorResponse(
 636 |               typeof args.tabId === 'number'
 637 |                 ? `Tab not found: ${args.tabId}`
 638 |                 : 'No active tab found',
 639 |             );
 640 |           }
 641 | 
 642 |           if (this.isRestrictedUrl(tab.url)) {
 643 |             return createErrorResponse(
 644 |               'Cannot record special browser pages or web store pages due to security restrictions.',
 645 |             );
 646 |           }
 647 | 
 648 |           // Check if auto-capture is active
 649 |           if (isAutoCaptureActive(tab.id)) {
 650 |             return createErrorResponse(
 651 |               'Auto-capture mode is active for this tab. Use action="stop" to stop it first.',
 652 |             );
 653 |           }
 654 | 
 655 |           const fps = normalizePositiveInt(args.fps, DEFAULT_FPS, 30);
 656 |           const durationMs = normalizePositiveInt(args.durationMs, DEFAULT_DURATION_MS, 60000);
 657 |           const maxFrames = normalizePositiveInt(args.maxFrames, DEFAULT_MAX_FRAMES, 300);
 658 |           const width = normalizePositiveInt(args.width, DEFAULT_WIDTH, 1920);
 659 |           const height = normalizePositiveInt(args.height, DEFAULT_HEIGHT, 1080);
 660 |           const maxColors = normalizePositiveInt(args.maxColors, DEFAULT_MAX_COLORS, 256);
 661 | 
 662 |           const result = await startRecording(
 663 |             tab.id,
 664 |             fps,
 665 |             durationMs,
 666 |             maxFrames,
 667 |             width,
 668 |             height,
 669 |             maxColors,
 670 |             args.filename,
 671 |           );
 672 | 
 673 |           if (result.success) {
 674 |             result.mode = 'fixed_fps';
 675 |           }
 676 | 
 677 |           return this.buildResponse(result);
 678 |         }
 679 | 
 680 |         case 'auto_start': {
 681 |           // Auto-capture mode: captures frames when tools succeed
 682 |           const tab = await this.resolveTargetTab(args.tabId);
 683 |           if (!tab?.id) {
 684 |             return createErrorResponse(
 685 |               typeof args.tabId === 'number'
 686 |                 ? `Tab not found: ${args.tabId}`
 687 |                 : 'No active tab found',
 688 |             );
 689 |           }
 690 | 
 691 |           if (this.isRestrictedUrl(tab.url)) {
 692 |             return createErrorResponse(
 693 |               'Cannot record special browser pages or web store pages due to security restrictions.',
 694 |             );
 695 |           }
 696 | 
 697 |           // Check if fixed-FPS recording is active
 698 |           if (recordingState?.isRecording && recordingState.tabId === tab.id) {
 699 |             return createErrorResponse(
 700 |               'Fixed-FPS recording is active for this tab. Use action="stop" to stop it first.',
 701 |             );
 702 |           }
 703 | 
 704 |           // Check if auto-capture is already active
 705 |           if (isAutoCaptureActive(tab.id)) {
 706 |             return createErrorResponse('Auto-capture is already active for this tab.');
 707 |           }
 708 | 
 709 |           const width = normalizePositiveInt(args.width, DEFAULT_WIDTH, 1920);
 710 |           const height = normalizePositiveInt(args.height, DEFAULT_HEIGHT, 1080);
 711 |           const maxColors = normalizePositiveInt(args.maxColors, DEFAULT_MAX_COLORS, 256);
 712 |           const maxFrames = normalizePositiveInt(args.maxFrames, 100, 300);
 713 |           const captureDelayMs = normalizePositiveInt(args.captureDelayMs, 150, 2000);
 714 |           const frameDelayCs = normalizePositiveInt(args.frameDelayCs, 20, 100);
 715 | 
 716 |           const startResult = await startAutoCapture(tab.id, {
 717 |             width,
 718 |             height,
 719 |             maxColors,
 720 |             maxFrames,
 721 |             captureDelayMs,
 722 |             frameDelayCs,
 723 |             enhancedRendering: args.enhancedRendering,
 724 |           });
 725 | 
 726 |           if (!startResult.success) {
 727 |             return this.buildResponse({
 728 |               success: false,
 729 |               action: 'auto_start',
 730 |               tabId: tab.id,
 731 |               error: startResult.error,
 732 |             });
 733 |           }
 734 | 
 735 |           // Store metadata for stop
 736 |           autoCaptureMetadata = {
 737 |             tabId: tab.id,
 738 |             filename: args.filename,
 739 |           };
 740 | 
 741 |           // Capture initial frame
 742 |           await captureInitialFrame(tab.id);
 743 | 
 744 |           return this.buildResponse({
 745 |             success: true,
 746 |             action: 'auto_start',
 747 |             tabId: tab.id,
 748 |             mode: 'auto_capture',
 749 |             isRecording: true,
 750 |           });
 751 |         }
 752 | 
 753 |         case 'capture': {
 754 |           // Manual frame capture in auto mode
 755 |           const tab = await this.resolveTargetTab(args.tabId);
 756 |           if (!tab?.id) {
 757 |             return createErrorResponse(
 758 |               typeof args.tabId === 'number'
 759 |                 ? `Tab not found: ${args.tabId}`
 760 |                 : 'No active tab found',
 761 |             );
 762 |           }
 763 | 
 764 |           if (!isAutoCaptureActive(tab.id)) {
 765 |             return createErrorResponse(
 766 |               'Auto-capture is not active for this tab. Use action="auto_start" first.',
 767 |             );
 768 |           }
 769 | 
 770 |           // Support optional annotation for manual captures
 771 |           const annotation =
 772 |             typeof args.annotation === 'string' && args.annotation.trim().length > 0
 773 |               ? args.annotation.trim()
 774 |               : undefined;
 775 | 
 776 |           const action: ActionMetadata | undefined = annotation
 777 |             ? { type: 'annotation', label: annotation }
 778 |             : undefined;
 779 | 
 780 |           const captureResult = await captureFrameOnAction(tab.id, action, true);
 781 | 
 782 |           return this.buildResponse({
 783 |             success: captureResult.success,
 784 |             action: 'capture',
 785 |             tabId: tab.id,
 786 |             frameCount: captureResult.frameNumber,
 787 |             error: captureResult.error,
 788 |           });
 789 |         }
 790 | 
 791 |         case 'stop': {
 792 |           // Stop either mode
 793 |           // Check auto-capture first
 794 |           const autoTab = autoCaptureMetadata?.tabId;
 795 |           if (autoTab !== undefined && isAutoCaptureActive(autoTab)) {
 796 |             const stopResult = await stopAutoCapture(autoTab);
 797 |             const filename = autoCaptureMetadata?.filename;
 798 |             autoCaptureMetadata = null;
 799 | 
 800 |             if (!stopResult.success || !stopResult.gifData) {
 801 |               return this.buildResponse({
 802 |                 success: false,
 803 |                 action: 'stop',
 804 |                 tabId: autoTab,
 805 |                 mode: 'auto_capture',
 806 |                 frameCount: stopResult.frameCount,
 807 |                 durationMs: stopResult.durationMs,
 808 |                 actionsCount: stopResult.actions?.length,
 809 |                 error: stopResult.error || 'No GIF data generated',
 810 |               });
 811 |             }
 812 | 
 813 |             // Cache for later export
 814 |             lastRecordedGif = {
 815 |               gifData: stopResult.gifData,
 816 |               width: DEFAULT_WIDTH, // auto mode uses default dimensions
 817 |               height: DEFAULT_HEIGHT,
 818 |               frameCount: stopResult.frameCount ?? 0,
 819 |               durationMs: stopResult.durationMs ?? 0,
 820 |               tabId: autoTab,
 821 |               filename,
 822 |               actionsCount: stopResult.actions?.length,
 823 |               mode: 'auto_capture',
 824 |               createdAt: Date.now(),
 825 |             };
 826 | 
 827 |             // Save GIF file
 828 |             const blob = new Blob([stopResult.gifData], { type: 'image/gif' });
 829 |             const dataUrl = await blobToDataUrl(blob);
 830 | 
 831 |             const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
 832 |             const outputFilename =
 833 |               filename?.replace(/[^a-z0-9_-]/gi, '_') || `recording_${timestamp}`;
 834 |             const fullFilename = outputFilename.endsWith('.gif')
 835 |               ? outputFilename
 836 |               : `${outputFilename}.gif`;
 837 | 
 838 |             const downloadId = await chrome.downloads.download({
 839 |               url: dataUrl,
 840 |               filename: fullFilename,
 841 |               saveAs: false,
 842 |             });
 843 | 
 844 |             await new Promise((resolve) => setTimeout(resolve, 100));
 845 | 
 846 |             let fullPath: string | undefined;
 847 |             try {
 848 |               const [downloadItem] = await chrome.downloads.search({ id: downloadId });
 849 |               fullPath = downloadItem?.filename;
 850 |             } catch {
 851 |               // Ignore
 852 |             }
 853 | 
 854 |             return this.buildResponse({
 855 |               success: true,
 856 |               action: 'stop',
 857 |               tabId: autoTab,
 858 |               mode: 'auto_capture',
 859 |               frameCount: stopResult.frameCount,
 860 |               durationMs: stopResult.durationMs,
 861 |               byteLength: stopResult.gifData.byteLength,
 862 |               actionsCount: stopResult.actions?.length,
 863 |               downloadId,
 864 |               filename: fullFilename,
 865 |               fullPath,
 866 |             });
 867 |           }
 868 | 
 869 |           // Fall back to fixed-FPS stop
 870 |           const result = await stopRecording();
 871 |           if (result.success) {
 872 |             result.mode = 'fixed_fps';
 873 |           }
 874 |           return this.buildResponse(result);
 875 |         }
 876 | 
 877 |         case 'status': {
 878 |           // Check auto-capture status first
 879 |           const autoTab = autoCaptureMetadata?.tabId;
 880 |           if (autoTab !== undefined && isAutoCaptureActive(autoTab)) {
 881 |             const status = getAutoCaptureStatus(autoTab);
 882 |             return this.buildResponse({
 883 |               success: true,
 884 |               action: 'status',
 885 |               tabId: autoTab,
 886 |               isRecording: status.active,
 887 |               mode: 'auto_capture',
 888 |               frameCount: status.frameCount,
 889 |               durationMs: status.durationMs,
 890 |               actionsCount: status.actionsCount,
 891 |             });
 892 |           }
 893 | 
 894 |           // Fall back to fixed-FPS status
 895 |           const result = getRecordingStatus();
 896 |           if (result.isRecording) {
 897 |             result.mode = 'fixed_fps';
 898 |           }
 899 |           return this.buildResponse(result);
 900 |         }
 901 | 
 902 |         case 'clear': {
 903 |           // Clear all recording state and cached GIF
 904 |           let clearedAuto = false;
 905 |           let clearedFixedFps = false;
 906 |           let clearedCache = false;
 907 | 
 908 |           // Stop auto-capture if active
 909 |           const autoTab = autoCaptureMetadata?.tabId;
 910 |           if (autoTab !== undefined && isAutoCaptureActive(autoTab)) {
 911 |             await stopAutoCapture(autoTab);
 912 |             autoCaptureMetadata = null;
 913 |             clearedAuto = true;
 914 |           }
 915 | 
 916 |           // Stop fixed-FPS recording if active or stopping
 917 |           if (recordingState) {
 918 |             // Cancel timer and cleanup without waiting for finish
 919 |             if (recordingState.captureTimer) {
 920 |               clearTimeout(recordingState.captureTimer);
 921 |               recordingState.captureTimer = null;
 922 |             }
 923 |             try {
 924 |               await recordingState.captureInProgress;
 925 |             } catch {
 926 |               // ignore
 927 |             }
 928 |             try {
 929 |               await cdpSessionManager.detach(recordingState.tabId, CDP_SESSION_KEY);
 930 |             } catch {
 931 |               // ignore
 932 |             }
 933 |             const wasRecording = recordingState.isRecording || recordingState.isStopping;
 934 |             recordingState = null;
 935 |             stopPromise = null; // Clear any pending stop promise
 936 |             if (wasRecording) {
 937 |               clearedFixedFps = true;
 938 |             }
 939 |           }
 940 | 
 941 |           // Reset offscreen encoder
 942 |           try {
 943 |             await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {});
 944 |           } catch {
 945 |             // ignore
 946 |           }
 947 | 
 948 |           // Clear cached GIF
 949 |           if (lastRecordedGif) {
 950 |             lastRecordedGif = null;
 951 |             clearedCache = true;
 952 |           }
 953 | 
 954 |           return this.buildResponse({
 955 |             success: true,
 956 |             action: 'clear',
 957 |             clearedAutoCapture: clearedAuto,
 958 |             clearedFixedFps,
 959 |             clearedCache,
 960 |           } as GifResult);
 961 |         }
 962 | 
 963 |         case 'export': {
 964 |           // Export the last recorded GIF (download or drag&drop upload)
 965 | 
 966 |           // Check if cache is valid
 967 |           if (!lastRecordedGif) {
 968 |             return createErrorResponse(
 969 |               'No recorded GIF available for export. Use action="stop" to finish a recording first.',
 970 |             );
 971 |           }
 972 | 
 973 |           // Check cache expiration
 974 |           if (Date.now() - lastRecordedGif.createdAt > EXPORT_CACHE_LIFETIME_MS) {
 975 |             lastRecordedGif = null;
 976 |             return createErrorResponse('Cached GIF has expired. Please record a new GIF.');
 977 |           }
 978 | 
 979 |           const download = args.download !== false; // Default to download
 980 | 
 981 |           if (download) {
 982 |             // Download mode
 983 |             const blob = new Blob([lastRecordedGif.gifData], { type: 'image/gif' });
 984 |             const dataUrl = await blobToDataUrl(blob);
 985 | 
 986 |             const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
 987 |             const filename = args.filename ?? lastRecordedGif.filename;
 988 |             const outputFilename = filename?.replace(/[^a-z0-9_-]/gi, '_') || `export_${timestamp}`;
 989 |             const fullFilename = outputFilename.endsWith('.gif')
 990 |               ? outputFilename
 991 |               : `${outputFilename}.gif`;
 992 | 
 993 |             const downloadId = await chrome.downloads.download({
 994 |               url: dataUrl,
 995 |               filename: fullFilename,
 996 |               saveAs: false,
 997 |             });
 998 | 
 999 |             await new Promise((resolve) => setTimeout(resolve, 100));
1000 | 
1001 |             let fullPath: string | undefined;
1002 |             try {
1003 |               const [downloadItem] = await chrome.downloads.search({ id: downloadId });
1004 |               fullPath = downloadItem?.filename;
1005 |             } catch {
1006 |               // Ignore
1007 |             }
1008 | 
1009 |             return this.buildResponse({
1010 |               success: true,
1011 |               action: 'export',
1012 |               mode: lastRecordedGif.mode,
1013 |               frameCount: lastRecordedGif.frameCount,
1014 |               durationMs: lastRecordedGif.durationMs,
1015 |               byteLength: lastRecordedGif.gifData.byteLength,
1016 |               downloadId,
1017 |               filename: fullFilename,
1018 |               fullPath,
1019 |             });
1020 |           } else {
1021 |             // Drag&drop upload mode
1022 |             const { coordinates, ref, selector } = args;
1023 | 
1024 |             if (!coordinates && !ref && !selector) {
1025 |               return createErrorResponse(
1026 |                 'For drag&drop upload, provide coordinates, ref, or selector to identify the drop target.',
1027 |               );
1028 |             }
1029 | 
1030 |             // Resolve target tab
1031 |             const tab = await this.resolveTargetTab(args.tabId);
1032 |             if (!tab?.id) {
1033 |               return createErrorResponse(
1034 |                 typeof args.tabId === 'number'
1035 |                   ? `Tab not found: ${args.tabId}`
1036 |                   : 'No active tab found',
1037 |               );
1038 |             }
1039 | 
1040 |             // Security check
1041 |             if (this.isRestrictedUrl(tab.url)) {
1042 |               return createErrorResponse(
1043 |                 'Cannot upload to special browser pages or web store pages.',
1044 |               );
1045 |             }
1046 | 
1047 |             // Prepare GIF data as base64
1048 |             const gifBase64 = btoa(
1049 |               Array.from(lastRecordedGif.gifData)
1050 |                 .map((b) => String.fromCharCode(b))
1051 |                 .join(''),
1052 |             );
1053 | 
1054 |             // Resolve drop target coordinates
1055 |             let targetX: number | undefined;
1056 |             let targetY: number | undefined;
1057 | 
1058 |             if (ref) {
1059 |               // Use the project's built-in ref resolution mechanism
1060 |               try {
1061 |                 await this.injectContentScript(tab.id, [
1062 |                   'inject-scripts/accessibility-tree-helper.js',
1063 |                 ]);
1064 |                 const resolved = await this.sendMessageToTab(tab.id, {
1065 |                   action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
1066 |                   ref,
1067 |                 });
1068 |                 if (resolved?.success && resolved.center) {
1069 |                   targetX = resolved.center.x;
1070 |                   targetY = resolved.center.y;
1071 |                 } else {
1072 |                   return createErrorResponse(`Could not resolve ref: ${ref}`);
1073 |                 }
1074 |               } catch (err) {
1075 |                 return createErrorResponse(
1076 |                   `Failed to resolve ref: ${err instanceof Error ? err.message : String(err)}`,
1077 |                 );
1078 |               }
1079 |             } else if (selector) {
1080 |               // Use executeScript to get element center coordinates by CSS selector
1081 |               try {
1082 |                 const [result] = await chrome.scripting.executeScript({
1083 |                   target: { tabId: tab.id },
1084 |                   func: (cssSelector: string) => {
1085 |                     const el = document.querySelector(cssSelector);
1086 |                     if (!el) return null;
1087 |                     const rect = el.getBoundingClientRect();
1088 |                     return {
1089 |                       x: rect.left + rect.width / 2,
1090 |                       y: rect.top + rect.height / 2,
1091 |                     };
1092 |                   },
1093 |                   args: [selector],
1094 |                 });
1095 | 
1096 |                 if (result?.result) {
1097 |                   targetX = result.result.x;
1098 |                   targetY = result.result.y;
1099 |                 } else {
1100 |                   return createErrorResponse(`Could not find element: ${selector}`);
1101 |                 }
1102 |               } catch (err) {
1103 |                 return createErrorResponse(
1104 |                   `Failed to resolve selector: ${err instanceof Error ? err.message : String(err)}`,
1105 |                 );
1106 |               }
1107 |             } else if (coordinates) {
1108 |               targetX = coordinates.x;
1109 |               targetY = coordinates.y;
1110 |             }
1111 | 
1112 |             if (typeof targetX !== 'number' || typeof targetY !== 'number') {
1113 |               return createErrorResponse('Invalid drop target coordinates.');
1114 |             }
1115 | 
1116 |             // Execute drag&drop upload
1117 |             try {
1118 |               const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1119 |               const filename =
1120 |                 args.filename ?? lastRecordedGif.filename ?? `recording_${timestamp}`;
1121 |               const fullFilename = filename.endsWith('.gif') ? filename : `${filename}.gif`;
1122 | 
1123 |               const [result] = await chrome.scripting.executeScript({
1124 |                 target: { tabId: tab.id },
1125 |                 func: (base64Data: string, x: number, y: number, fname: string) => {
1126 |                   // Convert base64 to Blob
1127 |                   const byteChars = atob(base64Data);
1128 |                   const byteArray = new Uint8Array(byteChars.length);
1129 |                   for (let i = 0; i < byteChars.length; i++) {
1130 |                     byteArray[i] = byteChars.charCodeAt(i);
1131 |                   }
1132 |                   const blob = new Blob([byteArray], { type: 'image/gif' });
1133 |                   const file = new File([blob], fname, { type: 'image/gif' });
1134 | 
1135 |                   // Find drop target element
1136 |                   const target = document.elementFromPoint(x, y);
1137 |                   if (!target) {
1138 |                     return { success: false, error: 'No element at drop coordinates' };
1139 |                   }
1140 | 
1141 |                   // Create DataTransfer with the file
1142 |                   const dt = new DataTransfer();
1143 |                   dt.items.add(file);
1144 | 
1145 |                   // Dispatch drag events
1146 |                   const events = ['dragenter', 'dragover', 'drop'] as const;
1147 |                   for (const eventType of events) {
1148 |                     const evt = new DragEvent(eventType, {
1149 |                       bubbles: true,
1150 |                       cancelable: true,
1151 |                       dataTransfer: dt,
1152 |                       clientX: x,
1153 |                       clientY: y,
1154 |                     });
1155 |                     target.dispatchEvent(evt);
1156 |                   }
1157 | 
1158 |                   return {
1159 |                     success: true,
1160 |                     targetTagName: target.tagName,
1161 |                     targetId: target.id || undefined,
1162 |                   };
1163 |                 },
1164 |                 args: [gifBase64, targetX, targetY, fullFilename],
1165 |               });
1166 | 
1167 |               if (!result?.result?.success) {
1168 |                 return createErrorResponse(result?.result?.error || 'Drag&drop upload failed');
1169 |               }
1170 | 
1171 |               return this.buildResponse({
1172 |                 success: true,
1173 |                 action: 'export',
1174 |                 mode: lastRecordedGif.mode,
1175 |                 frameCount: lastRecordedGif.frameCount,
1176 |                 durationMs: lastRecordedGif.durationMs,
1177 |                 byteLength: lastRecordedGif.gifData.byteLength,
1178 |                 uploadTarget: {
1179 |                   x: targetX,
1180 |                   y: targetY,
1181 |                   tagName: result.result.targetTagName,
1182 |                   id: result.result.targetId,
1183 |                 },
1184 |               } as GifResult);
1185 |             } catch (err) {
1186 |               return createErrorResponse(
1187 |                 `Drag&drop upload failed: ${err instanceof Error ? err.message : String(err)}`,
1188 |               );
1189 |             }
1190 |           }
1191 |         }
1192 | 
1193 |         default:
1194 |           return createErrorResponse(`Unknown action: ${action}`);
1195 |       }
1196 |     } catch (error) {
1197 |       console.error('GifRecorderTool.execute error:', error);
1198 |       return createErrorResponse(
1199 |         `GIF recorder error: ${error instanceof Error ? error.message : String(error)}`,
1200 |       );
1201 |     }
1202 |   }
1203 | 
1204 |   private isRestrictedUrl(url?: string): boolean {
1205 |     if (!url) return false;
1206 |     return (
1207 |       url.startsWith('chrome://') ||
1208 |       url.startsWith('edge://') ||
1209 |       url.startsWith('https://chrome.google.com/webstore') ||
1210 |       url.startsWith('https://microsoftedge.microsoft.com/')
1211 |     );
1212 |   }
1213 | 
1214 |   private async resolveTargetTab(tabId?: number): Promise<chrome.tabs.Tab | null> {
1215 |     if (typeof tabId === 'number') {
1216 |       return this.tryGetTab(tabId);
1217 |     }
1218 |     try {
1219 |       return await this.getActiveTabOrThrow();
1220 |     } catch {
1221 |       return null;
1222 |     }
1223 |   }
1224 | 
1225 |   private buildResponse(result: GifResult): ToolResult {
1226 |     return {
1227 |       content: [{ type: 'text', text: JSON.stringify(result) }],
1228 |       isError: !result.success,
1229 |     };
1230 |   }
1231 | }
1232 | 
1233 | export const gifRecorderTool = new GifRecorderTool();
1234 | 
1235 | // Re-export auto-capture utilities for use by other tools (e.g., chrome_computer, chrome_navigate)
1236 | export {
1237 |   captureFrameOnAction,
1238 |   isAutoCaptureActive,
1239 |   type ActionMetadata,
1240 |   type ActionType,
1241 | } from './gif-auto-capture';
1242 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/tests/record-replay-v3/rpc-api.test.ts:
--------------------------------------------------------------------------------

```typescript
   1 | /* eslint-disable @typescript-eslint/no-unsafe-function-type */
   2 | /**
   3 |  * @fileoverview Record-Replay V3 RPC API Tests
   4 |  * @description
   5 |  * Tests for the queue management RPC APIs:
   6 |  * - rr_v3.enqueueRun
   7 |  * - rr_v3.listQueue
   8 |  * - rr_v3.cancelQueueItem
   9 |  *
  10 |  * Tests for Flow CRUD RPC APIs:
  11 |  * - rr_v3.saveFlow
  12 |  * - rr_v3.deleteFlow
  13 |  */
  14 | 
  15 | import { beforeEach, describe, expect, it, vi } from 'vitest';
  16 | 
  17 | import type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow';
  18 | import type { RunRecordV3 } from '@/entrypoints/background/record-replay-v3/domain/events';
  19 | import type { StoragePort } from '@/entrypoints/background/record-replay-v3/engine/storage/storage-port';
  20 | import type { EventsBus } from '@/entrypoints/background/record-replay-v3/engine/transport/events-bus';
  21 | import type { RunScheduler } from '@/entrypoints/background/record-replay-v3/engine/queue/scheduler';
  22 | import type { RunQueueItem } from '@/entrypoints/background/record-replay-v3/engine/queue/queue';
  23 | import { RpcServer } from '@/entrypoints/background/record-replay-v3/engine/transport/rpc-server';
  24 | 
  25 | // ==================== Test Utilities ====================
  26 | 
  27 | function createMockStorage(): StoragePort {
  28 |   const flowsMap = new Map<string, FlowV3>();
  29 |   const runsMap = new Map<string, RunRecordV3>();
  30 |   const queueMap = new Map<string, RunQueueItem>();
  31 |   const eventsLog: Array<{ runId: string; type: string }> = [];
  32 | 
  33 |   return {
  34 |     flows: {
  35 |       list: vi.fn(async () => Array.from(flowsMap.values())),
  36 |       get: vi.fn(async (id: string) => flowsMap.get(id) ?? null),
  37 |       save: vi.fn(async (flow: FlowV3) => {
  38 |         flowsMap.set(flow.id, flow);
  39 |       }),
  40 |       delete: vi.fn(async (id: string) => {
  41 |         flowsMap.delete(id);
  42 |       }),
  43 |     },
  44 |     runs: {
  45 |       list: vi.fn(async () => Array.from(runsMap.values())),
  46 |       get: vi.fn(async (id: string) => runsMap.get(id) ?? null),
  47 |       save: vi.fn(async (record: RunRecordV3) => {
  48 |         runsMap.set(record.id, record);
  49 |       }),
  50 |       patch: vi.fn(async (id: string, patch: Partial<RunRecordV3>) => {
  51 |         const existing = runsMap.get(id);
  52 |         if (existing) {
  53 |           runsMap.set(id, { ...existing, ...patch });
  54 |         }
  55 |       }),
  56 |     },
  57 |     events: {
  58 |       append: vi.fn(async (event: { runId: string; type: string }) => {
  59 |         eventsLog.push(event);
  60 |         return { ...event, ts: Date.now(), seq: eventsLog.length };
  61 |       }),
  62 |       list: vi.fn(async () => eventsLog),
  63 |     },
  64 |     queue: {
  65 |       enqueue: vi.fn(async (input) => {
  66 |         const now = Date.now();
  67 |         const item: RunQueueItem = {
  68 |           ...input,
  69 |           priority: input.priority ?? 0,
  70 |           maxAttempts: input.maxAttempts ?? 1,
  71 |           status: 'queued',
  72 |           createdAt: now,
  73 |           updatedAt: now,
  74 |           attempt: 0,
  75 |         };
  76 |         queueMap.set(input.id, item);
  77 |         return item;
  78 |       }),
  79 |       claimNext: vi.fn(async () => null),
  80 |       heartbeat: vi.fn(async () => {}),
  81 |       reclaimExpiredLeases: vi.fn(async () => []),
  82 |       markRunning: vi.fn(async () => {}),
  83 |       markPaused: vi.fn(async () => {}),
  84 |       markDone: vi.fn(async () => {}),
  85 |       cancel: vi.fn(async (runId: string) => {
  86 |         queueMap.delete(runId);
  87 |       }),
  88 |       get: vi.fn(async (runId: string) => queueMap.get(runId) ?? null),
  89 |       list: vi.fn(async (status?: string) => {
  90 |         const items = Array.from(queueMap.values());
  91 |         if (status) {
  92 |           return items.filter((item) => item.status === status);
  93 |         }
  94 |         return items;
  95 |       }),
  96 |     },
  97 |     persistentVars: {
  98 |       get: vi.fn(async () => undefined),
  99 |       set: vi.fn(async () => ({ key: '', value: null, updatedAt: 0 })),
 100 |       delete: vi.fn(async () => {}),
 101 |       list: vi.fn(async () => []),
 102 |     },
 103 |     triggers: {
 104 |       list: vi.fn(async () => []),
 105 |       get: vi.fn(async () => null),
 106 |       save: vi.fn(async () => {}),
 107 |       delete: vi.fn(async () => {}),
 108 |     },
 109 |     // Expose internal maps for assertions
 110 |     _internal: { flowsMap, runsMap, queueMap, eventsLog },
 111 |   } as unknown as StoragePort & {
 112 |     _internal: {
 113 |       flowsMap: Map<string, FlowV3>;
 114 |       runsMap: Map<string, RunRecordV3>;
 115 |       queueMap: Map<string, RunQueueItem>;
 116 |       eventsLog: Array<{ runId: string; type: string }>;
 117 |     };
 118 |   };
 119 | }
 120 | 
 121 | function createMockEventsBus(): EventsBus {
 122 |   const subscribers: Array<(event: unknown) => void> = [];
 123 |   return {
 124 |     subscribe: vi.fn((callback: (event: unknown) => void) => {
 125 |       subscribers.push(callback);
 126 |       return () => {
 127 |         const idx = subscribers.indexOf(callback);
 128 |         if (idx >= 0) subscribers.splice(idx, 1);
 129 |       };
 130 |     }),
 131 |     append: vi.fn(async (event) => {
 132 |       const fullEvent = { ...event, ts: Date.now(), seq: 1 };
 133 |       subscribers.forEach((cb) => cb(fullEvent));
 134 |       return fullEvent as ReturnType<EventsBus['append']> extends Promise<infer T> ? T : never;
 135 |     }),
 136 |     list: vi.fn(async () => []),
 137 |   } as EventsBus;
 138 | }
 139 | 
 140 | function createMockScheduler(): RunScheduler {
 141 |   return {
 142 |     start: vi.fn(),
 143 |     stop: vi.fn(),
 144 |     kick: vi.fn(async () => {}),
 145 |     getState: vi.fn(() => ({
 146 |       started: false,
 147 |       ownerId: 'test-owner',
 148 |       maxParallelRuns: 3,
 149 |       activeRunIds: [],
 150 |     })),
 151 |     dispose: vi.fn(),
 152 |   };
 153 | }
 154 | 
 155 | function createTestFlow(id: string, options: { withNodes?: boolean } = {}): FlowV3 {
 156 |   const now = new Date().toISOString();
 157 |   const nodes =
 158 |     options.withNodes !== false
 159 |       ? [
 160 |           { id: 'node-start', kind: 'test', config: {} },
 161 |           { id: 'node-end', kind: 'test', config: {} },
 162 |         ]
 163 |       : [];
 164 |   return {
 165 |     schemaVersion: 3,
 166 |     id: id as FlowV3['id'],
 167 |     name: `Test Flow ${id}`,
 168 |     entryNodeId: 'node-start' as FlowV3['entryNodeId'],
 169 |     nodes: nodes as FlowV3['nodes'],
 170 |     edges: [{ id: 'edge-1', from: 'node-start', to: 'node-end' }] as FlowV3['edges'],
 171 |     variables: [],
 172 |     createdAt: now,
 173 |     updatedAt: now,
 174 |   };
 175 | }
 176 | 
 177 | // Helper type for accessing internal maps in mock storage
 178 | interface MockStorageInternal {
 179 |   flowsMap: Map<string, FlowV3>;
 180 |   runsMap: Map<string, RunRecordV3>;
 181 |   queueMap: Map<string, RunQueueItem>;
 182 |   eventsLog: Array<{ runId: string; type: string }>;
 183 | }
 184 | 
 185 | // Access _internal property with type safety
 186 | function getInternal(storage: StoragePort): MockStorageInternal {
 187 |   return (storage as unknown as { _internal: MockStorageInternal })._internal;
 188 | }
 189 | 
 190 | // ==================== Tests ====================
 191 | 
 192 | describe('V3 RPC Queue Management APIs', () => {
 193 |   let storage: ReturnType<typeof createMockStorage>;
 194 |   let events: EventsBus;
 195 |   let scheduler: RunScheduler;
 196 |   let server: RpcServer;
 197 |   let runIdCounter: number;
 198 |   let fixedNow: number;
 199 | 
 200 |   beforeEach(() => {
 201 |     storage = createMockStorage();
 202 |     events = createMockEventsBus();
 203 |     scheduler = createMockScheduler();
 204 |     runIdCounter = 0;
 205 |     fixedNow = 1_700_000_000_000;
 206 | 
 207 |     server = new RpcServer({
 208 |       storage,
 209 |       events,
 210 |       scheduler,
 211 |       generateRunId: () => `run-${++runIdCounter}`,
 212 |       now: () => fixedNow,
 213 |     });
 214 |   });
 215 | 
 216 |   describe('rr_v3.enqueueRun', () => {
 217 |     it('creates run record, enqueues, emits event, and kicks scheduler', async () => {
 218 |       // Setup: add a flow
 219 |       const flow = createTestFlow('flow-1');
 220 |       getInternal(storage).flowsMap.set(flow.id, flow);
 221 | 
 222 |       // Act: call enqueueRun via handleRequest
 223 |       const result = await (server as unknown as { handleRequest: Function }).handleRequest(
 224 |         { method: 'rr_v3.enqueueRun', params: { flowId: 'flow-1' }, requestId: 'req-1' },
 225 |         { subscriptions: new Set() },
 226 |       );
 227 | 
 228 |       // Assert: run record created
 229 |       expect(storage.runs.save).toHaveBeenCalledTimes(1);
 230 |       const savedRun = (storage.runs.save as ReturnType<typeof vi.fn>).mock.calls[0][0];
 231 |       expect(savedRun).toMatchObject({
 232 |         id: 'run-1',
 233 |         flowId: 'flow-1',
 234 |         status: 'queued',
 235 |         attempt: 0,
 236 |         maxAttempts: 1,
 237 |       });
 238 | 
 239 |       // Assert: enqueued
 240 |       expect(storage.queue.enqueue).toHaveBeenCalledTimes(1);
 241 | 
 242 |       // Assert: event emitted via EventsBus
 243 |       expect(events.append).toHaveBeenCalledWith(
 244 |         expect.objectContaining({
 245 |           runId: 'run-1',
 246 |           type: 'run.queued',
 247 |           flowId: 'flow-1',
 248 |         }),
 249 |       );
 250 | 
 251 |       // Assert: scheduler kicked
 252 |       expect(scheduler.kick).toHaveBeenCalledTimes(1);
 253 | 
 254 |       // Assert: result
 255 |       expect(result).toMatchObject({
 256 |         runId: 'run-1',
 257 |         position: 1,
 258 |       });
 259 |     });
 260 | 
 261 |     it('throws if flowId is missing', async () => {
 262 |       await expect(
 263 |         (server as unknown as { handleRequest: Function }).handleRequest(
 264 |           { method: 'rr_v3.enqueueRun', params: {}, requestId: 'req-1' },
 265 |           { subscriptions: new Set() },
 266 |         ),
 267 |       ).rejects.toThrow('flowId is required');
 268 |     });
 269 | 
 270 |     it('throws if flow does not exist', async () => {
 271 |       await expect(
 272 |         (server as unknown as { handleRequest: Function }).handleRequest(
 273 |           { method: 'rr_v3.enqueueRun', params: { flowId: 'non-existent' }, requestId: 'req-1' },
 274 |           { subscriptions: new Set() },
 275 |         ),
 276 |       ).rejects.toThrow('Flow "non-existent" not found');
 277 |     });
 278 | 
 279 |     it('respects custom priority and maxAttempts', async () => {
 280 |       const flow = createTestFlow('flow-1');
 281 |       getInternal(storage).flowsMap.set(flow.id, flow);
 282 | 
 283 |       await (server as unknown as { handleRequest: Function }).handleRequest(
 284 |         {
 285 |           method: 'rr_v3.enqueueRun',
 286 |           params: { flowId: 'flow-1', priority: 10, maxAttempts: 3 },
 287 |           requestId: 'req-1',
 288 |         },
 289 |         { subscriptions: new Set() },
 290 |       );
 291 | 
 292 |       expect(storage.queue.enqueue).toHaveBeenCalledWith(
 293 |         expect.objectContaining({
 294 |           priority: 10,
 295 |           maxAttempts: 3,
 296 |         }),
 297 |       );
 298 |     });
 299 | 
 300 |     it('passes args and debug config', async () => {
 301 |       const flow = createTestFlow('flow-1');
 302 |       getInternal(storage).flowsMap.set(flow.id, flow);
 303 | 
 304 |       const args = { url: 'https://example.com' };
 305 |       const debug = { pauseOnStart: true, breakpoints: ['node-1'] };
 306 | 
 307 |       await (server as unknown as { handleRequest: Function }).handleRequest(
 308 |         {
 309 |           method: 'rr_v3.enqueueRun',
 310 |           params: { flowId: 'flow-1', args, debug },
 311 |           requestId: 'req-1',
 312 |         },
 313 |         { subscriptions: new Set() },
 314 |       );
 315 | 
 316 |       expect(storage.runs.save).toHaveBeenCalledWith(
 317 |         expect.objectContaining({
 318 |           args,
 319 |           debug,
 320 |         }),
 321 |       );
 322 |     });
 323 | 
 324 |     it('rejects NaN priority', async () => {
 325 |       const flow = createTestFlow('flow-1');
 326 |       getInternal(storage).flowsMap.set(flow.id, flow);
 327 | 
 328 |       await expect(
 329 |         (server as unknown as { handleRequest: Function }).handleRequest(
 330 |           {
 331 |             method: 'rr_v3.enqueueRun',
 332 |             params: { flowId: 'flow-1', priority: NaN },
 333 |             requestId: 'req-1',
 334 |           },
 335 |           { subscriptions: new Set() },
 336 |         ),
 337 |       ).rejects.toThrow('priority must be a finite number');
 338 |     });
 339 | 
 340 |     it('rejects Infinity maxAttempts', async () => {
 341 |       const flow = createTestFlow('flow-1');
 342 |       getInternal(storage).flowsMap.set(flow.id, flow);
 343 | 
 344 |       await expect(
 345 |         (server as unknown as { handleRequest: Function }).handleRequest(
 346 |           {
 347 |             method: 'rr_v3.enqueueRun',
 348 |             params: { flowId: 'flow-1', maxAttempts: Infinity },
 349 |             requestId: 'req-1',
 350 |           },
 351 |           { subscriptions: new Set() },
 352 |         ),
 353 |       ).rejects.toThrow('maxAttempts must be a finite number');
 354 |     });
 355 | 
 356 |     it('rejects maxAttempts < 1', async () => {
 357 |       const flow = createTestFlow('flow-1');
 358 |       getInternal(storage).flowsMap.set(flow.id, flow);
 359 | 
 360 |       await expect(
 361 |         (server as unknown as { handleRequest: Function }).handleRequest(
 362 |           {
 363 |             method: 'rr_v3.enqueueRun',
 364 |             params: { flowId: 'flow-1', maxAttempts: 0 },
 365 |             requestId: 'req-1',
 366 |           },
 367 |           { subscriptions: new Set() },
 368 |         ),
 369 |       ).rejects.toThrow('maxAttempts must be >= 1');
 370 |     });
 371 | 
 372 |     it('persists startNodeId in RunRecord when provided', async () => {
 373 |       // Setup: add a flow with multiple nodes
 374 |       const flow = createTestFlow('flow-start-node');
 375 |       getInternal(storage).flowsMap.set(flow.id, flow);
 376 | 
 377 |       // Act: enqueue with startNodeId
 378 |       const targetNodeId = flow.nodes[0].id; // Use the first node
 379 |       await (server as unknown as { handleRequest: Function }).handleRequest(
 380 |         {
 381 |           method: 'rr_v3.enqueueRun',
 382 |           params: { flowId: 'flow-start-node', startNodeId: targetNodeId },
 383 |           requestId: 'req-1',
 384 |         },
 385 |         { subscriptions: new Set() },
 386 |       );
 387 | 
 388 |       // Assert: RunRecord should have startNodeId
 389 |       const runsMap = getInternal(storage).runsMap;
 390 |       expect(runsMap.size).toBe(1);
 391 |       const runRecord = Array.from(runsMap.values())[0];
 392 |       expect(runRecord.startNodeId).toBe(targetNodeId);
 393 |     });
 394 | 
 395 |     it('throws if startNodeId does not exist in flow', async () => {
 396 |       // Setup: add a flow
 397 |       const flow = createTestFlow('flow-invalid-start');
 398 |       getInternal(storage).flowsMap.set(flow.id, flow);
 399 | 
 400 |       // Act & Assert
 401 |       await expect(
 402 |         (server as unknown as { handleRequest: Function }).handleRequest(
 403 |           {
 404 |             method: 'rr_v3.enqueueRun',
 405 |             params: { flowId: 'flow-invalid-start', startNodeId: 'non-existent-node' },
 406 |             requestId: 'req-1',
 407 |           },
 408 |           { subscriptions: new Set() },
 409 |         ),
 410 |       ).rejects.toThrow('startNodeId "non-existent-node" not found in flow');
 411 |     });
 412 |   });
 413 | 
 414 |   describe('rr_v3.listQueue', () => {
 415 |     it('returns all queue items sorted by priority DESC and createdAt ASC', async () => {
 416 |       // Setup: add items with different priorities and times
 417 |       getInternal(storage).queueMap.set('run-1', {
 418 |         id: 'run-1',
 419 |         flowId: 'flow-1',
 420 |         status: 'queued',
 421 |         priority: 5,
 422 |         createdAt: 1000,
 423 |         updatedAt: 1000,
 424 |         attempt: 0,
 425 |         maxAttempts: 1,
 426 |       });
 427 |       getInternal(storage).queueMap.set('run-2', {
 428 |         id: 'run-2',
 429 |         flowId: 'flow-1',
 430 |         status: 'queued',
 431 |         priority: 10,
 432 |         createdAt: 2000,
 433 |         updatedAt: 2000,
 434 |         attempt: 0,
 435 |         maxAttempts: 1,
 436 |       });
 437 |       getInternal(storage).queueMap.set('run-3', {
 438 |         id: 'run-3',
 439 |         flowId: 'flow-1',
 440 |         status: 'queued',
 441 |         priority: 10,
 442 |         createdAt: 1500,
 443 |         updatedAt: 1500,
 444 |         attempt: 0,
 445 |         maxAttempts: 1,
 446 |       });
 447 | 
 448 |       const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
 449 |         { method: 'rr_v3.listQueue', params: {}, requestId: 'req-1' },
 450 |         { subscriptions: new Set() },
 451 |       )) as RunQueueItem[];
 452 | 
 453 |       // run-3 (priority 10, earlier) > run-2 (priority 10, later) > run-1 (priority 5)
 454 |       expect(result.map((r) => r.id)).toEqual(['run-3', 'run-2', 'run-1']);
 455 |     });
 456 | 
 457 |     it('filters by status', async () => {
 458 |       getInternal(storage).queueMap.set('run-1', {
 459 |         id: 'run-1',
 460 |         flowId: 'flow-1',
 461 |         status: 'queued',
 462 |         priority: 0,
 463 |         createdAt: 1000,
 464 |         updatedAt: 1000,
 465 |         attempt: 0,
 466 |         maxAttempts: 1,
 467 |       });
 468 |       getInternal(storage).queueMap.set('run-2', {
 469 |         id: 'run-2',
 470 |         flowId: 'flow-1',
 471 |         status: 'running',
 472 |         priority: 0,
 473 |         createdAt: 2000,
 474 |         updatedAt: 2000,
 475 |         attempt: 1,
 476 |         maxAttempts: 1,
 477 |       });
 478 | 
 479 |       const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
 480 |         { method: 'rr_v3.listQueue', params: { status: 'queued' }, requestId: 'req-1' },
 481 |         { subscriptions: new Set() },
 482 |       )) as RunQueueItem[];
 483 | 
 484 |       expect(result).toHaveLength(1);
 485 |       expect(result[0].id).toBe('run-1');
 486 |     });
 487 | 
 488 |     it('rejects invalid status', async () => {
 489 |       await expect(
 490 |         (server as unknown as { handleRequest: Function }).handleRequest(
 491 |           { method: 'rr_v3.listQueue', params: { status: 'invalid' }, requestId: 'req-1' },
 492 |           { subscriptions: new Set() },
 493 |         ),
 494 |       ).rejects.toThrow('status must be one of: queued, running, paused');
 495 |     });
 496 |   });
 497 | 
 498 |   describe('rr_v3.cancelQueueItem', () => {
 499 |     it('cancels queue item, patches run, and emits event', async () => {
 500 |       // Setup
 501 |       getInternal(storage).queueMap.set('run-1', {
 502 |         id: 'run-1',
 503 |         flowId: 'flow-1',
 504 |         status: 'queued',
 505 |         priority: 0,
 506 |         createdAt: 1000,
 507 |         updatedAt: 1000,
 508 |         attempt: 0,
 509 |         maxAttempts: 1,
 510 |       });
 511 |       getInternal(storage).runsMap.set('run-1', {
 512 |         schemaVersion: 3,
 513 |         id: 'run-1',
 514 |         flowId: 'flow-1',
 515 |         status: 'queued',
 516 |         createdAt: 1000,
 517 |         updatedAt: 1000,
 518 |         attempt: 0,
 519 |         maxAttempts: 1,
 520 |         nextSeq: 0,
 521 |       });
 522 | 
 523 |       const result = await (server as unknown as { handleRequest: Function }).handleRequest(
 524 |         { method: 'rr_v3.cancelQueueItem', params: { runId: 'run-1' }, requestId: 'req-1' },
 525 |         { subscriptions: new Set() },
 526 |       );
 527 | 
 528 |       // Assert: queue.cancel called
 529 |       expect(storage.queue.cancel).toHaveBeenCalledWith('run-1', fixedNow, undefined);
 530 | 
 531 |       // Assert: run patched
 532 |       expect(storage.runs.patch).toHaveBeenCalledWith('run-1', {
 533 |         status: 'canceled',
 534 |         updatedAt: fixedNow,
 535 |         finishedAt: fixedNow,
 536 |       });
 537 | 
 538 |       // Assert: event emitted via EventsBus
 539 |       expect(events.append).toHaveBeenCalledWith(
 540 |         expect.objectContaining({
 541 |           runId: 'run-1',
 542 |           type: 'run.canceled',
 543 |         }),
 544 |       );
 545 | 
 546 |       // Assert: result
 547 |       expect(result).toMatchObject({ ok: true, runId: 'run-1' });
 548 |     });
 549 | 
 550 |     it('throws if runId is missing', async () => {
 551 |       await expect(
 552 |         (server as unknown as { handleRequest: Function }).handleRequest(
 553 |           { method: 'rr_v3.cancelQueueItem', params: {}, requestId: 'req-1' },
 554 |           { subscriptions: new Set() },
 555 |         ),
 556 |       ).rejects.toThrow('runId is required');
 557 |     });
 558 | 
 559 |     it('throws if queue item does not exist', async () => {
 560 |       await expect(
 561 |         (server as unknown as { handleRequest: Function }).handleRequest(
 562 |           {
 563 |             method: 'rr_v3.cancelQueueItem',
 564 |             params: { runId: 'non-existent' },
 565 |             requestId: 'req-1',
 566 |           },
 567 |           { subscriptions: new Set() },
 568 |         ),
 569 |       ).rejects.toThrow('Queue item "non-existent" not found');
 570 |     });
 571 | 
 572 |     it('throws if queue item is not queued', async () => {
 573 |       getInternal(storage).queueMap.set('run-1', {
 574 |         id: 'run-1',
 575 |         flowId: 'flow-1',
 576 |         status: 'running',
 577 |         priority: 0,
 578 |         createdAt: 1000,
 579 |         updatedAt: 1000,
 580 |         attempt: 1,
 581 |         maxAttempts: 1,
 582 |       });
 583 | 
 584 |       await expect(
 585 |         (server as unknown as { handleRequest: Function }).handleRequest(
 586 |           { method: 'rr_v3.cancelQueueItem', params: { runId: 'run-1' }, requestId: 'req-1' },
 587 |           { subscriptions: new Set() },
 588 |         ),
 589 |       ).rejects.toThrow('Cannot cancel queue item "run-1" with status "running"');
 590 |     });
 591 | 
 592 |     it('includes reason in cancel event', async () => {
 593 |       getInternal(storage).queueMap.set('run-1', {
 594 |         id: 'run-1',
 595 |         flowId: 'flow-1',
 596 |         status: 'queued',
 597 |         priority: 0,
 598 |         createdAt: 1000,
 599 |         updatedAt: 1000,
 600 |         attempt: 0,
 601 |         maxAttempts: 1,
 602 |       });
 603 | 
 604 |       await (server as unknown as { handleRequest: Function }).handleRequest(
 605 |         {
 606 |           method: 'rr_v3.cancelQueueItem',
 607 |           params: { runId: 'run-1', reason: 'User requested cancellation' },
 608 |           requestId: 'req-1',
 609 |         },
 610 |         { subscriptions: new Set() },
 611 |       );
 612 | 
 613 |       expect(storage.queue.cancel).toHaveBeenCalledWith(
 614 |         'run-1',
 615 |         fixedNow,
 616 |         'User requested cancellation',
 617 |       );
 618 |       expect(events.append).toHaveBeenCalledWith(
 619 |         expect.objectContaining({
 620 |           reason: 'User requested cancellation',
 621 |         }),
 622 |       );
 623 |     });
 624 |   });
 625 | });
 626 | 
 627 | describe('V3 RPC Flow CRUD APIs', () => {
 628 |   let storage: ReturnType<typeof createMockStorage>;
 629 |   let events: EventsBus;
 630 |   let scheduler: RunScheduler;
 631 |   let server: RpcServer;
 632 |   let fixedNow: number;
 633 | 
 634 |   beforeEach(() => {
 635 |     storage = createMockStorage();
 636 |     events = createMockEventsBus();
 637 |     scheduler = createMockScheduler();
 638 |     fixedNow = 1_700_000_000_000;
 639 | 
 640 |     server = new RpcServer({
 641 |       storage,
 642 |       events,
 643 |       scheduler,
 644 |       now: () => fixedNow,
 645 |     });
 646 |   });
 647 | 
 648 |   describe('rr_v3.saveFlow', () => {
 649 |     it('saves a new flow with all required fields', async () => {
 650 |       const flowInput = {
 651 |         name: 'My New Flow',
 652 |         entryNodeId: 'node-1',
 653 |         nodes: [
 654 |           { id: 'node-1', kind: 'click', config: { selector: '#btn' } },
 655 |           { id: 'node-2', kind: 'delay', config: { ms: 1000 } },
 656 |         ],
 657 |         edges: [{ id: 'e1', from: 'node-1', to: 'node-2' }],
 658 |       };
 659 | 
 660 |       const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
 661 |         { method: 'rr_v3.saveFlow', params: { flow: flowInput }, requestId: 'req-1' },
 662 |         { subscriptions: new Set() },
 663 |       )) as FlowV3;
 664 | 
 665 |       // Assert: flow saved
 666 |       expect(storage.flows.save).toHaveBeenCalledTimes(1);
 667 | 
 668 |       // Assert: returned flow has all fields
 669 |       expect(result.schemaVersion).toBe(3);
 670 |       expect(result.id).toMatch(/^flow_\d+_[a-z0-9]+$/);
 671 |       expect(result.name).toBe('My New Flow');
 672 |       expect(result.entryNodeId).toBe('node-1');
 673 |       expect(result.nodes).toHaveLength(2);
 674 |       expect(result.edges).toHaveLength(1);
 675 |       expect(result.createdAt).toBeDefined();
 676 |       expect(result.updatedAt).toBeDefined();
 677 |     });
 678 | 
 679 |     it('updates an existing flow', async () => {
 680 |       // Setup: add existing flow with a past timestamp
 681 |       const existing = createTestFlow('flow-1');
 682 |       const pastDate = new Date(Date.now() - 100000).toISOString(); // 100 seconds ago
 683 |       existing.createdAt = pastDate;
 684 |       existing.updatedAt = pastDate;
 685 |       getInternal(storage).flowsMap.set(existing.id, existing);
 686 | 
 687 |       const flowInput = {
 688 |         id: 'flow-1',
 689 |         name: 'Updated Flow',
 690 |         entryNodeId: 'node-start',
 691 |         nodes: [{ id: 'node-start', kind: 'navigate', config: { url: 'https://example.com' } }],
 692 |         edges: [],
 693 |         createdAt: existing.createdAt, // Preserve original createdAt
 694 |       };
 695 | 
 696 |       const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
 697 |         { method: 'rr_v3.saveFlow', params: { flow: flowInput }, requestId: 'req-1' },
 698 |         { subscriptions: new Set() },
 699 |       )) as FlowV3;
 700 | 
 701 |       // Assert: flow updated
 702 |       expect(result.id).toBe('flow-1');
 703 |       expect(result.name).toBe('Updated Flow');
 704 |       expect(result.createdAt).toBe(existing.createdAt);
 705 |       expect(result.updatedAt).not.toBe(existing.updatedAt);
 706 |     });
 707 | 
 708 |     it('preserves createdAt when updating without providing it', async () => {
 709 |       // Setup: add existing flow with a past timestamp
 710 |       const existing = createTestFlow('flow-1');
 711 |       const pastDate = new Date(Date.now() - 100000).toISOString();
 712 |       existing.createdAt = pastDate;
 713 |       existing.updatedAt = pastDate;
 714 |       getInternal(storage).flowsMap.set(existing.id, existing);
 715 | 
 716 |       // Update without providing createdAt - should inherit from existing
 717 |       const flowInput = {
 718 |         id: 'flow-1',
 719 |         name: 'Updated Without CreatedAt',
 720 |         entryNodeId: 'node-start',
 721 |         nodes: [{ id: 'node-start', kind: 'test', config: {} }],
 722 |         edges: [],
 723 |         // Note: createdAt is NOT provided
 724 |       };
 725 | 
 726 |       const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
 727 |         { method: 'rr_v3.saveFlow', params: { flow: flowInput }, requestId: 'req-1' },
 728 |         { subscriptions: new Set() },
 729 |       )) as FlowV3;
 730 | 
 731 |       // Assert: createdAt is inherited from existing flow
 732 |       expect(result.createdAt).toBe(existing.createdAt);
 733 |       expect(result.updatedAt).not.toBe(existing.updatedAt);
 734 |     });
 735 | 
 736 |     it('throws if flow is missing', async () => {
 737 |       await expect(
 738 |         (server as unknown as { handleRequest: Function }).handleRequest(
 739 |           { method: 'rr_v3.saveFlow', params: {}, requestId: 'req-1' },
 740 |           { subscriptions: new Set() },
 741 |         ),
 742 |       ).rejects.toThrow('flow is required');
 743 |     });
 744 | 
 745 |     it('throws if name is missing', async () => {
 746 |       await expect(
 747 |         (server as unknown as { handleRequest: Function }).handleRequest(
 748 |           {
 749 |             method: 'rr_v3.saveFlow',
 750 |             params: {
 751 |               flow: {
 752 |                 entryNodeId: 'node-1',
 753 |                 nodes: [{ id: 'node-1', kind: 'test', config: {} }],
 754 |               },
 755 |             },
 756 |             requestId: 'req-1',
 757 |           },
 758 |           { subscriptions: new Set() },
 759 |         ),
 760 |       ).rejects.toThrow('flow.name is required');
 761 |     });
 762 | 
 763 |     it('throws if entryNodeId is missing', async () => {
 764 |       await expect(
 765 |         (server as unknown as { handleRequest: Function }).handleRequest(
 766 |           {
 767 |             method: 'rr_v3.saveFlow',
 768 |             params: {
 769 |               flow: {
 770 |                 name: 'Test',
 771 |                 nodes: [{ id: 'node-1', kind: 'test', config: {} }],
 772 |               },
 773 |             },
 774 |             requestId: 'req-1',
 775 |           },
 776 |           { subscriptions: new Set() },
 777 |         ),
 778 |       ).rejects.toThrow('flow.entryNodeId is required');
 779 |     });
 780 | 
 781 |     it('throws if entryNodeId does not exist in nodes', async () => {
 782 |       await expect(
 783 |         (server as unknown as { handleRequest: Function }).handleRequest(
 784 |           {
 785 |             method: 'rr_v3.saveFlow',
 786 |             params: {
 787 |               flow: {
 788 |                 name: 'Test',
 789 |                 entryNodeId: 'non-existent',
 790 |                 nodes: [{ id: 'node-1', kind: 'test', config: {} }],
 791 |               },
 792 |             },
 793 |             requestId: 'req-1',
 794 |           },
 795 |           { subscriptions: new Set() },
 796 |         ),
 797 |       ).rejects.toThrow('Entry node "non-existent" does not exist in flow');
 798 |     });
 799 | 
 800 |     it('throws if edge references non-existent source node', async () => {
 801 |       await expect(
 802 |         (server as unknown as { handleRequest: Function }).handleRequest(
 803 |           {
 804 |             method: 'rr_v3.saveFlow',
 805 |             params: {
 806 |               flow: {
 807 |                 name: 'Test',
 808 |                 entryNodeId: 'node-1',
 809 |                 nodes: [{ id: 'node-1', kind: 'test', config: {} }],
 810 |                 edges: [{ id: 'e1', from: 'non-existent', to: 'node-1' }],
 811 |               },
 812 |             },
 813 |             requestId: 'req-1',
 814 |           },
 815 |           { subscriptions: new Set() },
 816 |         ),
 817 |       ).rejects.toThrow('Edge "e1" references non-existent source node "non-existent"');
 818 |     });
 819 | 
 820 |     it('throws if edge references non-existent target node', async () => {
 821 |       await expect(
 822 |         (server as unknown as { handleRequest: Function }).handleRequest(
 823 |           {
 824 |             method: 'rr_v3.saveFlow',
 825 |             params: {
 826 |               flow: {
 827 |                 name: 'Test',
 828 |                 entryNodeId: 'node-1',
 829 |                 nodes: [{ id: 'node-1', kind: 'test', config: {} }],
 830 |                 edges: [{ id: 'e1', from: 'node-1', to: 'non-existent' }],
 831 |               },
 832 |             },
 833 |             requestId: 'req-1',
 834 |           },
 835 |           { subscriptions: new Set() },
 836 |         ),
 837 |       ).rejects.toThrow('Edge "e1" references non-existent target node "non-existent"');
 838 |     });
 839 | 
 840 |     it('validates node structure', async () => {
 841 |       await expect(
 842 |         (server as unknown as { handleRequest: Function }).handleRequest(
 843 |           {
 844 |             method: 'rr_v3.saveFlow',
 845 |             params: {
 846 |               flow: {
 847 |                 name: 'Test',
 848 |                 entryNodeId: 'node-1',
 849 |                 nodes: [{ id: 'node-1' }], // missing kind
 850 |               },
 851 |             },
 852 |             requestId: 'req-1',
 853 |           },
 854 |           { subscriptions: new Set() },
 855 |         ),
 856 |       ).rejects.toThrow('flow.nodes[0].kind is required');
 857 |     });
 858 | 
 859 |     it('generates edge ID if not provided', async () => {
 860 |       const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
 861 |         {
 862 |           method: 'rr_v3.saveFlow',
 863 |           params: {
 864 |             flow: {
 865 |               name: 'Test',
 866 |               entryNodeId: 'node-1',
 867 |               nodes: [
 868 |                 { id: 'node-1', kind: 'test', config: {} },
 869 |                 { id: 'node-2', kind: 'test', config: {} },
 870 |               ],
 871 |               edges: [{ from: 'node-1', to: 'node-2' }], // no id
 872 |             },
 873 |           },
 874 |           requestId: 'req-1',
 875 |         },
 876 |         { subscriptions: new Set() },
 877 |       )) as FlowV3;
 878 | 
 879 |       expect(result.edges[0].id).toMatch(/^edge_0_[a-z0-9]+$/);
 880 |     });
 881 | 
 882 |     it('saves flow with optional fields', async () => {
 883 |       const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
 884 |         {
 885 |           method: 'rr_v3.saveFlow',
 886 |           params: {
 887 |             flow: {
 888 |               name: 'Test',
 889 |               description: 'A test flow',
 890 |               entryNodeId: 'node-1',
 891 |               nodes: [
 892 |                 { id: 'node-1', kind: 'test', config: {}, name: 'Start Node', disabled: false },
 893 |               ],
 894 |               edges: [],
 895 |               // 符合 VariableDefinition 类型:name 必填,description/default/label 可选
 896 |               variables: [
 897 |                 { name: 'url', description: 'Target URL', default: 'https://example.com' },
 898 |               ],
 899 |               // 符合 FlowPolicy 类型
 900 |               policy: { runTimeoutMs: 30000, defaultNodePolicy: { onError: { kind: 'stop' } } },
 901 |               meta: { tags: ['test', 'demo'] },
 902 |             },
 903 |           },
 904 |           requestId: 'req-1',
 905 |         },
 906 |         { subscriptions: new Set() },
 907 |       )) as FlowV3;
 908 | 
 909 |       expect(result.description).toBe('A test flow');
 910 |       expect(result.variables).toHaveLength(1);
 911 |       expect(result.policy).toEqual({
 912 |         runTimeoutMs: 30000,
 913 |         defaultNodePolicy: { onError: { kind: 'stop' } },
 914 |       });
 915 |       expect(result.meta).toEqual({ tags: ['test', 'demo'] });
 916 |       expect(result.nodes[0].name).toBe('Start Node');
 917 |     });
 918 | 
 919 |     it('throws if variable is missing name', async () => {
 920 |       await expect(
 921 |         (server as unknown as { handleRequest: Function }).handleRequest(
 922 |           {
 923 |             method: 'rr_v3.saveFlow',
 924 |             params: {
 925 |               flow: {
 926 |                 name: 'Test',
 927 |                 entryNodeId: 'node-1',
 928 |                 nodes: [{ id: 'node-1', kind: 'test', config: {} }],
 929 |                 variables: [{ description: 'Missing name field' }],
 930 |               },
 931 |             },
 932 |             requestId: 'req-1',
 933 |           },
 934 |           { subscriptions: new Set() },
 935 |         ),
 936 |       ).rejects.toThrow('flow.variables[0].name is required');
 937 |     });
 938 | 
 939 |     it('throws if duplicate variable names', async () => {
 940 |       await expect(
 941 |         (server as unknown as { handleRequest: Function }).handleRequest(
 942 |           {
 943 |             method: 'rr_v3.saveFlow',
 944 |             params: {
 945 |               flow: {
 946 |                 name: 'Test',
 947 |                 entryNodeId: 'node-1',
 948 |                 nodes: [{ id: 'node-1', kind: 'test', config: {} }],
 949 |                 variables: [
 950 |                   { name: 'myVar' },
 951 |                   { name: 'myVar' }, // duplicate
 952 |                 ],
 953 |               },
 954 |             },
 955 |             requestId: 'req-1',
 956 |           },
 957 |           { subscriptions: new Set() },
 958 |         ),
 959 |       ).rejects.toThrow('Duplicate variable name: "myVar"');
 960 |     });
 961 | 
 962 |     it('throws if duplicate node IDs', async () => {
 963 |       await expect(
 964 |         (server as unknown as { handleRequest: Function }).handleRequest(
 965 |           {
 966 |             method: 'rr_v3.saveFlow',
 967 |             params: {
 968 |               flow: {
 969 |                 name: 'Test',
 970 |                 entryNodeId: 'node-1',
 971 |                 nodes: [
 972 |                   { id: 'node-1', kind: 'test', config: {} },
 973 |                   { id: 'node-1', kind: 'test', config: {} }, // duplicate
 974 |                 ],
 975 |               },
 976 |             },
 977 |             requestId: 'req-1',
 978 |           },
 979 |           { subscriptions: new Set() },
 980 |         ),
 981 |       ).rejects.toThrow('Duplicate node ID: "node-1"');
 982 |     });
 983 | 
 984 |     it('throws if duplicate edge IDs', async () => {
 985 |       await expect(
 986 |         (server as unknown as { handleRequest: Function }).handleRequest(
 987 |           {
 988 |             method: 'rr_v3.saveFlow',
 989 |             params: {
 990 |               flow: {
 991 |                 name: 'Test',
 992 |                 entryNodeId: 'node-1',
 993 |                 nodes: [
 994 |                   { id: 'node-1', kind: 'test', config: {} },
 995 |                   { id: 'node-2', kind: 'test', config: {} },
 996 |                 ],
 997 |                 edges: [
 998 |                   { id: 'e1', from: 'node-1', to: 'node-2' },
 999 |                   { id: 'e1', from: 'node-2', to: 'node-1' }, // duplicate
1000 |                 ],
1001 |               },
1002 |             },
1003 |             requestId: 'req-1',
1004 |           },
1005 |           { subscriptions: new Set() },
1006 |         ),
1007 |       ).rejects.toThrow('Duplicate edge ID: "e1"');
1008 |     });
1009 |   });
1010 | 
1011 |   describe('rr_v3.deleteFlow', () => {
1012 |     it('deletes an existing flow', async () => {
1013 |       // Setup: add flow
1014 |       const flow = createTestFlow('flow-1');
1015 |       getInternal(storage).flowsMap.set(flow.id, flow);
1016 | 
1017 |       const result = await (server as unknown as { handleRequest: Function }).handleRequest(
1018 |         { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },
1019 |         { subscriptions: new Set() },
1020 |       );
1021 | 
1022 |       expect(storage.flows.delete).toHaveBeenCalledWith('flow-1');
1023 |       expect(result).toEqual({ ok: true, flowId: 'flow-1' });
1024 |     });
1025 | 
1026 |     it('throws if flowId is missing', async () => {
1027 |       await expect(
1028 |         (server as unknown as { handleRequest: Function }).handleRequest(
1029 |           { method: 'rr_v3.deleteFlow', params: {}, requestId: 'req-1' },
1030 |           { subscriptions: new Set() },
1031 |         ),
1032 |       ).rejects.toThrow('flowId is required');
1033 |     });
1034 | 
1035 |     it('throws if flow does not exist', async () => {
1036 |       await expect(
1037 |         (server as unknown as { handleRequest: Function }).handleRequest(
1038 |           { method: 'rr_v3.deleteFlow', params: { flowId: 'non-existent' }, requestId: 'req-1' },
1039 |           { subscriptions: new Set() },
1040 |         ),
1041 |       ).rejects.toThrow('Flow "non-existent" not found');
1042 |     });
1043 | 
1044 |     it('throws if flow has linked triggers', async () => {
1045 |       // Setup: add flow and trigger
1046 |       const flow = createTestFlow('flow-1');
1047 |       getInternal(storage).flowsMap.set(flow.id, flow);
1048 | 
1049 |       // Mock triggers.list to return a trigger linked to this flow
1050 |       (storage.triggers.list as ReturnType<typeof vi.fn>).mockResolvedValue([
1051 |         { id: 'trigger-1', kind: 'manual', flowId: 'flow-1', enabled: true },
1052 |       ]);
1053 | 
1054 |       await expect(
1055 |         (server as unknown as { handleRequest: Function }).handleRequest(
1056 |           { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },
1057 |           { subscriptions: new Set() },
1058 |         ),
1059 |       ).rejects.toThrow('Cannot delete flow "flow-1": it has 1 linked trigger(s): trigger-1');
1060 |     });
1061 | 
1062 |     it('throws if flow has multiple linked triggers', async () => {
1063 |       // Setup
1064 |       const flow = createTestFlow('flow-1');
1065 |       getInternal(storage).flowsMap.set(flow.id, flow);
1066 | 
1067 |       (storage.triggers.list as ReturnType<typeof vi.fn>).mockResolvedValue([
1068 |         { id: 'trigger-1', kind: 'manual', flowId: 'flow-1', enabled: true },
1069 |         { id: 'trigger-2', kind: 'cron', flowId: 'flow-1', enabled: true, cron: '0 * * * *' },
1070 |       ]);
1071 | 
1072 |       await expect(
1073 |         (server as unknown as { handleRequest: Function }).handleRequest(
1074 |           { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },
1075 |           { subscriptions: new Set() },
1076 |         ),
1077 |       ).rejects.toThrow(
1078 |         'Cannot delete flow "flow-1": it has 2 linked trigger(s): trigger-1, trigger-2',
1079 |       );
1080 |     });
1081 | 
1082 |     it('throws if flow has queued runs', async () => {
1083 |       // Setup
1084 |       const flow = createTestFlow('flow-1');
1085 |       getInternal(storage).flowsMap.set(flow.id, flow);
1086 | 
1087 |       // Add queued run
1088 |       getInternal(storage).queueMap.set('run-1', {
1089 |         id: 'run-1',
1090 |         flowId: 'flow-1',
1091 |         status: 'queued',
1092 |         priority: 0,
1093 |         createdAt: 1000,
1094 |         updatedAt: 1000,
1095 |         attempt: 0,
1096 |         maxAttempts: 1,
1097 |       });
1098 | 
1099 |       await expect(
1100 |         (server as unknown as { handleRequest: Function }).handleRequest(
1101 |           { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },
1102 |           { subscriptions: new Set() },
1103 |         ),
1104 |       ).rejects.toThrow('Cannot delete flow "flow-1": it has 1 queued run(s): run-1');
1105 |     });
1106 | 
1107 |     it('allows deletion when runs are running (not queued)', async () => {
1108 |       // Setup
1109 |       const flow = createTestFlow('flow-1');
1110 |       getInternal(storage).flowsMap.set(flow.id, flow);
1111 | 
1112 |       // Add running run (not queued) - should NOT block deletion
1113 |       getInternal(storage).queueMap.set('run-1', {
1114 |         id: 'run-1',
1115 |         flowId: 'flow-1',
1116 |         status: 'running', // running, not queued
1117 |         priority: 0,
1118 |         createdAt: 1000,
1119 |         updatedAt: 1000,
1120 |         attempt: 1,
1121 |         maxAttempts: 1,
1122 |       });
1123 | 
1124 |       const result = await (server as unknown as { handleRequest: Function }).handleRequest(
1125 |         { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },
1126 |         { subscriptions: new Set() },
1127 |       );
1128 | 
1129 |       expect(result).toEqual({ ok: true, flowId: 'flow-1' });
1130 |     });
1131 |   });
1132 | 
1133 |   describe('rr_v3.getFlow', () => {
1134 |     it('returns flow by id', async () => {
1135 |       const flow = createTestFlow('flow-1');
1136 |       getInternal(storage).flowsMap.set(flow.id, flow);
1137 | 
1138 |       const result = await (server as unknown as { handleRequest: Function }).handleRequest(
1139 |         { method: 'rr_v3.getFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },
1140 |         { subscriptions: new Set() },
1141 |       );
1142 | 
1143 |       expect(result).toEqual(flow);
1144 |     });
1145 | 
1146 |     it('returns null for non-existent flow', async () => {
1147 |       const result = await (server as unknown as { handleRequest: Function }).handleRequest(
1148 |         { method: 'rr_v3.getFlow', params: { flowId: 'non-existent' }, requestId: 'req-1' },
1149 |         { subscriptions: new Set() },
1150 |       );
1151 | 
1152 |       expect(result).toBeNull();
1153 |     });
1154 | 
1155 |     it('throws if flowId is missing', async () => {
1156 |       await expect(
1157 |         (server as unknown as { handleRequest: Function }).handleRequest(
1158 |           { method: 'rr_v3.getFlow', params: {}, requestId: 'req-1' },
1159 |           { subscriptions: new Set() },
1160 |         ),
1161 |       ).rejects.toThrow('flowId is required');
1162 |     });
1163 |   });
1164 | 
1165 |   describe('rr_v3.listFlows', () => {
1166 |     it('returns all flows', async () => {
1167 |       const flow1 = createTestFlow('flow-1');
1168 |       const flow2 = createTestFlow('flow-2');
1169 |       getInternal(storage).flowsMap.set(flow1.id, flow1);
1170 |       getInternal(storage).flowsMap.set(flow2.id, flow2);
1171 | 
1172 |       const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
1173 |         { method: 'rr_v3.listFlows', params: {}, requestId: 'req-1' },
1174 |         { subscriptions: new Set() },
1175 |       )) as FlowV3[];
1176 | 
1177 |       expect(result).toHaveLength(2);
1178 |       expect(result.map((f) => f.id).sort()).toEqual(['flow-1', 'flow-2']);
1179 |     });
1180 | 
1181 |     it('returns empty array when no flows exist', async () => {
1182 |       const result = await (server as unknown as { handleRequest: Function }).handleRequest(
1183 |         { method: 'rr_v3.listFlows', params: {}, requestId: 'req-1' },
1184 |         { subscriptions: new Set() },
1185 |       );
1186 | 
1187 |       expect(result).toEqual([]);
1188 |     });
1189 |   });
1190 | });
1191 | 
```
Page 41/60FirstPrevNextLast