This is page 43 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/web-editor-v2/core/cssom-styles-collector.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * CSSOM Styles Collector (Phase 4.6)
3 | *
4 | * Provides CSS rule collection and cascade computation using CSSOM.
5 | * Used for the CSS panel's style source tracking feature.
6 | *
7 | * Design goals:
8 | * - Collect matched CSS rules for an element via CSSOM
9 | * - Compute cascade (specificity + source order + !important)
10 | * - Track inherited styles from ancestor elements
11 | * - Handle Shadow DOM stylesheets
12 | * - Produce UI-ready snapshot for rendering
13 | *
14 | * Limitations (CSSOM-only approach):
15 | * - No reliable file:line info (only href/label available)
16 | * - @container/@scope rules are not evaluated
17 | * - @layer ordering is approximated via source order
18 | */
19 |
20 | // =============================================================================
21 | // Public Types (UI-ready snapshot)
22 | // =============================================================================
23 |
24 | export type Specificity = readonly [inline: number, ids: number, classes: number, types: number];
25 |
26 | export type DeclStatus = 'active' | 'overridden';
27 |
28 | export interface CssRuleSource {
29 | url?: string;
30 | label: string;
31 | }
32 |
33 | export interface CssDeclView {
34 | id: string;
35 | name: string;
36 | value: string;
37 | important: boolean;
38 | affects: readonly string[];
39 | status: DeclStatus;
40 | }
41 |
42 | export interface CssRuleView {
43 | id: string;
44 | origin: 'inline' | 'rule';
45 | selector: string;
46 | matchedSelector?: string;
47 | specificity?: Specificity;
48 | source?: CssRuleSource;
49 | order: number;
50 | decls: CssDeclView[];
51 | }
52 |
53 | export interface CssSectionView {
54 | kind: 'inline' | 'matched' | 'inherited';
55 | title: string;
56 | inheritedFrom?: { label: string };
57 | rules: CssRuleView[];
58 | }
59 |
60 | export interface CssPanelSnapshot {
61 | target: {
62 | label: string;
63 | root: 'document' | 'shadow';
64 | };
65 | warnings: string[];
66 | stats: {
67 | roots: number;
68 | styleSheets: number;
69 | rulesScanned: number;
70 | matchedRules: number;
71 | };
72 | sections: CssSectionView[];
73 | }
74 |
75 | // =============================================================================
76 | // Internal Types (cascade + collection)
77 | // =============================================================================
78 |
79 | interface DeclCandidate {
80 | id: string;
81 | important: boolean;
82 | specificity: Specificity;
83 | sourceOrder: readonly [sheetIndex: number, ruleOrder: number, declIndex: number];
84 | property: string;
85 | value: string;
86 | affects: readonly string[];
87 | ownerRuleId: string;
88 | ownerElementId: number;
89 | }
90 |
91 | interface FlatStyleRule {
92 | sheetIndex: number;
93 | order: number;
94 | selectorText: string;
95 | style: CSSStyleDeclaration;
96 | source: CssRuleSource;
97 | }
98 |
99 | interface RuleIndex {
100 | root: Document | ShadowRoot;
101 | rootId: number;
102 | flatRules: FlatStyleRule[];
103 | warnings: string[];
104 | stats: { styleSheets: number; rulesScanned: number };
105 | }
106 |
107 | interface CollectElementOptions {
108 | includeInline: boolean;
109 | declFilter: (decl: { property: string; affects: readonly string[] }) => boolean;
110 | }
111 |
112 | interface CollectedElementRules {
113 | element: Element;
114 | elementId: number;
115 | root: Document | ShadowRoot;
116 | rootType: 'document' | 'shadow';
117 | inlineRule: CssRuleView | null;
118 | matchedRules: CssRuleView[];
119 | candidates: DeclCandidate[];
120 | warnings: string[];
121 | stats: { matchedRules: number };
122 | }
123 |
124 | // =============================================================================
125 | // Specificity (Selectors Level 4)
126 | // =============================================================================
127 |
128 | const ZERO_SPEC: Specificity = [0, 0, 0, 0] as const;
129 |
130 | export function compareSpecificity(a: Specificity, b: Specificity): number {
131 | for (let i = 0; i < 4; i++) {
132 | if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1;
133 | }
134 | return 0;
135 | }
136 |
137 | function splitSelectorList(input: string): string[] {
138 | const out: string[] = [];
139 | let start = 0;
140 | let depthParen = 0;
141 | let depthBrack = 0;
142 | let quote: "'" | '"' | null = null;
143 |
144 | for (let i = 0; i < input.length; i++) {
145 | const ch = input[i];
146 |
147 | if (quote) {
148 | if (ch === '\\') {
149 | i += 1;
150 | continue;
151 | }
152 | if (ch === quote) quote = null;
153 | continue;
154 | }
155 |
156 | if (ch === '"' || ch === "'") {
157 | quote = ch;
158 | continue;
159 | }
160 |
161 | if (ch === '\\') {
162 | i += 1;
163 | continue;
164 | }
165 |
166 | if (ch === '[') depthBrack += 1;
167 | else if (ch === ']' && depthBrack > 0) depthBrack -= 1;
168 | else if (ch === '(') depthParen += 1;
169 | else if (ch === ')' && depthParen > 0) depthParen -= 1;
170 |
171 | if (ch === ',' && depthParen === 0 && depthBrack === 0) {
172 | const part = input.slice(start, i).trim();
173 | if (part) out.push(part);
174 | start = i + 1;
175 | }
176 | }
177 |
178 | const tail = input.slice(start).trim();
179 | if (tail) out.push(tail);
180 | return out;
181 | }
182 |
183 | function maxSpecificity(list: readonly Specificity[]): Specificity {
184 | let best: Specificity = ZERO_SPEC;
185 | for (const s of list) if (compareSpecificity(s, best) > 0) best = s;
186 | return best;
187 | }
188 |
189 | function computeSelectorSpecificity(selector: string): Specificity {
190 | let ids = 0;
191 | let classes = 0;
192 | let types = 0;
193 |
194 | let expectType = true;
195 |
196 | for (let i = 0; i < selector.length; i++) {
197 | const ch = selector[i];
198 |
199 | if (ch === '\\') {
200 | i += 1;
201 | continue;
202 | }
203 |
204 | if (ch === '[') {
205 | classes += 1;
206 | i = consumeBracket(selector, i);
207 | expectType = false;
208 | continue;
209 | }
210 |
211 | if (isCombinatorOrWhitespace(selector, i)) {
212 | i = consumeWhitespaceAndCombinators(selector, i);
213 | expectType = true;
214 | continue;
215 | }
216 |
217 | if (ch === '#') {
218 | ids += 1;
219 | i = consumeIdent(selector, i + 1) - 1;
220 | expectType = false;
221 | continue;
222 | }
223 |
224 | if (ch === '.') {
225 | classes += 1;
226 | i = consumeIdent(selector, i + 1) - 1;
227 | expectType = false;
228 | continue;
229 | }
230 |
231 | if (ch === ':') {
232 | const isPseudoEl = selector[i + 1] === ':';
233 | if (isPseudoEl) {
234 | types += 1;
235 | const nameStart = i + 2;
236 | const nameEnd = consumeIdent(selector, nameStart);
237 | const name = selector.slice(nameStart, nameEnd).toLowerCase();
238 | i = nameEnd - 1;
239 |
240 | if (selector[i + 1] === '(' && name === 'slotted') {
241 | const { content, endIndex } = consumeParenFunction(selector, i + 1);
242 | const maxArg = maxSpecificity(splitSelectorList(content).map(computeSelectorSpecificity));
243 | ids += maxArg[1];
244 | classes += maxArg[2];
245 | types += maxArg[3];
246 | i = endIndex;
247 | }
248 |
249 | expectType = false;
250 | continue;
251 | }
252 |
253 | const nameStart = i + 1;
254 | const nameEnd = consumeIdent(selector, nameStart);
255 | const name = selector.slice(nameStart, nameEnd).toLowerCase();
256 |
257 | if (LEGACY_PSEUDO_ELEMENTS.has(name)) {
258 | types += 1;
259 | i = nameEnd - 1;
260 | expectType = false;
261 | continue;
262 | }
263 |
264 | if (selector[nameEnd] === '(') {
265 | const { content, endIndex } = consumeParenFunction(selector, nameEnd);
266 | i = endIndex;
267 |
268 | if (name === 'where') {
269 | expectType = false;
270 | continue;
271 | }
272 |
273 | if (name === 'is' || name === 'not' || name === 'has') {
274 | const maxArg = maxSpecificity(splitSelectorList(content).map(computeSelectorSpecificity));
275 | ids += maxArg[1];
276 | classes += maxArg[2];
277 | types += maxArg[3];
278 | expectType = false;
279 | continue;
280 | }
281 |
282 | if (name === 'nth-child' || name === 'nth-last-child') {
283 | classes += 1;
284 | const ofSelectors = extractNthOfSelectorList(content);
285 | if (ofSelectors) {
286 | const maxArg = maxSpecificity(
287 | splitSelectorList(ofSelectors).map(computeSelectorSpecificity),
288 | );
289 | ids += maxArg[1];
290 | classes += maxArg[2];
291 | types += maxArg[3];
292 | }
293 | expectType = false;
294 | continue;
295 | }
296 |
297 | // Other functional pseudo-classes count as class specificity (+1).
298 | classes += 1;
299 | expectType = false;
300 | continue;
301 | }
302 |
303 | classes += 1;
304 | i = nameEnd - 1;
305 | expectType = false;
306 | continue;
307 | }
308 |
309 | if (expectType) {
310 | if (ch === '*') {
311 | expectType = false;
312 | continue;
313 | }
314 | if (isIdentStart(ch)) {
315 | types += 1;
316 | i = consumeIdent(selector, i + 1) - 1;
317 | expectType = false;
318 | continue;
319 | }
320 | }
321 | }
322 |
323 | return [0, ids, classes, types] as const;
324 | }
325 |
326 | /**
327 | * For a selector list, returns the matched selector with max specificity among matches.
328 | */
329 | function computeMatchedRuleSpecificity(
330 | element: Element,
331 | selectorText: string,
332 | ): { matchedSelector: string; specificity: Specificity } | null {
333 | const selectors = splitSelectorList(selectorText);
334 | let bestSel: string | null = null;
335 | let bestSpec: Specificity = ZERO_SPEC;
336 |
337 | for (const sel of selectors) {
338 | try {
339 | if (!element.matches(sel)) continue;
340 | const spec = computeSelectorSpecificity(sel);
341 | if (!bestSel || compareSpecificity(spec, bestSpec) > 0) {
342 | bestSel = sel;
343 | bestSpec = spec;
344 | }
345 | } catch {
346 | // Invalid selector for matches() (e.g. pseudo-elements) => ignore.
347 | }
348 | }
349 |
350 | return bestSel ? { matchedSelector: bestSel, specificity: bestSpec } : null;
351 | }
352 |
353 | const LEGACY_PSEUDO_ELEMENTS = new Set([
354 | 'before',
355 | 'after',
356 | 'first-line',
357 | 'first-letter',
358 | 'selection',
359 | 'backdrop',
360 | 'placeholder',
361 | ]);
362 |
363 | function isIdentStart(ch: string): boolean {
364 | return /[a-zA-Z_]/.test(ch) || ch.charCodeAt(0) >= 0x80;
365 | }
366 |
367 | function consumeIdent(s: string, start: number): number {
368 | let i = start;
369 | for (; i < s.length; i++) {
370 | const ch = s[i];
371 | if (ch === '\\') {
372 | i += 1;
373 | continue;
374 | }
375 | if (/[a-zA-Z0-9_-]/.test(ch) || ch.charCodeAt(0) >= 0x80) continue;
376 | break;
377 | }
378 | return i;
379 | }
380 |
381 | function consumeBracket(s: string, openIndex: number): number {
382 | let depth = 1;
383 | let quote: "'" | '"' | null = null;
384 |
385 | for (let i = openIndex + 1; i < s.length; i++) {
386 | const ch = s[i];
387 | if (quote) {
388 | if (ch === '\\') {
389 | i += 1;
390 | continue;
391 | }
392 | if (ch === quote) quote = null;
393 | continue;
394 | }
395 | if (ch === '"' || ch === "'") {
396 | quote = ch;
397 | continue;
398 | }
399 | if (ch === '\\') {
400 | i += 1;
401 | continue;
402 | }
403 | if (ch === '[') depth += 1;
404 | else if (ch === ']') {
405 | depth -= 1;
406 | if (depth === 0) return i;
407 | }
408 | }
409 | return s.length - 1;
410 | }
411 |
412 | function consumeParenFunction(
413 | s: string,
414 | openParenIndex: number,
415 | ): { content: string; endIndex: number } {
416 | let depth = 1;
417 | let quote: "'" | '"' | null = null;
418 |
419 | for (let i = openParenIndex + 1; i < s.length; i++) {
420 | const ch = s[i];
421 | if (quote) {
422 | if (ch === '\\') {
423 | i += 1;
424 | continue;
425 | }
426 | if (ch === quote) quote = null;
427 | continue;
428 | }
429 | if (ch === '"' || ch === "'") {
430 | quote = ch;
431 | continue;
432 | }
433 | if (ch === '\\') {
434 | i += 1;
435 | continue;
436 | }
437 | if (ch === '[') i = consumeBracket(s, i);
438 | else if (ch === '(') depth += 1;
439 | else if (ch === ')') {
440 | depth -= 1;
441 | if (depth === 0) return { content: s.slice(openParenIndex + 1, i), endIndex: i };
442 | }
443 | }
444 | return { content: s.slice(openParenIndex + 1), endIndex: s.length - 1 };
445 | }
446 |
447 | function isCombinatorOrWhitespace(s: string, i: number): boolean {
448 | const ch = s[i];
449 | return /\s/.test(ch) || ch === '>' || ch === '+' || ch === '~' || ch === '|';
450 | }
451 |
452 | function consumeWhitespaceAndCombinators(s: string, i: number): number {
453 | let j = i;
454 | while (j < s.length && /\s/.test(s[j])) j++;
455 | if (s[j] === '|' && s[j + 1] === '|') return j + 1;
456 | if (s[j] === '>' || s[j] === '+' || s[j] === '~' || s[j] === '|') return j;
457 | return j - 1;
458 | }
459 |
460 | function extractNthOfSelectorList(content: string): string | null {
461 | let depthParen = 0;
462 | let depthBrack = 0;
463 | let quote: "'" | '"' | null = null;
464 |
465 | for (let i = 0; i < content.length; i++) {
466 | const ch = content[i];
467 |
468 | if (quote) {
469 | if (ch === '\\') {
470 | i += 1;
471 | continue;
472 | }
473 | if (ch === quote) quote = null;
474 | continue;
475 | }
476 |
477 | if (ch === '"' || ch === "'") {
478 | quote = ch;
479 | continue;
480 | }
481 |
482 | if (ch === '\\') {
483 | i += 1;
484 | continue;
485 | }
486 |
487 | if (ch === '[') depthBrack += 1;
488 | else if (ch === ']' && depthBrack > 0) depthBrack -= 1;
489 | else if (ch === '(') depthParen += 1;
490 | else if (ch === ')' && depthParen > 0) depthParen -= 1;
491 |
492 | if (depthParen === 0 && depthBrack === 0) {
493 | if (isOfTokenAt(content, i)) return content.slice(i + 2).trimStart();
494 | }
495 | }
496 |
497 | return null;
498 | }
499 |
500 | function isOfTokenAt(s: string, i: number): boolean {
501 | if (s[i] !== 'o' || s[i + 1] !== 'f') return false;
502 | const prev = s[i - 1];
503 | const next = s[i + 2];
504 | const prevOk = prev === undefined || /\s/.test(prev);
505 | const nextOk = next === undefined || /\s/.test(next);
506 | return prevOk && nextOk;
507 | }
508 |
509 | // =============================================================================
510 | // Inherited properties
511 | // =============================================================================
512 |
513 | export const INHERITED_PROPERTIES = new Set<string>([
514 | // Color & appearance
515 | 'color',
516 | 'color-scheme',
517 | 'caret-color',
518 | 'accent-color',
519 |
520 | // Typography / fonts
521 | 'font',
522 | 'font-family',
523 | 'font-feature-settings',
524 | 'font-kerning',
525 | 'font-language-override',
526 | 'font-optical-sizing',
527 | 'font-palette',
528 | 'font-size',
529 | 'font-size-adjust',
530 | 'font-stretch',
531 | 'font-style',
532 | 'font-synthesis',
533 | 'font-synthesis-small-caps',
534 | 'font-synthesis-style',
535 | 'font-synthesis-weight',
536 | 'font-variant',
537 | 'font-variant-alternates',
538 | 'font-variant-caps',
539 | 'font-variant-east-asian',
540 | 'font-variant-emoji',
541 | 'font-variant-ligatures',
542 | 'font-variant-numeric',
543 | 'font-variant-position',
544 | 'font-variation-settings',
545 | 'font-weight',
546 | 'letter-spacing',
547 | 'line-height',
548 | 'text-rendering',
549 | 'text-size-adjust',
550 | 'text-transform',
551 | 'text-indent',
552 | 'text-align',
553 | 'text-align-last',
554 | 'text-justify',
555 | 'text-shadow',
556 | 'text-emphasis-color',
557 | 'text-emphasis-position',
558 | 'text-emphasis-style',
559 | 'text-underline-position',
560 | 'tab-size',
561 | 'white-space',
562 | 'word-break',
563 | 'overflow-wrap',
564 | 'word-spacing',
565 | 'hyphens',
566 | 'line-break',
567 |
568 | // Writing / bidi
569 | 'direction',
570 | 'unicode-bidi',
571 | 'writing-mode',
572 | 'text-orientation',
573 | 'text-combine-upright',
574 |
575 | // Lists
576 | 'list-style',
577 | 'list-style-image',
578 | 'list-style-position',
579 | 'list-style-type',
580 |
581 | // Tables
582 | 'border-collapse',
583 | 'border-spacing',
584 | 'caption-side',
585 | 'empty-cells',
586 |
587 | // Visibility / interaction
588 | 'cursor',
589 | 'visibility',
590 | 'pointer-events',
591 | 'user-select',
592 |
593 | // Quotes & pagination
594 | 'quotes',
595 | 'orphans',
596 | 'widows',
597 |
598 | // SVG
599 | 'fill',
600 | 'fill-opacity',
601 | 'fill-rule',
602 | 'stroke',
603 | 'stroke-width',
604 | 'stroke-linecap',
605 | 'stroke-linejoin',
606 | 'stroke-miterlimit',
607 | 'stroke-dasharray',
608 | 'stroke-dashoffset',
609 | 'stroke-opacity',
610 | 'paint-order',
611 | 'shape-rendering',
612 | 'image-rendering',
613 | 'color-interpolation',
614 | 'color-interpolation-filters',
615 | 'color-rendering',
616 | 'dominant-baseline',
617 | 'alignment-baseline',
618 | 'baseline-shift',
619 | 'text-anchor',
620 | 'stop-color',
621 | 'stop-opacity',
622 | 'flood-color',
623 | 'flood-opacity',
624 | 'lighting-color',
625 | 'marker',
626 | 'marker-start',
627 | 'marker-mid',
628 | 'marker-end',
629 | ]);
630 |
631 | export function isInheritableProperty(property: string): boolean {
632 | const p = String(property || '').trim();
633 | if (!p) return false;
634 | if (p.startsWith('--')) return true;
635 | return INHERITED_PROPERTIES.has(p.toLowerCase());
636 | }
637 |
638 | // =============================================================================
639 | // Shorthand expansion
640 | // =============================================================================
641 |
642 | export const SHORTHAND_TO_LONGHANDS: Record<string, readonly string[]> = {
643 | // Spacing
644 | margin: ['margin-top', 'margin-right', 'margin-bottom', 'margin-left'],
645 | padding: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'],
646 | inset: ['top', 'right', 'bottom', 'left'],
647 |
648 | // Border
649 | border: [
650 | 'border-top-width',
651 | 'border-right-width',
652 | 'border-bottom-width',
653 | 'border-left-width',
654 | 'border-top-style',
655 | 'border-right-style',
656 | 'border-bottom-style',
657 | 'border-left-style',
658 | 'border-top-color',
659 | 'border-right-color',
660 | 'border-bottom-color',
661 | 'border-left-color',
662 | ],
663 | 'border-width': [
664 | 'border-top-width',
665 | 'border-right-width',
666 | 'border-bottom-width',
667 | 'border-left-width',
668 | ],
669 | 'border-style': [
670 | 'border-top-style',
671 | 'border-right-style',
672 | 'border-bottom-style',
673 | 'border-left-style',
674 | ],
675 | 'border-color': [
676 | 'border-top-color',
677 | 'border-right-color',
678 | 'border-bottom-color',
679 | 'border-left-color',
680 | ],
681 |
682 | 'border-top': ['border-top-width', 'border-top-style', 'border-top-color'],
683 | 'border-right': ['border-right-width', 'border-right-style', 'border-right-color'],
684 | 'border-bottom': ['border-bottom-width', 'border-bottom-style', 'border-bottom-color'],
685 | 'border-left': ['border-left-width', 'border-left-style', 'border-left-color'],
686 |
687 | 'border-radius': [
688 | 'border-top-left-radius',
689 | 'border-top-right-radius',
690 | 'border-bottom-right-radius',
691 | 'border-bottom-left-radius',
692 | ],
693 |
694 | outline: ['outline-color', 'outline-style', 'outline-width'],
695 |
696 | // Background
697 | background: [
698 | 'background-attachment',
699 | 'background-clip',
700 | 'background-color',
701 | 'background-image',
702 | 'background-origin',
703 | 'background-position',
704 | 'background-repeat',
705 | 'background-size',
706 | ],
707 |
708 | // Font
709 | font: [
710 | 'font-style',
711 | 'font-variant',
712 | 'font-weight',
713 | 'font-stretch',
714 | 'font-size',
715 | 'line-height',
716 | 'font-family',
717 | ],
718 |
719 | // Flexbox
720 | flex: ['flex-grow', 'flex-shrink', 'flex-basis'],
721 | 'flex-flow': ['flex-direction', 'flex-wrap'],
722 |
723 | // Alignment
724 | 'place-content': ['align-content', 'justify-content'],
725 | 'place-items': ['align-items', 'justify-items'],
726 | 'place-self': ['align-self', 'justify-self'],
727 |
728 | // Gaps
729 | gap: ['row-gap', 'column-gap'],
730 | 'grid-gap': ['row-gap', 'column-gap'],
731 |
732 | // Overflow
733 | overflow: ['overflow-x', 'overflow-y'],
734 |
735 | // Grid
736 | 'grid-area': ['grid-row-start', 'grid-column-start', 'grid-row-end', 'grid-column-end'],
737 | 'grid-row': ['grid-row-start', 'grid-row-end'],
738 | 'grid-column': ['grid-column-start', 'grid-column-end'],
739 | 'grid-template': ['grid-template-rows', 'grid-template-columns', 'grid-template-areas'],
740 |
741 | // Text
742 | 'text-emphasis': ['text-emphasis-style', 'text-emphasis-color'],
743 | 'text-decoration': [
744 | 'text-decoration-line',
745 | 'text-decoration-style',
746 | 'text-decoration-color',
747 | 'text-decoration-thickness',
748 | ],
749 |
750 | // Animations / transitions
751 | transition: [
752 | 'transition-property',
753 | 'transition-duration',
754 | 'transition-timing-function',
755 | 'transition-delay',
756 | ],
757 | animation: [
758 | 'animation-name',
759 | 'animation-duration',
760 | 'animation-timing-function',
761 | 'animation-delay',
762 | 'animation-iteration-count',
763 | 'animation-direction',
764 | 'animation-fill-mode',
765 | 'animation-play-state',
766 | ],
767 |
768 | // Multi-column
769 | columns: ['column-width', 'column-count'],
770 | 'column-rule': ['column-rule-width', 'column-rule-style', 'column-rule-color'],
771 |
772 | // Lists
773 | 'list-style': ['list-style-position', 'list-style-image', 'list-style-type'],
774 | };
775 |
776 | export function expandToLonghands(property: string): readonly string[] {
777 | const raw = String(property || '').trim();
778 | if (!raw) return [];
779 | if (raw.startsWith('--')) return [raw];
780 | const p = raw.toLowerCase();
781 | return SHORTHAND_TO_LONGHANDS[p] ?? [p];
782 | }
783 |
784 | function normalizePropertyName(property: string): string {
785 | const raw = String(property || '').trim();
786 | if (!raw) return '';
787 | if (raw.startsWith('--')) return raw;
788 | return raw.toLowerCase();
789 | }
790 |
791 | // =============================================================================
792 | // Cascade / override
793 | // =============================================================================
794 |
795 | function compareSourceOrder(
796 | a: readonly [number, number, number],
797 | b: readonly [number, number, number],
798 | ): number {
799 | if (a[0] !== b[0]) return a[0] > b[0] ? 1 : -1;
800 | if (a[1] !== b[1]) return a[1] > b[1] ? 1 : -1;
801 | if (a[2] !== b[2]) return a[2] > b[2] ? 1 : -1;
802 | return 0;
803 | }
804 |
805 | function compareCascade(a: DeclCandidate, b: DeclCandidate): number {
806 | if (a.important !== b.important) return a.important ? 1 : -1;
807 | const spec = compareSpecificity(a.specificity, b.specificity);
808 | if (spec !== 0) return spec;
809 | return compareSourceOrder(a.sourceOrder, b.sourceOrder);
810 | }
811 |
812 | function computeOverrides(candidates: readonly DeclCandidate[]): {
813 | winners: Map<string, DeclCandidate>;
814 | declStatus: Map<string, DeclStatus>;
815 | } {
816 | const winners = new Map<string, DeclCandidate>();
817 |
818 | for (const cand of candidates) {
819 | for (const longhand of cand.affects) {
820 | const cur = winners.get(longhand);
821 | if (!cur || compareCascade(cand, cur) > 0) winners.set(longhand, cand);
822 | }
823 | }
824 |
825 | const declStatus = new Map<string, DeclStatus>();
826 | for (const cand of candidates) declStatus.set(cand.id, 'overridden');
827 | for (const [, winner] of winners) declStatus.set(winner.id, 'active');
828 |
829 | return { winners, declStatus };
830 | }
831 |
832 | // =============================================================================
833 | // CSSOM Rule Index
834 | // =============================================================================
835 |
836 | const CONTAINER_RULE = (globalThis as unknown as { CSSRule?: { CONTAINER_RULE?: number } }).CSSRule
837 | ?.CONTAINER_RULE;
838 | const SCOPE_RULE = (globalThis as unknown as { CSSRule?: { SCOPE_RULE?: number } }).CSSRule
839 | ?.SCOPE_RULE;
840 |
841 | function isSheetApplicable(sheet: CSSStyleSheet): boolean {
842 | if ((sheet as { disabled?: boolean }).disabled) return false;
843 |
844 | try {
845 | const mediaText = sheet.media?.mediaText?.trim() ?? '';
846 | if (!mediaText || mediaText.toLowerCase() === 'all') return true;
847 | return window.matchMedia(mediaText).matches;
848 | } catch {
849 | return true;
850 | }
851 | }
852 |
853 | function describeStyleSheet(sheet: CSSStyleSheet, fallbackIndex: number): CssRuleSource {
854 | const href = typeof sheet.href === 'string' ? sheet.href : undefined;
855 |
856 | if (href) {
857 | const file = href.split('/').pop()?.split('?')[0] ?? href;
858 | return { url: href, label: file };
859 | }
860 |
861 | const ownerNode = sheet.ownerNode as Node | null | undefined;
862 | if (ownerNode && ownerNode.nodeType === Node.ELEMENT_NODE) {
863 | const el = ownerNode as Element;
864 | if (el.tagName === 'STYLE') return { label: `<style #${fallbackIndex}>` };
865 | if (el.tagName === 'LINK') return { label: `<link #${fallbackIndex}>` };
866 | }
867 |
868 | return { label: `<constructed #${fallbackIndex}>` };
869 | }
870 |
871 | function safeReadCssRules(sheet: CSSStyleSheet): CSSRuleList | null {
872 | try {
873 | return sheet.cssRules;
874 | } catch {
875 | return null;
876 | }
877 | }
878 |
879 | function evalMediaRule(rule: CSSMediaRule, warnings: string[]): boolean {
880 | try {
881 | const mediaText = rule.media?.mediaText?.trim() ?? '';
882 | if (!mediaText || mediaText.toLowerCase() === 'all') return true;
883 | return window.matchMedia(mediaText).matches;
884 | } catch (e) {
885 | warnings.push(`Failed to evaluate @media rule: ${String(e)}`);
886 | return false;
887 | }
888 | }
889 |
890 | function evalSupportsRule(rule: CSSSupportsRule, warnings: string[]): boolean {
891 | try {
892 | const cond = rule.conditionText?.trim() ?? '';
893 | if (!cond) return true;
894 | if (typeof CSS?.supports !== 'function') return true;
895 | return CSS.supports(cond);
896 | } catch (e) {
897 | warnings.push(`Failed to evaluate @supports rule: ${String(e)}`);
898 | return false;
899 | }
900 | }
901 |
902 | function createRuleIndexForRoot(root: Document | ShadowRoot, rootId: number): RuleIndex {
903 | const warnings: string[] = [];
904 | const flatRules: FlatStyleRule[] = [];
905 | let rulesScanned = 0;
906 |
907 | const docOrShadow = root as DocumentOrShadowRoot;
908 | const styleSheets: CSSStyleSheet[] = [];
909 |
910 | try {
911 | for (const s of Array.from(docOrShadow.styleSheets ?? [])) {
912 | if (s && s instanceof CSSStyleSheet) styleSheets.push(s);
913 | }
914 | } catch {
915 | // ignore
916 | }
917 |
918 | try {
919 | const adopted = Array.from(docOrShadow.adoptedStyleSheets ?? []) as CSSStyleSheet[];
920 | for (const s of adopted) if (s && s instanceof CSSStyleSheet) styleSheets.push(s);
921 | } catch {
922 | // ignore
923 | }
924 |
925 | let order = 0;
926 |
927 | function walkRuleList(
928 | list: CSSRuleList,
929 | ctx: {
930 | sheetIndex: number;
931 | sourceForRules: CssRuleSource;
932 | topSheet: CSSStyleSheet;
933 | stack: Set<CSSStyleSheet>;
934 | },
935 | ): void {
936 | for (const rule of Array.from(list)) {
937 | rulesScanned += 1;
938 |
939 | if (CONTAINER_RULE && rule.type === CONTAINER_RULE) {
940 | warnings.push('Skipped @container rules (not evaluated in CSSOM collector)');
941 | continue;
942 | }
943 |
944 | if (SCOPE_RULE && rule.type === SCOPE_RULE) {
945 | warnings.push('Skipped @scope rules (not evaluated in CSSOM collector)');
946 | continue;
947 | }
948 |
949 | if (rule.type === CSSRule.IMPORT_RULE) {
950 | const importRule = rule as CSSImportRule;
951 |
952 | try {
953 | const mediaText = importRule.media?.mediaText?.trim() ?? '';
954 | if (
955 | mediaText &&
956 | mediaText.toLowerCase() !== 'all' &&
957 | !window.matchMedia(mediaText).matches
958 | ) {
959 | continue;
960 | }
961 | } catch {
962 | // ignore
963 | }
964 |
965 | const imported = importRule.styleSheet;
966 | if (imported) {
967 | // Check for cycle BEFORE adding to stack
968 | if (ctx.stack.has(imported)) {
969 | const src = describeStyleSheet(imported, ctx.sheetIndex);
970 | warnings.push(`Detected @import cycle, skipping: ${src.url ?? src.label}`);
971 | continue;
972 | }
973 |
974 | // Add to stack, process, then remove
975 | ctx.stack.add(imported);
976 | try {
977 | // Recursively walk the imported stylesheet
978 | if (!isSheetApplicable(imported)) {
979 | continue;
980 | }
981 |
982 | const cssRules = safeReadCssRules(imported);
983 | const src = describeStyleSheet(imported, ctx.sheetIndex);
984 |
985 | if (!cssRules) {
986 | warnings.push(
987 | `Skipped @import stylesheet (cannot access cssRules, likely cross-origin): ${src.url ?? src.label}`,
988 | );
989 | continue;
990 | }
991 |
992 | walkRuleList(cssRules, {
993 | sheetIndex: ctx.sheetIndex,
994 | sourceForRules: src,
995 | topSheet: imported,
996 | stack: ctx.stack,
997 | });
998 | } finally {
999 | ctx.stack.delete(imported);
1000 | }
1001 | }
1002 | continue;
1003 | }
1004 |
1005 | if (rule.type === CSSRule.MEDIA_RULE) {
1006 | if (evalMediaRule(rule as CSSMediaRule, warnings)) {
1007 | walkRuleList((rule as CSSMediaRule).cssRules, ctx);
1008 | }
1009 | continue;
1010 | }
1011 |
1012 | if (rule.type === CSSRule.SUPPORTS_RULE) {
1013 | if (evalSupportsRule(rule as CSSSupportsRule, warnings)) {
1014 | walkRuleList((rule as CSSSupportsRule).cssRules, ctx);
1015 | }
1016 | continue;
1017 | }
1018 |
1019 | if (rule.type === CSSRule.STYLE_RULE) {
1020 | const styleRule = rule as CSSStyleRule;
1021 | flatRules.push({
1022 | sheetIndex: ctx.sheetIndex,
1023 | order: order++,
1024 | selectorText: styleRule.selectorText ?? '',
1025 | style: styleRule.style,
1026 | source: ctx.sourceForRules,
1027 | });
1028 | continue;
1029 | }
1030 |
1031 | // Best-effort: traverse grouping rules we don't explicitly model (e.g. @layer blocks).
1032 | const anyRule = rule as { cssRules?: CSSRuleList };
1033 | if (anyRule.cssRules && typeof anyRule.cssRules.length === 'number') {
1034 | try {
1035 | walkRuleList(anyRule.cssRules, ctx);
1036 | } catch {
1037 | // ignore
1038 | }
1039 | }
1040 | }
1041 | }
1042 |
1043 | for (let sheetIndex = 0; sheetIndex < styleSheets.length; sheetIndex++) {
1044 | const sheet = styleSheets[sheetIndex]!;
1045 | if (!isSheetApplicable(sheet)) continue;
1046 |
1047 | const sheetSource = describeStyleSheet(sheet, sheetIndex);
1048 | const cssRules = safeReadCssRules(sheet);
1049 | if (!cssRules) {
1050 | warnings.push(
1051 | `Skipped stylesheet (cannot access cssRules, likely cross-origin): ${sheetSource.url ?? sheetSource.label}`,
1052 | );
1053 | continue;
1054 | }
1055 |
1056 | // Create a fresh recursion stack for each top-level stylesheet
1057 | const recursionStack = new Set<CSSStyleSheet>();
1058 | recursionStack.add(sheet); // Add self to prevent self-import cycles
1059 | walkRuleList(cssRules, {
1060 | sheetIndex,
1061 | sourceForRules: sheetSource,
1062 | topSheet: sheet,
1063 | stack: recursionStack,
1064 | });
1065 | }
1066 |
1067 | return {
1068 | root,
1069 | rootId,
1070 | flatRules,
1071 | warnings,
1072 | stats: { styleSheets: styleSheets.length, rulesScanned },
1073 | };
1074 | }
1075 |
1076 | // =============================================================================
1077 | // Per-element collection
1078 | // =============================================================================
1079 |
1080 | function readStyleDecls(style: CSSStyleDeclaration): Array<{
1081 | property: string;
1082 | value: string;
1083 | important: boolean;
1084 | declIndex: number;
1085 | }> {
1086 | const out: Array<{ property: string; value: string; important: boolean; declIndex: number }> = [];
1087 |
1088 | const len = Number(style?.length ?? 0);
1089 | for (let i = 0; i < len; i++) {
1090 | let prop = '';
1091 | try {
1092 | prop = style.item(i);
1093 | } catch {
1094 | prop = '';
1095 | }
1096 | prop = normalizePropertyName(prop);
1097 | if (!prop) continue;
1098 |
1099 | let value = '';
1100 | let important = false;
1101 | try {
1102 | value = style.getPropertyValue(prop) ?? '';
1103 | important = String(style.getPropertyPriority(prop) ?? '') === 'important';
1104 | } catch {
1105 | value = '';
1106 | important = false;
1107 | }
1108 |
1109 | out.push({ property: prop, value: String(value).trim(), important, declIndex: i });
1110 | }
1111 |
1112 | return out;
1113 | }
1114 |
1115 | function canReadInlineStyle(element: Element): element is Element & { style: CSSStyleDeclaration } {
1116 | const anyEl = element as { style?: CSSStyleDeclaration };
1117 | return (
1118 | !!anyEl.style &&
1119 | typeof anyEl.style.getPropertyValue === 'function' &&
1120 | typeof anyEl.style.getPropertyPriority === 'function'
1121 | );
1122 | }
1123 |
1124 | function formatElementLabel(element: Element, maxClasses = 2): string {
1125 | const tag = element.tagName.toLowerCase();
1126 | const id = (element as HTMLElement).id?.trim();
1127 | if (id) return `${tag}#${id}`;
1128 |
1129 | const classes = Array.from(element.classList ?? [])
1130 | .slice(0, maxClasses)
1131 | .filter(Boolean);
1132 | if (classes.length) return `${tag}.${classes.join('.')}`;
1133 |
1134 | return tag;
1135 | }
1136 |
1137 | function getElementRoot(element: Element): Document | ShadowRoot {
1138 | try {
1139 | const root = element.getRootNode?.();
1140 | return root instanceof ShadowRoot ? root : (element.ownerDocument ?? document);
1141 | } catch {
1142 | return element.ownerDocument ?? document;
1143 | }
1144 | }
1145 |
1146 | function getParentElementOrHost(element: Element): Element | null {
1147 | if (element.parentElement) return element.parentElement;
1148 |
1149 | try {
1150 | const root = element.getRootNode?.();
1151 | if (root instanceof ShadowRoot) return root.host;
1152 | } catch {
1153 | // ignore
1154 | }
1155 |
1156 | return null;
1157 | }
1158 |
1159 | function collectForElement(
1160 | element: Element,
1161 | index: RuleIndex,
1162 | elementId: number,
1163 | options: CollectElementOptions,
1164 | ): CollectedElementRules {
1165 | const warnings: string[] = [];
1166 | const matchedRules: CssRuleView[] = [];
1167 | const candidates: DeclCandidate[] = [];
1168 |
1169 | const rootType: 'document' | 'shadow' = index.root instanceof ShadowRoot ? 'shadow' : 'document';
1170 |
1171 | let inlineRule: CssRuleView | null = null;
1172 |
1173 | if (options.includeInline && canReadInlineStyle(element)) {
1174 | const declsRaw = readStyleDecls(element.style);
1175 | const decls: CssDeclView[] = [];
1176 |
1177 | for (const d of declsRaw) {
1178 | const affects = expandToLonghands(d.property);
1179 | if (!options.declFilter({ property: d.property, affects })) continue;
1180 |
1181 | const declId = `inline:${elementId}:${d.declIndex}`;
1182 |
1183 | decls.push({
1184 | id: declId,
1185 | name: d.property,
1186 | value: d.value,
1187 | important: d.important,
1188 | affects,
1189 | status: 'overridden',
1190 | });
1191 |
1192 | candidates.push({
1193 | id: declId,
1194 | important: d.important,
1195 | specificity: [1, 0, 0, 0] as const,
1196 | sourceOrder: [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, d.declIndex],
1197 | property: d.property,
1198 | value: d.value,
1199 | affects,
1200 | ownerRuleId: `inline:${elementId}`,
1201 | ownerElementId: elementId,
1202 | });
1203 | }
1204 |
1205 | inlineRule = {
1206 | id: `inline:${elementId}`,
1207 | origin: 'inline',
1208 | selector: 'element.style',
1209 | matchedSelector: 'element.style',
1210 | specificity: [1, 0, 0, 0] as const,
1211 | source: { label: 'element.style' },
1212 | order: Number.MAX_SAFE_INTEGER,
1213 | decls,
1214 | };
1215 | }
1216 |
1217 | for (const flat of index.flatRules) {
1218 | const match = computeMatchedRuleSpecificity(element, flat.selectorText);
1219 | if (!match) continue;
1220 |
1221 | const declsRaw = readStyleDecls(flat.style);
1222 | const decls: CssDeclView[] = [];
1223 | const ruleId = `rule:${index.rootId}:${flat.sheetIndex}:${flat.order}`;
1224 |
1225 | for (const d of declsRaw) {
1226 | const affects = expandToLonghands(d.property);
1227 | if (!options.declFilter({ property: d.property, affects })) continue;
1228 |
1229 | const declId = `${ruleId}:${d.declIndex}`;
1230 |
1231 | decls.push({
1232 | id: declId,
1233 | name: d.property,
1234 | value: d.value,
1235 | important: d.important,
1236 | affects,
1237 | status: 'overridden',
1238 | });
1239 |
1240 | candidates.push({
1241 | id: declId,
1242 | important: d.important,
1243 | specificity: match.specificity,
1244 | sourceOrder: [flat.sheetIndex, flat.order, d.declIndex],
1245 | property: d.property,
1246 | value: d.value,
1247 | affects,
1248 | ownerRuleId: ruleId,
1249 | ownerElementId: elementId,
1250 | });
1251 | }
1252 |
1253 | if (decls.length === 0) continue;
1254 |
1255 | matchedRules.push({
1256 | id: ruleId,
1257 | origin: 'rule',
1258 | selector: flat.selectorText,
1259 | matchedSelector: match.matchedSelector,
1260 | specificity: match.specificity,
1261 | source: flat.source,
1262 | order: flat.order,
1263 | decls,
1264 | });
1265 | }
1266 |
1267 | // Sort matched rules in a DevTools-like way (best-effort).
1268 | matchedRules.sort((a, b) => {
1269 | const sa = a.specificity ?? ZERO_SPEC;
1270 | const sb = b.specificity ?? ZERO_SPEC;
1271 | const spec = compareSpecificity(sb, sa); // desc
1272 | if (spec !== 0) return spec;
1273 | return b.order - a.order; // later first
1274 | });
1275 |
1276 | return {
1277 | element,
1278 | elementId,
1279 | root: index.root,
1280 | rootType,
1281 | inlineRule,
1282 | matchedRules,
1283 | candidates,
1284 | warnings,
1285 | stats: { matchedRules: matchedRules.length },
1286 | };
1287 | }
1288 |
1289 | // =============================================================================
1290 | // Public API
1291 | // =============================================================================
1292 |
1293 | /**
1294 | * Collect matched rules for ONE element (no inheritance), plus DeclCandidate[] used for cascade.
1295 | */
1296 | export function collectMatchedRules(element: Element): {
1297 | inlineRule: CssRuleView | null;
1298 | matchedRules: CssRuleView[];
1299 | candidates: DeclCandidate[];
1300 | warnings: string[];
1301 | stats: { styleSheets: number; rulesScanned: number; matchedRules: number };
1302 | } {
1303 | const root = getElementRoot(element);
1304 |
1305 | const index = createRuleIndexForRoot(root, 1);
1306 | const res = collectForElement(element, index, 1, {
1307 | includeInline: true,
1308 | declFilter: () => true,
1309 | });
1310 |
1311 | return {
1312 | inlineRule: res.inlineRule,
1313 | matchedRules: res.matchedRules,
1314 | candidates: res.candidates,
1315 | warnings: [...index.warnings, ...res.warnings],
1316 | stats: {
1317 | styleSheets: index.stats.styleSheets,
1318 | rulesScanned: index.stats.rulesScanned,
1319 | matchedRules: res.stats.matchedRules,
1320 | },
1321 | };
1322 | }
1323 |
1324 | /**
1325 | * Collect full snapshot: inline + matched + inherited chain (ancestor traversal).
1326 | */
1327 | export function collectCssPanelSnapshot(
1328 | element: Element,
1329 | options: { maxInheritanceDepth?: number } = {},
1330 | ): CssPanelSnapshot {
1331 | const warnings: string[] = [];
1332 | const maxDepth = Number.isFinite(options.maxInheritanceDepth)
1333 | ? Math.max(0, options.maxInheritanceDepth!)
1334 | : 10;
1335 |
1336 | const elementIds = new WeakMap<Element, number>();
1337 | let nextElementId = 1;
1338 | const rootIds = new WeakMap<Document | ShadowRoot, number>();
1339 | let nextRootId = 1;
1340 | // Use WeakMap for caching, but also maintain a list for stats aggregation
1341 | const indexCache = new WeakMap<Document | ShadowRoot, RuleIndex>();
1342 | const indexList: RuleIndex[] = [];
1343 |
1344 | function getElementId(el: Element): number {
1345 | const existing = elementIds.get(el);
1346 | if (existing) return existing;
1347 | const id = nextElementId++;
1348 | elementIds.set(el, id);
1349 | return id;
1350 | }
1351 |
1352 | function getIndex(root: Document | ShadowRoot): RuleIndex {
1353 | const cached = indexCache.get(root);
1354 | if (cached) return cached;
1355 | const rootId =
1356 | rootIds.get(root) ??
1357 | (() => {
1358 | const v = nextRootId++;
1359 | rootIds.set(root, v);
1360 | return v;
1361 | })();
1362 | const idx = createRuleIndexForRoot(root, rootId);
1363 | indexCache.set(root, idx);
1364 | indexList.push(idx); // Also add to list for stats aggregation
1365 | return idx;
1366 | }
1367 |
1368 | if (!element || !element.isConnected) {
1369 | return {
1370 | target: { label: formatElementLabel(element), root: 'document' },
1371 | warnings: ['Target element is not connected; snapshot may be incomplete.'],
1372 | stats: { roots: 0, styleSheets: 0, rulesScanned: 0, matchedRules: 0 },
1373 | sections: [],
1374 | };
1375 | }
1376 |
1377 | // ---- Target (direct rules) ----
1378 | const targetRoot = getElementRoot(element);
1379 | const targetIndex = getIndex(targetRoot);
1380 | warnings.push(...targetIndex.warnings);
1381 |
1382 | const targetCollected = collectForElement(element, targetIndex, getElementId(element), {
1383 | includeInline: true,
1384 | declFilter: () => true,
1385 | });
1386 |
1387 | // Compute overrides on target itself.
1388 | const targetOverrides = computeOverrides(targetCollected.candidates);
1389 | const targetDeclStatus = targetOverrides.declStatus;
1390 |
1391 | if (targetCollected.inlineRule) {
1392 | for (const d of targetCollected.inlineRule.decls) {
1393 | d.status = targetDeclStatus.get(d.id) ?? 'overridden';
1394 | }
1395 | }
1396 | for (const rule of targetCollected.matchedRules) {
1397 | for (const d of rule.decls) d.status = targetDeclStatus.get(d.id) ?? 'overridden';
1398 | }
1399 |
1400 | // ---- Ancestor chain (inherited props only) ----
1401 | const ancestors: Element[] = [];
1402 | let cur: Element | null = getParentElementOrHost(element);
1403 | while (cur && ancestors.length < maxDepth) {
1404 | ancestors.push(cur);
1405 | cur = getParentElementOrHost(cur);
1406 | }
1407 |
1408 | const inheritableLonghands = new Set<string>();
1409 |
1410 | // Only consider inheritable longhands that appear in collected declarations (keeps work bounded).
1411 | for (const cand of targetCollected.candidates) {
1412 | for (const lh of cand.affects) if (isInheritableProperty(lh)) inheritableLonghands.add(lh);
1413 | }
1414 |
1415 | const ancestorData: Array<{
1416 | ancestor: Element;
1417 | label: string;
1418 | collected: CollectedElementRules;
1419 | overrides: ReturnType<typeof computeOverrides>;
1420 | }> = [];
1421 |
1422 | for (const a of ancestors) {
1423 | const aRoot = getElementRoot(a);
1424 | const aIndex = getIndex(aRoot);
1425 | warnings.push(...aIndex.warnings);
1426 |
1427 | const aCollected = collectForElement(a, aIndex, getElementId(a), {
1428 | includeInline: true,
1429 | declFilter: ({ affects }) => affects.some(isInheritableProperty),
1430 | });
1431 |
1432 | // Filter candidates to inheritable longhands only (affects subset).
1433 | const filteredCandidates: DeclCandidate[] = [];
1434 |
1435 | for (const cand of aCollected.candidates) {
1436 | const affects = cand.affects.filter(isInheritableProperty);
1437 | if (affects.length === 0) continue;
1438 | const next: DeclCandidate = { ...cand, affects };
1439 | filteredCandidates.push(next);
1440 | for (const lh of affects) inheritableLonghands.add(lh);
1441 | }
1442 |
1443 | const aOverrides = computeOverrides(filteredCandidates);
1444 |
1445 | // Keep only inheritable decls in rule views (already filtered by declFilter), but ensure affects trimmed.
1446 | if (aCollected.inlineRule) {
1447 | aCollected.inlineRule.decls = aCollected.inlineRule.decls
1448 | .map((d) => ({ ...d, affects: d.affects.filter(isInheritableProperty) }))
1449 | .filter((d) => d.affects.length > 0);
1450 | if (aCollected.inlineRule.decls.length === 0) aCollected.inlineRule = null;
1451 | }
1452 | aCollected.matchedRules = aCollected.matchedRules
1453 | .map((r) => ({
1454 | ...r,
1455 | decls: r.decls
1456 | .map((d) => ({ ...d, affects: d.affects.filter(isInheritableProperty) }))
1457 | .filter((d) => d.affects.length > 0),
1458 | }))
1459 | .filter((r) => r.decls.length > 0);
1460 |
1461 | if (!aCollected.inlineRule && aCollected.matchedRules.length === 0) continue;
1462 |
1463 | ancestorData.push({
1464 | ancestor: a,
1465 | label: formatElementLabel(a),
1466 | collected: { ...aCollected, candidates: filteredCandidates },
1467 | overrides: aOverrides,
1468 | });
1469 | }
1470 |
1471 | // Determine which inherited declaration IDs actually provide the final inherited value for target.
1472 | const finalInheritedDeclIds = new Set<string>();
1473 |
1474 | for (const longhand of inheritableLonghands) {
1475 | if (targetOverrides.winners.has(longhand)) continue;
1476 |
1477 | for (const a of ancestorData) {
1478 | const win = a.overrides.winners.get(longhand);
1479 | if (win) {
1480 | finalInheritedDeclIds.add(win.id);
1481 | break;
1482 | }
1483 | }
1484 | }
1485 |
1486 | // Apply inherited statuses: active only if it is the chosen inherited source for any longhand.
1487 | for (const a of ancestorData) {
1488 | if (a.collected.inlineRule) {
1489 | for (const d of a.collected.inlineRule.decls) {
1490 | d.status = finalInheritedDeclIds.has(d.id) ? 'active' : 'overridden';
1491 | }
1492 | }
1493 | for (const r of a.collected.matchedRules) {
1494 | for (const d of r.decls) d.status = finalInheritedDeclIds.has(d.id) ? 'active' : 'overridden';
1495 | }
1496 | }
1497 |
1498 | // ---- Build sections ----
1499 | const sections: CssSectionView[] = [];
1500 |
1501 | sections.push({
1502 | kind: 'inline',
1503 | title: 'element.style',
1504 | rules: targetCollected.inlineRule ? [targetCollected.inlineRule] : [],
1505 | });
1506 |
1507 | sections.push({
1508 | kind: 'matched',
1509 | title: 'Matched CSS Rules',
1510 | rules: targetCollected.matchedRules,
1511 | });
1512 |
1513 | for (const a of ancestorData) {
1514 | const rules: CssRuleView[] = [];
1515 | if (a.collected.inlineRule) rules.push(a.collected.inlineRule);
1516 | rules.push(...a.collected.matchedRules);
1517 |
1518 | sections.push({
1519 | kind: 'inherited',
1520 | title: `Inherited from ${a.label}`,
1521 | inheritedFrom: { label: a.label },
1522 | rules,
1523 | });
1524 | }
1525 |
1526 | // ---- Aggregate stats ----
1527 | let totalStyleSheets = 0;
1528 | let totalRulesScanned = 0;
1529 | const rootsSeen = new Set<number>();
1530 | for (const idx of indexList) {
1531 | rootsSeen.add(idx.rootId);
1532 | totalStyleSheets += idx.stats.styleSheets;
1533 | totalRulesScanned += idx.stats.rulesScanned;
1534 | }
1535 |
1536 | const dedupWarnings = Array.from(new Set([...warnings, ...targetCollected.warnings]));
1537 |
1538 | return {
1539 | target: {
1540 | label: formatElementLabel(element),
1541 | root: targetRoot instanceof ShadowRoot ? 'shadow' : 'document',
1542 | },
1543 | warnings: dedupWarnings,
1544 | stats: {
1545 | roots: rootsSeen.size,
1546 | styleSheets: totalStyleSheets,
1547 | rulesScanned: totalRulesScanned,
1548 | matchedRules: targetCollected.stats.matchedRules,
1549 | },
1550 | sections,
1551 | };
1552 | }
1553 |
```
--------------------------------------------------------------------------------
/app/native-server/src/server/routes/agent.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Agent Routes - All agent-related HTTP endpoints.
3 | *
4 | * Handles:
5 | * - Projects CRUD
6 | * - Chat messages CRUD
7 | * - Chat streaming (SSE)
8 | * - Chat actions (act, cancel)
9 | * - Engine listing
10 | */
11 | import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
12 | import { HTTP_STATUS, ERROR_MESSAGES } from '../../constant';
13 | import { AgentStreamManager } from '../../agent/stream-manager';
14 | import { AgentChatService } from '../../agent/chat-service';
15 | import type { AgentActRequest, AgentActResponse, RealtimeEvent } from '../../agent/types';
16 | import type { CreateOrUpdateProjectInput } from '../../agent/project-types';
17 | import {
18 | createProjectDirectory,
19 | deleteProject,
20 | listProjects,
21 | upsertProject,
22 | validateRootPath,
23 | } from '../../agent/project-service';
24 | import {
25 | createMessage as createStoredMessage,
26 | deleteMessagesByProjectId,
27 | deleteMessagesBySessionId,
28 | getMessagesByProjectId,
29 | getMessagesCountByProjectId,
30 | getMessagesBySessionId,
31 | getMessagesCountBySessionId,
32 | } from '../../agent/message-service';
33 | import {
34 | createSession,
35 | deleteSession,
36 | getSession,
37 | getSessionsByProject,
38 | getSessionsByProjectAndEngine,
39 | getAllSessions,
40 | updateSession,
41 | type CreateSessionOptions,
42 | type UpdateSessionInput,
43 | } from '../../agent/session-service';
44 | import { getProject } from '../../agent/project-service';
45 | import { getDefaultWorkspaceDir, getDefaultProjectRoot } from '../../agent/storage';
46 | import { openDirectoryPicker } from '../../agent/directory-picker';
47 | import type { EngineName } from '../../agent/engines/types';
48 | import { attachmentService } from '../../agent/attachment-service';
49 | import { openProjectDirectory, openFileInVSCode } from '../../agent/open-project';
50 | import type {
51 | AttachmentStatsResponse,
52 | AttachmentCleanupRequest,
53 | AttachmentCleanupResponse,
54 | OpenProjectRequest,
55 | OpenProjectTarget,
56 | } from 'chrome-mcp-shared';
57 |
58 | // Valid engine names for validation
59 | const VALID_ENGINE_NAMES: readonly EngineName[] = ['claude', 'codex', 'cursor', 'qwen', 'glm'];
60 |
61 | function isValidEngineName(name: string): name is EngineName {
62 | return VALID_ENGINE_NAMES.includes(name as EngineName);
63 | }
64 |
65 | // Valid open project targets
66 | const VALID_OPEN_TARGETS: readonly OpenProjectTarget[] = ['vscode', 'terminal'];
67 |
68 | function isValidOpenTarget(target: string): target is OpenProjectTarget {
69 | return VALID_OPEN_TARGETS.includes(target as OpenProjectTarget);
70 | }
71 |
72 | // ============================================================
73 | // Types
74 | // ============================================================
75 |
76 | export interface AgentRoutesOptions {
77 | streamManager: AgentStreamManager;
78 | chatService: AgentChatService;
79 | }
80 |
81 | // ============================================================
82 | // Route Registration
83 | // ============================================================
84 |
85 | /**
86 | * Register all agent-related routes on the Fastify instance.
87 | */
88 | export function registerAgentRoutes(fastify: FastifyInstance, options: AgentRoutesOptions): void {
89 | const { streamManager, chatService } = options;
90 |
91 | // ============================================================
92 | // Engine Routes
93 | // ============================================================
94 |
95 | fastify.get('/agent/engines', async (_request, reply) => {
96 | try {
97 | const engines = chatService.getEngineInfos();
98 | reply.status(HTTP_STATUS.OK).send({ engines });
99 | } catch (error) {
100 | fastify.log.error({ err: error }, 'Failed to list agent engines');
101 | if (!reply.sent) {
102 | reply
103 | .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
104 | .send({ error: ERROR_MESSAGES.INTERNAL_SERVER_ERROR });
105 | }
106 | }
107 | });
108 |
109 | // ============================================================
110 | // Project Routes
111 | // ============================================================
112 |
113 | fastify.get('/agent/projects', async (_request, reply) => {
114 | try {
115 | const projects = await listProjects();
116 | reply.status(HTTP_STATUS.OK).send({ projects });
117 | } catch (error) {
118 | if (!reply.sent) {
119 | reply
120 | .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
121 | .send({ error: ERROR_MESSAGES.INTERNAL_SERVER_ERROR });
122 | }
123 | }
124 | });
125 |
126 | fastify.post(
127 | '/agent/projects',
128 | async (request: FastifyRequest<{ Body: CreateOrUpdateProjectInput }>, reply: FastifyReply) => {
129 | try {
130 | const body = request.body;
131 | if (!body || !body.name || !body.rootPath) {
132 | reply
133 | .status(HTTP_STATUS.BAD_REQUEST)
134 | .send({ error: 'name and rootPath are required to create a project' });
135 | return;
136 | }
137 | const project = await upsertProject(body);
138 | reply.status(HTTP_STATUS.OK).send({ project });
139 | } catch (error) {
140 | const message = error instanceof Error ? error.message : String(error);
141 | reply
142 | .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
143 | .send({ error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR });
144 | }
145 | },
146 | );
147 |
148 | fastify.delete(
149 | '/agent/projects/:id',
150 | async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
151 | const { id } = request.params;
152 | if (!id) {
153 | reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'project id is required' });
154 | return;
155 | }
156 | try {
157 | await deleteProject(id);
158 | reply.status(HTTP_STATUS.NO_CONTENT).send();
159 | } catch (error) {
160 | if (!reply.sent) {
161 | reply
162 | .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
163 | .send({ error: ERROR_MESSAGES.INTERNAL_SERVER_ERROR });
164 | }
165 | }
166 | },
167 | );
168 |
169 | // Path validation API
170 | fastify.post(
171 | '/agent/projects/validate-path',
172 | async (request: FastifyRequest<{ Body: { rootPath: string } }>, reply: FastifyReply) => {
173 | const { rootPath } = request.body || {};
174 | if (!rootPath || typeof rootPath !== 'string') {
175 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'rootPath is required' });
176 | }
177 | try {
178 | const result = await validateRootPath(rootPath);
179 | return reply.send(result);
180 | } catch (error) {
181 | const message = error instanceof Error ? error.message : String(error);
182 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({ error: message });
183 | }
184 | },
185 | );
186 |
187 | // Create directory API
188 | fastify.post(
189 | '/agent/projects/create-directory',
190 | async (request: FastifyRequest<{ Body: { absolutePath: string } }>, reply: FastifyReply) => {
191 | const { absolutePath } = request.body || {};
192 | if (!absolutePath || typeof absolutePath !== 'string') {
193 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'absolutePath is required' });
194 | }
195 | try {
196 | await createProjectDirectory(absolutePath);
197 | return reply.send({ success: true, path: absolutePath });
198 | } catch (error) {
199 | const message = error instanceof Error ? error.message : String(error);
200 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: message });
201 | }
202 | },
203 | );
204 |
205 | // Get default workspace directory
206 | fastify.get('/agent/projects/default-workspace', async (_request, reply) => {
207 | try {
208 | const workspaceDir = getDefaultWorkspaceDir();
209 | return reply.send({ success: true, path: workspaceDir });
210 | } catch (error) {
211 | const message = error instanceof Error ? error.message : String(error);
212 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({ error: message });
213 | }
214 | });
215 |
216 | // Get default project root for a given project name
217 | fastify.post(
218 | '/agent/projects/default-root',
219 | async (request: FastifyRequest<{ Body: { projectName: string } }>, reply: FastifyReply) => {
220 | const { projectName } = request.body || {};
221 | if (!projectName || typeof projectName !== 'string') {
222 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectName is required' });
223 | }
224 | try {
225 | const rootPath = getDefaultProjectRoot(projectName);
226 | return reply.send({ success: true, path: rootPath });
227 | } catch (error) {
228 | const message = error instanceof Error ? error.message : String(error);
229 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({ error: message });
230 | }
231 | },
232 | );
233 |
234 | // Open directory picker dialog
235 | fastify.post('/agent/projects/pick-directory', async (_request, reply) => {
236 | try {
237 | const result = await openDirectoryPicker('Select Project Directory');
238 | if (result.success && result.path) {
239 | return reply.send({ success: true, path: result.path });
240 | } else if (result.cancelled) {
241 | return reply.send({ success: false, cancelled: true });
242 | } else {
243 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
244 | success: false,
245 | error: result.error || 'Failed to open directory picker',
246 | });
247 | }
248 | } catch (error) {
249 | const message = error instanceof Error ? error.message : String(error);
250 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({ error: message });
251 | }
252 | });
253 |
254 | // ============================================================
255 | // Session Routes
256 | // ============================================================
257 |
258 | // List all sessions across all projects
259 | fastify.get('/agent/sessions', async (_request: FastifyRequest, reply: FastifyReply) => {
260 | try {
261 | const sessions = await getAllSessions();
262 | return reply.status(HTTP_STATUS.OK).send({ sessions });
263 | } catch (error) {
264 | const message = error instanceof Error ? error.message : String(error);
265 | fastify.log.error({ err: error }, 'Failed to list all sessions');
266 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
267 | error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
268 | });
269 | }
270 | });
271 |
272 | // List sessions for a project
273 | fastify.get(
274 | '/agent/projects/:projectId/sessions',
275 | async (request: FastifyRequest<{ Params: { projectId: string } }>, reply: FastifyReply) => {
276 | const { projectId } = request.params;
277 | if (!projectId) {
278 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });
279 | }
280 |
281 | try {
282 | const sessions = await getSessionsByProject(projectId);
283 | return reply.status(HTTP_STATUS.OK).send({ sessions });
284 | } catch (error) {
285 | const message = error instanceof Error ? error.message : String(error);
286 | fastify.log.error({ err: error }, 'Failed to list sessions');
287 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
288 | error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
289 | });
290 | }
291 | },
292 | );
293 |
294 | // Create a new session for a project
295 | fastify.post(
296 | '/agent/projects/:projectId/sessions',
297 | async (
298 | request: FastifyRequest<{
299 | Params: { projectId: string };
300 | Body: CreateSessionOptions & { engineName: string };
301 | }>,
302 | reply: FastifyReply,
303 | ) => {
304 | const { projectId } = request.params;
305 | const body = request.body || {};
306 |
307 | if (!projectId) {
308 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });
309 | }
310 | if (!body.engineName) {
311 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'engineName is required' });
312 | }
313 | if (!isValidEngineName(body.engineName)) {
314 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({
315 | error: `Invalid engineName. Must be one of: ${VALID_ENGINE_NAMES.join(', ')}`,
316 | });
317 | }
318 |
319 | try {
320 | // Verify project exists
321 | const project = await getProject(projectId);
322 | if (!project) {
323 | return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Project not found' });
324 | }
325 |
326 | const session = await createSession(projectId, body.engineName, {
327 | name: body.name,
328 | model: body.model,
329 | permissionMode: body.permissionMode,
330 | allowDangerouslySkipPermissions: body.allowDangerouslySkipPermissions,
331 | systemPromptConfig: body.systemPromptConfig,
332 | optionsConfig: body.optionsConfig,
333 | });
334 | return reply.status(HTTP_STATUS.CREATED).send({ session });
335 | } catch (error) {
336 | const message = error instanceof Error ? error.message : String(error);
337 | fastify.log.error({ err: error }, 'Failed to create session');
338 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
339 | error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
340 | });
341 | }
342 | },
343 | );
344 |
345 | // Get a specific session
346 | fastify.get(
347 | '/agent/sessions/:sessionId',
348 | async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {
349 | const { sessionId } = request.params;
350 | if (!sessionId) {
351 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });
352 | }
353 |
354 | try {
355 | const session = await getSession(sessionId);
356 | if (!session) {
357 | return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });
358 | }
359 | return reply.status(HTTP_STATUS.OK).send({ session });
360 | } catch (error) {
361 | const message = error instanceof Error ? error.message : String(error);
362 | fastify.log.error({ err: error }, 'Failed to get session');
363 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
364 | error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
365 | });
366 | }
367 | },
368 | );
369 |
370 | // Update a session
371 | fastify.patch(
372 | '/agent/sessions/:sessionId',
373 | async (
374 | request: FastifyRequest<{
375 | Params: { sessionId: string };
376 | Body: UpdateSessionInput;
377 | }>,
378 | reply: FastifyReply,
379 | ) => {
380 | const { sessionId } = request.params;
381 | const updates = request.body || {};
382 |
383 | if (!sessionId) {
384 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });
385 | }
386 |
387 | try {
388 | const existing = await getSession(sessionId);
389 | if (!existing) {
390 | return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });
391 | }
392 |
393 | await updateSession(sessionId, updates);
394 | const updated = await getSession(sessionId);
395 | return reply.status(HTTP_STATUS.OK).send({ session: updated });
396 | } catch (error) {
397 | const message = error instanceof Error ? error.message : String(error);
398 | fastify.log.error({ err: error }, 'Failed to update session');
399 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
400 | error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
401 | });
402 | }
403 | },
404 | );
405 |
406 | // Delete a session
407 | fastify.delete(
408 | '/agent/sessions/:sessionId',
409 | async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {
410 | const { sessionId } = request.params;
411 | if (!sessionId) {
412 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });
413 | }
414 |
415 | try {
416 | await deleteSession(sessionId);
417 | return reply.status(HTTP_STATUS.NO_CONTENT).send();
418 | } catch (error) {
419 | const message = error instanceof Error ? error.message : String(error);
420 | fastify.log.error({ err: error }, 'Failed to delete session');
421 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
422 | error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
423 | });
424 | }
425 | },
426 | );
427 |
428 | // Get message history for a session
429 | fastify.get(
430 | '/agent/sessions/:sessionId/history',
431 | async (
432 | request: FastifyRequest<{
433 | Params: { sessionId: string };
434 | Querystring: { limit?: string; offset?: string };
435 | }>,
436 | reply: FastifyReply,
437 | ) => {
438 | const { sessionId } = request.params;
439 | if (!sessionId) {
440 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });
441 | }
442 |
443 | const limitRaw = request.query.limit;
444 | const offsetRaw = request.query.offset;
445 | const limit = Number.parseInt(limitRaw || '', 10);
446 | const offset = Number.parseInt(offsetRaw || '', 10);
447 | const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 0;
448 | const safeOffset = Number.isFinite(offset) && offset >= 0 ? offset : 0;
449 |
450 | try {
451 | const session = await getSession(sessionId);
452 | if (!session) {
453 | return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });
454 | }
455 |
456 | const [messages, totalCount] = await Promise.all([
457 | getMessagesBySessionId(sessionId, safeLimit, safeOffset),
458 | getMessagesCountBySessionId(sessionId),
459 | ]);
460 |
461 | return reply.status(HTTP_STATUS.OK).send({
462 | success: true,
463 | sessionId,
464 | messages,
465 | totalCount,
466 | pagination: {
467 | limit: safeLimit,
468 | offset: safeOffset,
469 | count: messages.length,
470 | hasMore: safeLimit > 0 ? safeOffset + messages.length < totalCount : false,
471 | },
472 | });
473 | } catch (error) {
474 | const message = error instanceof Error ? error.message : String(error);
475 | fastify.log.error({ err: error }, 'Failed to get session history');
476 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
477 | error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
478 | });
479 | }
480 | },
481 | );
482 |
483 | // Reset a session conversation (clear messages + engineSessionId)
484 | fastify.post(
485 | '/agent/sessions/:sessionId/reset',
486 | async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {
487 | const { sessionId } = request.params;
488 | if (!sessionId) {
489 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });
490 | }
491 |
492 | try {
493 | const existing = await getSession(sessionId);
494 | if (!existing) {
495 | return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });
496 | }
497 |
498 | // Clear resume state first, then delete messages
499 | await updateSession(sessionId, { engineSessionId: null });
500 | const deletedMessages = await deleteMessagesBySessionId(sessionId);
501 | const updated = await getSession(sessionId);
502 |
503 | return reply.status(HTTP_STATUS.OK).send({
504 | success: true,
505 | sessionId,
506 | deletedMessages,
507 | clearedEngineSessionId: Boolean(existing.engineSessionId),
508 | session: updated || null,
509 | });
510 | } catch (error) {
511 | const message = error instanceof Error ? error.message : String(error);
512 | fastify.log.error({ err: error }, 'Failed to reset session');
513 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
514 | error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
515 | });
516 | }
517 | },
518 | );
519 |
520 | // Get Claude management info for a session
521 | fastify.get(
522 | '/agent/sessions/:sessionId/claude-info',
523 | async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {
524 | const { sessionId } = request.params;
525 | if (!sessionId) {
526 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });
527 | }
528 |
529 | try {
530 | const session = await getSession(sessionId);
531 | if (!session) {
532 | return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });
533 | }
534 |
535 | return reply.status(HTTP_STATUS.OK).send({
536 | managementInfo: session.managementInfo || null,
537 | sessionId,
538 | engineName: session.engineName,
539 | });
540 | } catch (error) {
541 | const message = error instanceof Error ? error.message : String(error);
542 | fastify.log.error({ err: error }, 'Failed to get Claude info');
543 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
544 | error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
545 | });
546 | }
547 | },
548 | );
549 |
550 | // Get aggregated Claude management info for a project
551 | // Returns the most recent management info from any Claude session in the project
552 | fastify.get(
553 | '/agent/projects/:projectId/claude-info',
554 | async (request: FastifyRequest<{ Params: { projectId: string } }>, reply: FastifyReply) => {
555 | const { projectId } = request.params;
556 | if (!projectId) {
557 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });
558 | }
559 |
560 | try {
561 | const project = await getProject(projectId);
562 | if (!project) {
563 | return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Project not found' });
564 | }
565 |
566 | // Get only Claude sessions (more efficient than fetching all and filtering)
567 | const claudeSessions = await getSessionsByProjectAndEngine(projectId, 'claude');
568 | const sessionsWithInfo = claudeSessions.filter((s) => s.managementInfo);
569 |
570 | // Sort by lastUpdated in management info (fallback to session.updatedAt for old data)
571 | sessionsWithInfo.sort((a, b) => {
572 | const aTime = a.managementInfo?.lastUpdated || a.updatedAt || '';
573 | const bTime = b.managementInfo?.lastUpdated || b.updatedAt || '';
574 | return bTime.localeCompare(aTime);
575 | });
576 |
577 | const latestInfo = sessionsWithInfo[0]?.managementInfo || null;
578 | const sourceSessionId = sessionsWithInfo[0]?.id;
579 |
580 | return reply.status(HTTP_STATUS.OK).send({
581 | managementInfo: latestInfo,
582 | sourceSessionId,
583 | projectId,
584 | sessionsWithInfo: sessionsWithInfo.length,
585 | });
586 | } catch (error) {
587 | const message = error instanceof Error ? error.message : String(error);
588 | fastify.log.error({ err: error }, 'Failed to get project Claude info');
589 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
590 | error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
591 | });
592 | }
593 | },
594 | );
595 |
596 | // ============================================================
597 | // Open Project Routes
598 | // ============================================================
599 |
600 | /**
601 | * POST /agent/sessions/:sessionId/open
602 | * Open session's project directory in VSCode or terminal.
603 | */
604 | fastify.post(
605 | '/agent/sessions/:sessionId/open',
606 | async (
607 | request: FastifyRequest<{
608 | Params: { sessionId: string };
609 | Body: OpenProjectRequest;
610 | }>,
611 | reply: FastifyReply,
612 | ) => {
613 | const { sessionId } = request.params;
614 | const { target } = request.body || {};
615 |
616 | if (!sessionId) {
617 | return reply
618 | .status(HTTP_STATUS.BAD_REQUEST)
619 | .send({ success: false, error: 'sessionId is required' });
620 | }
621 | if (!target || typeof target !== 'string') {
622 | return reply
623 | .status(HTTP_STATUS.BAD_REQUEST)
624 | .send({ success: false, error: 'target is required' });
625 | }
626 | if (!isValidOpenTarget(target)) {
627 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({
628 | success: false,
629 | error: `Invalid target. Must be one of: ${VALID_OPEN_TARGETS.join(', ')}`,
630 | });
631 | }
632 |
633 | try {
634 | // Get session and its project
635 | const session = await getSession(sessionId);
636 | if (!session) {
637 | return reply
638 | .status(HTTP_STATUS.NOT_FOUND)
639 | .send({ success: false, error: 'Session not found' });
640 | }
641 |
642 | const project = await getProject(session.projectId);
643 | if (!project) {
644 | return reply
645 | .status(HTTP_STATUS.NOT_FOUND)
646 | .send({ success: false, error: 'Project not found' });
647 | }
648 |
649 | // Open the project directory
650 | const result = await openProjectDirectory(project.rootPath, target);
651 | if (result.success) {
652 | return reply.status(HTTP_STATUS.OK).send({ success: true });
653 | }
654 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
655 | success: false,
656 | error: result.error,
657 | });
658 | } catch (error) {
659 | const message = error instanceof Error ? error.message : String(error);
660 | fastify.log.error({ err: error }, 'Failed to open session project');
661 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
662 | success: false,
663 | error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
664 | });
665 | }
666 | },
667 | );
668 |
669 | /**
670 | * POST /agent/projects/:projectId/open
671 | * Open project directory in VSCode or terminal.
672 | */
673 | fastify.post(
674 | '/agent/projects/:projectId/open',
675 | async (
676 | request: FastifyRequest<{
677 | Params: { projectId: string };
678 | Body: OpenProjectRequest;
679 | }>,
680 | reply: FastifyReply,
681 | ) => {
682 | const { projectId } = request.params;
683 | const { target } = request.body || {};
684 |
685 | if (!projectId) {
686 | return reply
687 | .status(HTTP_STATUS.BAD_REQUEST)
688 | .send({ success: false, error: 'projectId is required' });
689 | }
690 | if (!target || typeof target !== 'string') {
691 | return reply
692 | .status(HTTP_STATUS.BAD_REQUEST)
693 | .send({ success: false, error: 'target is required' });
694 | }
695 | if (!isValidOpenTarget(target)) {
696 | return reply.status(HTTP_STATUS.BAD_REQUEST).send({
697 | success: false,
698 | error: `Invalid target. Must be one of: ${VALID_OPEN_TARGETS.join(', ')}`,
699 | });
700 | }
701 |
702 | try {
703 | const project = await getProject(projectId);
704 | if (!project) {
705 | return reply
706 | .status(HTTP_STATUS.NOT_FOUND)
707 | .send({ success: false, error: 'Project not found' });
708 | }
709 |
710 | // Open the project directory
711 | const result = await openProjectDirectory(project.rootPath, target);
712 | if (result.success) {
713 | return reply.status(HTTP_STATUS.OK).send({ success: true });
714 | }
715 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
716 | success: false,
717 | error: result.error,
718 | });
719 | } catch (error) {
720 | const message = error instanceof Error ? error.message : String(error);
721 | fastify.log.error({ err: error }, 'Failed to open project');
722 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
723 | success: false,
724 | error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
725 | });
726 | }
727 | },
728 | );
729 |
730 | /**
731 | * POST /agent/projects/:projectId/open-file
732 | * Open a file in VSCode at a specific line/column.
733 | *
734 | * Request body:
735 | * - filePath: string (required) - File path (relative or absolute)
736 | * - line?: number - Line number (1-based)
737 | * - column?: number - Column number (1-based)
738 | */
739 | fastify.post(
740 | '/agent/projects/:projectId/open-file',
741 | async (
742 | request: FastifyRequest<{
743 | Params: { projectId: string };
744 | Body: { filePath?: string; line?: number; column?: number };
745 | }>,
746 | reply: FastifyReply,
747 | ) => {
748 | const { projectId } = request.params;
749 | const { filePath, line, column } = request.body || {};
750 |
751 | if (!projectId) {
752 | return reply
753 | .status(HTTP_STATUS.BAD_REQUEST)
754 | .send({ success: false, error: 'projectId is required' });
755 | }
756 | if (!filePath || typeof filePath !== 'string') {
757 | return reply
758 | .status(HTTP_STATUS.BAD_REQUEST)
759 | .send({ success: false, error: 'filePath is required' });
760 | }
761 |
762 | try {
763 | const project = await getProject(projectId);
764 | if (!project) {
765 | return reply
766 | .status(HTTP_STATUS.NOT_FOUND)
767 | .send({ success: false, error: 'Project not found' });
768 | }
769 |
770 | // Open the file in VSCode
771 | const result = await openFileInVSCode(project.rootPath, filePath, line, column);
772 | if (result.success) {
773 | return reply.status(HTTP_STATUS.OK).send({ success: true });
774 | }
775 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
776 | success: false,
777 | error: result.error,
778 | });
779 | } catch (error) {
780 | const message = error instanceof Error ? error.message : String(error);
781 | fastify.log.error({ err: error }, 'Failed to open file in VSCode');
782 | return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
783 | success: false,
784 | error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
785 | });
786 | }
787 | },
788 | );
789 |
790 | // ============================================================
791 | // Chat Message Routes
792 | // ============================================================
793 |
794 | fastify.get(
795 | '/agent/chat/:projectId/messages',
796 | async (
797 | request: FastifyRequest<{
798 | Params: { projectId: string };
799 | Querystring: { limit?: string; offset?: string };
800 | }>,
801 | reply: FastifyReply,
802 | ) => {
803 | const { projectId } = request.params;
804 | if (!projectId) {
805 | reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });
806 | return;
807 | }
808 |
809 | const limitRaw = request.query.limit;
810 | const offsetRaw = request.query.offset;
811 | const limit = Number.parseInt(limitRaw || '', 10);
812 | const offset = Number.parseInt(offsetRaw || '', 10);
813 | const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 50;
814 | const safeOffset = Number.isFinite(offset) && offset >= 0 ? offset : 0;
815 |
816 | try {
817 | const [messages, totalCount] = await Promise.all([
818 | getMessagesByProjectId(projectId, safeLimit, safeOffset),
819 | getMessagesCountByProjectId(projectId),
820 | ]);
821 |
822 | reply.status(HTTP_STATUS.OK).send({
823 | success: true,
824 | data: messages,
825 | totalCount,
826 | pagination: {
827 | limit: safeLimit,
828 | offset: safeOffset,
829 | count: messages.length,
830 | hasMore: safeOffset + messages.length < totalCount,
831 | },
832 | });
833 | } catch (error) {
834 | const message = error instanceof Error ? error.message : String(error);
835 | fastify.log.error({ err: error }, 'Failed to load agent chat messages');
836 | reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
837 | success: false,
838 | error: 'Failed to fetch messages',
839 | message: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
840 | });
841 | }
842 | },
843 | );
844 |
845 | fastify.post(
846 | '/agent/chat/:projectId/messages',
847 | async (
848 | request: FastifyRequest<{
849 | Params: { projectId: string };
850 | Body: {
851 | content?: string;
852 | role?: string;
853 | messageType?: string;
854 | conversationId?: string;
855 | sessionId?: string;
856 | cliSource?: string;
857 | metadata?: Record<string, unknown>;
858 | requestId?: string;
859 | id?: string;
860 | createdAt?: string;
861 | };
862 | }>,
863 | reply: FastifyReply,
864 | ) => {
865 | const { projectId } = request.params;
866 | if (!projectId) {
867 | reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });
868 | return;
869 | }
870 |
871 | const body = request.body || {};
872 | const content = typeof body.content === 'string' ? body.content.trim() : '';
873 | if (!content) {
874 | reply
875 | .status(HTTP_STATUS.BAD_REQUEST)
876 | .send({ success: false, error: 'content is required' });
877 | return;
878 | }
879 |
880 | const rawRole = typeof body.role === 'string' ? body.role.toLowerCase().trim() : 'user';
881 | const role: 'assistant' | 'user' | 'system' | 'tool' =
882 | rawRole === 'assistant' || rawRole === 'system' || rawRole === 'tool'
883 | ? (rawRole as 'assistant' | 'system' | 'tool')
884 | : 'user';
885 |
886 | const rawType = typeof body.messageType === 'string' ? body.messageType.toLowerCase() : '';
887 | const allowedTypes = ['chat', 'tool_use', 'tool_result', 'status'] as const;
888 | const fallbackType: (typeof allowedTypes)[number] = role === 'system' ? 'status' : 'chat';
889 | const messageType =
890 | (allowedTypes as readonly string[]).includes(rawType) && rawType
891 | ? (rawType as (typeof allowedTypes)[number])
892 | : fallbackType;
893 |
894 | try {
895 | const stored = await createStoredMessage({
896 | projectId,
897 | role,
898 | messageType,
899 | content,
900 | metadata: body.metadata,
901 | sessionId: body.sessionId,
902 | conversationId: body.conversationId,
903 | cliSource: body.cliSource,
904 | requestId: body.requestId,
905 | id: body.id,
906 | createdAt: body.createdAt,
907 | });
908 |
909 | reply.status(HTTP_STATUS.CREATED).send({ success: true, data: stored });
910 | } catch (error) {
911 | const message = error instanceof Error ? error.message : String(error);
912 | fastify.log.error({ err: error }, 'Failed to create agent chat message');
913 | reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
914 | success: false,
915 | error: 'Failed to create message',
916 | message: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
917 | });
918 | }
919 | },
920 | );
921 |
922 | fastify.delete(
923 | '/agent/chat/:projectId/messages',
924 | async (
925 | request: FastifyRequest<{
926 | Params: { projectId: string };
927 | Querystring: { conversationId?: string };
928 | }>,
929 | reply: FastifyReply,
930 | ) => {
931 | const { projectId } = request.params;
932 | if (!projectId) {
933 | reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });
934 | return;
935 | }
936 |
937 | const { conversationId } = request.query;
938 |
939 | try {
940 | const deleted = await deleteMessagesByProjectId(projectId, conversationId || undefined);
941 | reply.status(HTTP_STATUS.OK).send({ success: true, deleted });
942 | } catch (error) {
943 | const message = error instanceof Error ? error.message : String(error);
944 | fastify.log.error({ err: error }, 'Failed to delete agent chat messages');
945 | reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
946 | success: false,
947 | error: 'Failed to delete messages',
948 | message: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
949 | });
950 | }
951 | },
952 | );
953 |
954 | // ============================================================
955 | // Chat Streaming Routes (SSE)
956 | // ============================================================
957 |
958 | fastify.get(
959 | '/agent/chat/:sessionId/stream',
960 | async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {
961 | const { sessionId } = request.params;
962 | if (!sessionId) {
963 | reply
964 | .status(HTTP_STATUS.BAD_REQUEST)
965 | .send({ error: 'sessionId is required for agent stream' });
966 | return;
967 | }
968 |
969 | try {
970 | reply.raw.writeHead(HTTP_STATUS.OK, {
971 | 'Content-Type': 'text/event-stream',
972 | 'Cache-Control': 'no-cache',
973 | Connection: 'keep-alive',
974 | });
975 |
976 | // Ensure client immediately receives an open event
977 | reply.raw.write(':\n\n');
978 |
979 | streamManager.addSseStream(sessionId, reply.raw);
980 |
981 | const connectedEvent: RealtimeEvent = {
982 | type: 'connected',
983 | data: {
984 | sessionId,
985 | transport: 'sse',
986 | timestamp: new Date().toISOString(),
987 | },
988 | };
989 | streamManager.publish(connectedEvent);
990 |
991 | reply.raw.on('close', () => {
992 | streamManager.removeSseStream(sessionId, reply.raw);
993 | });
994 | } catch (error) {
995 | if (!reply.sent) {
996 | reply.code(HTTP_STATUS.INTERNAL_SERVER_ERROR).send(ERROR_MESSAGES.INTERNAL_SERVER_ERROR);
997 | }
998 | }
999 | },
1000 | );
1001 |
1002 | // ============================================================
1003 | // Chat Action Routes
1004 | // ============================================================
1005 |
1006 | fastify.post(
1007 | '/agent/chat/:sessionId/act',
1008 | {
1009 | // Increase body limit to support image attachments (base64 encoded)
1010 | // Default Fastify limit is 1MB, which is too small for images
1011 | config: {
1012 | rawBody: false,
1013 | },
1014 | bodyLimit: 50 * 1024 * 1024, // 50MB to support multiple images
1015 | },
1016 | async (
1017 | request: FastifyRequest<{ Params: { sessionId: string }; Body: AgentActRequest }>,
1018 | reply: FastifyReply,
1019 | ) => {
1020 | const { sessionId } = request.params;
1021 | const payload = request.body;
1022 |
1023 | if (!sessionId) {
1024 | reply
1025 | .status(HTTP_STATUS.BAD_REQUEST)
1026 | .send({ error: 'sessionId is required for agent act' });
1027 | return;
1028 | }
1029 |
1030 | try {
1031 | const { requestId } = await chatService.handleAct(sessionId, payload);
1032 | const response: AgentActResponse = {
1033 | requestId,
1034 | sessionId,
1035 | status: 'accepted',
1036 | };
1037 | reply.status(HTTP_STATUS.OK).send(response);
1038 | } catch (error) {
1039 | const message = error instanceof Error ? error.message : String(error);
1040 | reply
1041 | .status(HTTP_STATUS.BAD_REQUEST)
1042 | .send({ error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR });
1043 | }
1044 | },
1045 | );
1046 |
1047 | // Cancel specific request
1048 | fastify.delete(
1049 | '/agent/chat/:sessionId/cancel/:requestId',
1050 | async (
1051 | request: FastifyRequest<{ Params: { sessionId: string; requestId: string } }>,
1052 | reply: FastifyReply,
1053 | ) => {
1054 | const { sessionId, requestId } = request.params;
1055 |
1056 | if (!sessionId || !requestId) {
1057 | reply
1058 | .status(HTTP_STATUS.BAD_REQUEST)
1059 | .send({ error: 'sessionId and requestId are required' });
1060 | return;
1061 | }
1062 |
1063 | const cancelled = chatService.cancelExecution(requestId);
1064 | if (cancelled) {
1065 | reply.status(HTTP_STATUS.OK).send({
1066 | success: true,
1067 | message: 'Execution cancelled',
1068 | requestId,
1069 | sessionId,
1070 | });
1071 | } else {
1072 | reply.status(HTTP_STATUS.OK).send({
1073 | success: false,
1074 | message: 'No running execution found with this requestId',
1075 | requestId,
1076 | sessionId,
1077 | });
1078 | }
1079 | },
1080 | );
1081 |
1082 | // Cancel all executions for a session
1083 | fastify.delete(
1084 | '/agent/chat/:sessionId/cancel',
1085 | async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {
1086 | const { sessionId } = request.params;
1087 |
1088 | if (!sessionId) {
1089 | reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });
1090 | return;
1091 | }
1092 |
1093 | const cancelledCount = chatService.cancelSessionExecutions(sessionId);
1094 | reply.status(HTTP_STATUS.OK).send({
1095 | success: true,
1096 | cancelledCount,
1097 | sessionId,
1098 | });
1099 | },
1100 | );
1101 |
1102 | // ============================================================
1103 | // Attachment Routes
1104 | // ============================================================
1105 |
1106 | /**
1107 | * GET /agent/attachments/stats
1108 | * Get statistics for all attachment caches.
1109 | */
1110 | fastify.get('/agent/attachments/stats', async (_request, reply) => {
1111 | try {
1112 | const stats = await attachmentService.getAttachmentStats();
1113 |
1114 | // Enrich with project names from database
1115 | const projects = await listProjects();
1116 | const projectMap = new Map(projects.map((p) => [p.id, p.name]));
1117 | const dbProjectIds = new Set(projects.map((p) => p.id));
1118 |
1119 | const enrichedProjects = stats.projects.map((p) => ({
1120 | ...p,
1121 | projectName: projectMap.get(p.projectId),
1122 | existsInDb: dbProjectIds.has(p.projectId),
1123 | }));
1124 |
1125 | const orphanProjectIds = stats.projects
1126 | .filter((p) => !dbProjectIds.has(p.projectId))
1127 | .map((p) => p.projectId);
1128 |
1129 | const response: AttachmentStatsResponse = {
1130 | success: true,
1131 | rootDir: stats.rootDir,
1132 | totalFiles: stats.totalFiles,
1133 | totalBytes: stats.totalBytes,
1134 | projects: enrichedProjects,
1135 | orphanProjectIds,
1136 | };
1137 |
1138 | reply.status(HTTP_STATUS.OK).send(response);
1139 | } catch (error) {
1140 | fastify.log.error({ err: error }, 'Failed to get attachment stats');
1141 | reply
1142 | .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
1143 | .send({ error: ERROR_MESSAGES.INTERNAL_SERVER_ERROR });
1144 | }
1145 | });
1146 |
1147 | /**
1148 | * GET /agent/attachments/:projectId/:filename
1149 | * Serve an attachment file.
1150 | */
1151 | fastify.get(
1152 | '/agent/attachments/:projectId/:filename',
1153 | async (
1154 | request: FastifyRequest<{ Params: { projectId: string; filename: string } }>,
1155 | reply: FastifyReply,
1156 | ) => {
1157 | const { projectId, filename } = request.params;
1158 |
1159 | try {
1160 | // Validate and get file
1161 | const buffer = await attachmentService.readAttachment(projectId, filename);
1162 |
1163 | // Determine content type from filename extension
1164 | const ext = filename.split('.').pop()?.toLowerCase();
1165 | let contentType = 'application/octet-stream';
1166 | switch (ext) {
1167 | case 'png':
1168 | contentType = 'image/png';
1169 | break;
1170 | case 'jpg':
1171 | case 'jpeg':
1172 | contentType = 'image/jpeg';
1173 | break;
1174 | case 'gif':
1175 | contentType = 'image/gif';
1176 | break;
1177 | case 'webp':
1178 | contentType = 'image/webp';
1179 | break;
1180 | }
1181 |
1182 | reply
1183 | .header('Content-Type', contentType)
1184 | .header('Cache-Control', 'public, max-age=31536000, immutable')
1185 | .send(buffer);
1186 | } catch (error) {
1187 | const message = error instanceof Error ? error.message : String(error);
1188 |
1189 | if (message.includes('Invalid') || message.includes('traversal')) {
1190 | reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: message });
1191 | return;
1192 | }
1193 |
1194 | // File not found or read error
1195 | reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Attachment not found' });
1196 | }
1197 | },
1198 | );
1199 |
1200 | /**
1201 | * DELETE /agent/attachments/:projectId
1202 | * Clean up attachments for a specific project.
1203 | */
1204 | fastify.delete(
1205 | '/agent/attachments/:projectId',
1206 | async (request: FastifyRequest<{ Params: { projectId: string } }>, reply: FastifyReply) => {
1207 | const { projectId } = request.params;
1208 |
1209 | try {
1210 | const result = await attachmentService.cleanupAttachments({ projectIds: [projectId] });
1211 |
1212 | const response: AttachmentCleanupResponse = {
1213 | success: true,
1214 | scope: 'project',
1215 | removedFiles: result.removedFiles,
1216 | removedBytes: result.removedBytes,
1217 | results: result.results,
1218 | };
1219 |
1220 | reply.status(HTTP_STATUS.OK).send(response);
1221 | } catch (error) {
1222 | fastify.log.error({ err: error }, 'Failed to cleanup project attachments');
1223 | reply
1224 | .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
1225 | .send({ error: ERROR_MESSAGES.INTERNAL_SERVER_ERROR });
1226 | }
1227 | },
1228 | );
1229 |
1230 | /**
1231 | * DELETE /agent/attachments
1232 | * Clean up attachments for all or selected projects.
1233 | */
1234 | fastify.delete(
1235 | '/agent/attachments',
1236 | async (request: FastifyRequest<{ Body?: AttachmentCleanupRequest }>, reply: FastifyReply) => {
1237 | try {
1238 | const body = request.body;
1239 | const projectIds = body?.projectIds;
1240 |
1241 | const result = await attachmentService.cleanupAttachments(
1242 | projectIds ? { projectIds } : undefined,
1243 | );
1244 |
1245 | const scope = projectIds && projectIds.length > 0 ? 'selected' : 'all';
1246 |
1247 | const response: AttachmentCleanupResponse = {
1248 | success: true,
1249 | scope,
1250 | removedFiles: result.removedFiles,
1251 | removedBytes: result.removedBytes,
1252 | results: result.results,
1253 | };
1254 |
1255 | reply.status(HTTP_STATUS.OK).send(response);
1256 | } catch (error) {
1257 | fastify.log.error({ err: error }, 'Failed to cleanup attachments');
1258 | reply
1259 | .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
1260 | .send({ error: ERROR_MESSAGES.INTERNAL_SERVER_ERROR });
1261 | }
1262 | },
1263 | );
1264 | }
1265 |
```