#
tokens: 40232/50000 2/574 files (page 43/60)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 43/60FirstPrevNextLast