diff --git a/.gitignore b/.gitignore
index b856834e..f5a95545 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,4 +36,14 @@ dist
false/
metadata-v1.3/
registry.npmmirror.com/
-registry.npmjs.com/
\ No newline at end of file
+registry.npmjs.com/
+
+other/
+tools_optimize.md
+Agents.md
+CLAUDE.md
+
+**/*/coverage/*
+
+.docs/
+.claude/
\ No newline at end of file
diff --git a/README.md b/README.md
index 7dab132b..170c0c27 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,6 @@
[](https://developer.chrome.com/docs/extensions/)
[](https://img.shields.io/github/v/release/hangwin/mcp-chrome.svg)
-
> 🌟 **Turn your Chrome browser into your intelligent assistant** - Let AI take control of your browser, transforming it into a powerful AI-controlled automation tool.
**📖 Documentation**: [English](README.md) | [中文](README_zh.md)
@@ -19,6 +18,10 @@
Chrome MCP Server is a Chrome extension-based **Model Context Protocol (MCP) server** that exposes your Chrome browser functionality to AI assistants like Claude, enabling complex browser automation, content analysis, and semantic search. Unlike traditional browser automation tools (like Playwright), **Chrome MCP Server** directly uses your daily Chrome browser, leveraging existing user habits, configurations, and login states, allowing various large models or chatbots to take control of your browser and truly become your everyday assistant.
+## ✨ New Features(2025/12/30)
+
+- **A New Visual Editor for Claude Code & Codex**, for more detail here: [VisualEditor](docs/VisualEditor.md)
+
## ✨ Core Features
- 😁 **Chatbot/Model Agnostic**: Let any LLM or chatbot client or agent you prefer automate your browser
@@ -46,7 +49,7 @@ Chrome MCP Server is a Chrome extension-based **Model Context Protocol (MCP) ser
### Prerequisites
-- Node.js >= 18.19.0 and pnpm/npm
+- Node.js >= 20.0.0 and pnpm/npm
- Chrome/Chromium browser
### Installation Steps
diff --git a/README_zh.md b/README_zh.md
index 7ab48820..016eea42 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -16,6 +16,10 @@
Chrome MCP Server 是一个基于chrome插件的 **模型上下文协议 (MCP) 服务器**,它将您的 Chrome 浏览器功能暴露给 Claude 等 AI 助手,实现复杂的浏览器自动化、内容分析和语义搜索等。与传统的浏览器自动化工具(如playwright)不同,**Chrome MCP server**直接使用您日常使用的chrome浏览器,基于现有的用户习惯和配置、登录态,让各种大模型或者各种chatbot都可以接管你的浏览器,真正成为你的日常助手
+## ✨ 船新的功能(2025/12/30)
+
+- **让Claude Code/Codex也能使用的可视化编辑器**, 更多详情请看: [VisualEditor](docs/VisualEditor_zh.md)
+
## ✨ 核心特性
- 😁 **chatbot/模型无关**:让任意你喜欢的llm或chatbot客户端或agent来自动化操作你的浏览器
@@ -43,7 +47,7 @@ Chrome MCP Server 是一个基于chrome插件的 **模型上下文协议 (MCP)
### 环境要求
-- Node.js >= 18.19.0 和 (npm 或 pnpm)
+- Node.js >= 20.0.0 和 (npm 或 pnpm)
- Chrome/Chromium 浏览器
### 安装步骤
@@ -79,7 +83,7 @@ mcp-chrome-bridge register
- 启用"开发者模式"
- 点击"加载已解压的扩展程序",选择 `your/dowloaded/extension/folder`
- 点击插件图标打开插件,点击连接即可看到mcp的配置
-
+
### 在支持MCP协议的客户端中使用
diff --git a/app/chrome-extension/_locales/en/messages.json b/app/chrome-extension/_locales/en/messages.json
index c7500979..cc9b8f5a 100644
--- a/app/chrome-extension/_locales/en/messages.json
+++ b/app/chrome-extension/_locales/en/messages.json
@@ -442,5 +442,63 @@
"pagesUnit": {
"message": "pages",
"description": "Pages count unit"
- }
+ },
+ "userscriptsManagerTitle": {
+ "message": "Userscripts Manager",
+ "description": "Options page title"
+ },
+ "emergencySwitchLabel": { "message": "Emergency Switch", "description": "Global disable switch" },
+ "createRunSectionTitle": {
+ "message": "Create / Run",
+ "description": "Create & run section title"
+ },
+ "nameLabel": { "message": "Name", "description": "Name input label" },
+ "runAtLabel": { "message": "Run At", "description": "runAt select label" },
+ "runAtAuto": { "message": "auto", "description": "runAt auto" },
+ "runAtDocumentStart": { "message": "document_start", "description": "runAt document_start" },
+ "runAtDocumentEnd": { "message": "document_end", "description": "runAt document_end" },
+ "runAtDocumentIdle": { "message": "document_idle", "description": "runAt document_idle" },
+ "worldLabel": { "message": "World", "description": "world select label" },
+ "worldAuto": { "message": "auto", "description": "world auto" },
+ "worldIsolated": { "message": "ISOLATED", "description": "ISOLATED world" },
+ "worldMain": { "message": "MAIN", "description": "MAIN world" },
+ "modeLabel": { "message": "Mode", "description": "mode select label" },
+ "modeAuto": { "message": "auto", "description": "mode auto" },
+ "modePersistent": { "message": "persistent", "description": "mode persistent" },
+ "modeCss": { "message": "css", "description": "mode css" },
+ "modeOnce": { "message": "once", "description": "mode once" },
+ "allFramesLabel": { "message": "All Frames", "description": "allFrames checkbox" },
+ "persistLabel": { "message": "Persist", "description": "persist checkbox" },
+ "dnrFallbackLabel": { "message": "DNR Fallback", "description": "dnr fallback checkbox" },
+ "matchesInputLabel": { "message": "Matches (comma-separated)", "description": "matches input" },
+ "excludesInputLabel": {
+ "message": "Excludes (comma-separated)",
+ "description": "excludes input"
+ },
+ "tagsInputLabel": { "message": "Tags (comma-separated)", "description": "tags input" },
+ "scriptLabel": { "message": "Script", "description": "script textarea label" },
+ "applyButton": { "message": "Apply", "description": "apply button" },
+ "runOnceButton": { "message": "Run Once (CDP)", "description": "run once button" },
+ "listSectionTitle": { "message": "List", "description": "list section title" },
+ "queryLabel": { "message": "Query", "description": "query input label" },
+ "statusAll": { "message": "all", "description": "status all" },
+ "statusEnabled": { "message": "enabled", "description": "status enabled" },
+ "statusDisabled": { "message": "disabled", "description": "status disabled" },
+ "domainLabel": { "message": "Domain", "description": "domain filter label" },
+ "exportAllButton": { "message": "Export All", "description": "export button" },
+ "tableHeaderName": { "message": "Name", "description": "table header name" },
+ "tableHeaderWorld": { "message": "World", "description": "table header world" },
+ "tableHeaderRunAt": { "message": "Run At", "description": "table header runAt" },
+ "tableHeaderUpdated": { "message": "Updated", "description": "table header updated" },
+ "deleteButton": { "message": "Delete", "description": "delete button" },
+ "placeholderOptional": { "message": "optional", "description": "generic optional placeholder" },
+ "placeholderMatchesExample": {
+ "message": "e.g. https://*.example.com/*",
+ "description": "matches example placeholder"
+ },
+ "placeholderScriptHint": {
+ "message": "Paste JS/CSS/TM here",
+ "description": "script textarea placeholder"
+ },
+ "placeholderDomainHint": { "message": "example.com", "description": "domain filter placeholder" }
}
diff --git a/app/chrome-extension/_locales/zh_CN/messages.json b/app/chrome-extension/_locales/zh_CN/messages.json
index 7c5a72ad..a8901ab1 100644
--- a/app/chrome-extension/_locales/zh_CN/messages.json
+++ b/app/chrome-extension/_locales/zh_CN/messages.json
@@ -442,5 +442,51 @@
"pagesUnit": {
"message": "页",
"description": "页面计数单位"
- }
+ },
+ "userscriptsManagerTitle": { "message": "脚本管理器", "description": "Options 页标题" },
+ "emergencySwitchLabel": { "message": "紧急开关", "description": "紧急关闭开关" },
+ "createRunSectionTitle": { "message": "创建 / 运行", "description": "创建与运行分区标题" },
+ "nameLabel": { "message": "名称", "description": "名称输入标签" },
+ "runAtLabel": { "message": "运行时机", "description": "runAt 选择标签" },
+ "runAtAuto": { "message": "自动", "description": "runAt auto" },
+ "runAtDocumentStart": { "message": "document_start", "description": "runAt document_start" },
+ "runAtDocumentEnd": { "message": "document_end", "description": "runAt document_end" },
+ "runAtDocumentIdle": { "message": "document_idle", "description": "runAt document_idle" },
+ "worldLabel": { "message": "执行上下文", "description": "world 选择标签" },
+ "worldAuto": { "message": "自动", "description": "world auto" },
+ "worldIsolated": { "message": "隔离 (ISOLATED)", "description": "ISOLATED world" },
+ "worldMain": { "message": "页面 (MAIN)", "description": "MAIN world" },
+ "modeLabel": { "message": "模式", "description": "模式选择标签" },
+ "modeAuto": { "message": "自动", "description": "mode auto" },
+ "modePersistent": { "message": "持久", "description": "mode persistent" },
+ "modeCss": { "message": "仅样式 (CSS)", "description": "mode css" },
+ "modeOnce": { "message": "一次运行 (CDP)", "description": "mode once" },
+ "allFramesLabel": { "message": "全部 frame", "description": "allFrames 复选框" },
+ "persistLabel": { "message": "持久化", "description": "persist 复选框" },
+ "dnrFallbackLabel": { "message": "DNR 回退", "description": "DNR fallback 复选框" },
+ "matchesInputLabel": { "message": "匹配(逗号分隔)", "description": "matches 输入" },
+ "excludesInputLabel": { "message": "排除(逗号分隔)", "description": "excludes 输入" },
+ "tagsInputLabel": { "message": "标签(逗号分隔)", "description": "tags 输入" },
+ "scriptLabel": { "message": "脚本", "description": "脚本文本标签" },
+ "applyButton": { "message": "应用", "description": "应用按钮" },
+ "runOnceButton": { "message": "一次运行(CDP)", "description": "一次运行按钮" },
+ "listSectionTitle": { "message": "脚本列表", "description": "列表分区标题" },
+ "queryLabel": { "message": "搜索", "description": "查询输入标签" },
+ "statusAll": { "message": "全部", "description": "状态-全部" },
+ "statusEnabled": { "message": "启用", "description": "状态-启用" },
+ "statusDisabled": { "message": "禁用", "description": "状态-禁用" },
+ "domainLabel": { "message": "域名", "description": "域名过滤标签" },
+ "exportAllButton": { "message": "导出全部", "description": "导出按钮" },
+ "tableHeaderName": { "message": "名称", "description": "表头-名称" },
+ "tableHeaderWorld": { "message": "执行上下文", "description": "表头-World" },
+ "tableHeaderRunAt": { "message": "运行时机", "description": "表头-RunAt" },
+ "tableHeaderUpdated": { "message": "更新时间", "description": "表头-更新时间" },
+ "deleteButton": { "message": "删除", "description": "删除按钮" },
+ "placeholderOptional": { "message": "可选", "description": "通用可选占位符" },
+ "placeholderMatchesExample": {
+ "message": "例如:https://*.example.com/*",
+ "description": "匹配示例占位符"
+ },
+ "placeholderScriptHint": { "message": "在此粘贴 JS/CSS/TM", "description": "脚本文本域占位符" },
+ "placeholderDomainHint": { "message": "example.com", "description": "域名筛选占位符" }
}
diff --git a/app/chrome-extension/common/agent-models.ts b/app/chrome-extension/common/agent-models.ts
new file mode 100644
index 00000000..85b81d7d
--- /dev/null
+++ b/app/chrome-extension/common/agent-models.ts
@@ -0,0 +1,270 @@
+/**
+ * Agent CLI Model Definitions.
+ *
+ * Static model definitions for each CLI type.
+ * Based on the pattern from Claudable (other/cweb).
+ */
+
+import type { CodexReasoningEffort } from 'chrome-mcp-shared';
+
+// ============================================================
+// Types
+// ============================================================
+
+export interface ModelDefinition {
+ id: string;
+ name: string;
+ description?: string;
+ supportsImages?: boolean;
+ /** Supported reasoning effort levels for Codex models */
+ supportedReasoningEfforts?: readonly CodexReasoningEffort[];
+}
+
+export type AgentCliType = 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm';
+
+// ============================================================
+// Claude Models
+// ============================================================
+
+export const CLAUDE_MODELS: ModelDefinition[] = [
+ {
+ id: 'claude-sonnet-4-5-20250929',
+ name: 'Claude Sonnet 4.5',
+ description: 'Balanced model with large context window',
+ supportsImages: true,
+ },
+ {
+ id: 'claude-opus-4-5-20251101',
+ name: 'Claude Opus 4.5',
+ description: 'Strongest reasoning model',
+ supportsImages: true,
+ },
+ {
+ id: 'claude-haiku-4-5-20251001',
+ name: 'Claude Haiku 4.5',
+ description: 'Fast and cost-efficient',
+ supportsImages: true,
+ },
+];
+
+export const CLAUDE_DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
+
+// ============================================================
+// Codex Models
+// ============================================================
+
+/** Standard reasoning efforts supported by all models */
+const CODEX_STANDARD_EFFORTS: readonly CodexReasoningEffort[] = ['low', 'medium', 'high'];
+/** Extended reasoning efforts (includes xhigh) - only for gpt-5.2 and gpt-5.1-codex-max */
+const CODEX_EXTENDED_EFFORTS: readonly CodexReasoningEffort[] = ['low', 'medium', 'high', 'xhigh'];
+
+export const CODEX_MODELS: ModelDefinition[] = [
+ {
+ id: 'gpt-5.1',
+ name: 'GPT-5.1',
+ description: 'OpenAI high-quality reasoning model',
+ supportedReasoningEfforts: CODEX_STANDARD_EFFORTS,
+ },
+ {
+ id: 'gpt-5.2',
+ name: 'GPT-5.2',
+ description: 'OpenAI flagship reasoning model with extended effort support',
+ supportedReasoningEfforts: CODEX_EXTENDED_EFFORTS,
+ },
+ {
+ id: 'gpt-5.1-codex',
+ name: 'GPT-5.1 Codex',
+ description: 'Coding-optimized model for agent workflows',
+ supportedReasoningEfforts: CODEX_STANDARD_EFFORTS,
+ },
+ {
+ id: 'gpt-5.1-codex-max',
+ name: 'GPT-5.1 Codex Max',
+ description: 'Highest quality coding model with extended effort support',
+ supportedReasoningEfforts: CODEX_EXTENDED_EFFORTS,
+ },
+ {
+ id: 'gpt-5.1-codex-mini',
+ name: 'GPT-5.1 Codex Mini',
+ description: 'Fast, cost-efficient coding model',
+ supportedReasoningEfforts: CODEX_STANDARD_EFFORTS,
+ },
+];
+
+export const CODEX_DEFAULT_MODEL = 'gpt-5.1';
+
+// Codex model alias normalization
+const CODEX_ALIAS_MAP: Record = {
+ gpt5: 'gpt-5.1',
+ gpt_5: 'gpt-5.1',
+ 'gpt-5': 'gpt-5.1',
+ 'gpt-5.0': 'gpt-5.1',
+};
+
+const CODEX_KNOWN_IDS = new Set(CODEX_MODELS.map((model) => model.id));
+
+/**
+ * Normalize a Codex model ID, handling aliases and falling back to default.
+ */
+export function normalizeCodexModelId(model?: string | null): string {
+ if (!model || typeof model !== 'string') {
+ return CODEX_DEFAULT_MODEL;
+ }
+
+ const trimmed = model.trim();
+ if (!trimmed) {
+ return CODEX_DEFAULT_MODEL;
+ }
+
+ const lower = trimmed.toLowerCase();
+ if (CODEX_ALIAS_MAP[lower]) {
+ return CODEX_ALIAS_MAP[lower];
+ }
+
+ if (CODEX_KNOWN_IDS.has(lower)) {
+ return lower;
+ }
+
+ // If the exact casing exists, allow it
+ if (CODEX_KNOWN_IDS.has(trimmed)) {
+ return trimmed;
+ }
+
+ return CODEX_DEFAULT_MODEL;
+}
+
+/**
+ * Get supported reasoning efforts for a Codex model.
+ * Returns standard efforts (low/medium/high) for unknown models.
+ */
+export function getCodexReasoningEfforts(modelId?: string | null): readonly CodexReasoningEffort[] {
+ const normalized = normalizeCodexModelId(modelId);
+ const model = CODEX_MODELS.find((m) => m.id === normalized);
+ return model?.supportedReasoningEfforts ?? CODEX_STANDARD_EFFORTS;
+}
+
+/**
+ * Check if a model supports xhigh reasoning effort.
+ */
+export function supportsXhighEffort(modelId?: string | null): boolean {
+ const efforts = getCodexReasoningEfforts(modelId);
+ return efforts.includes('xhigh');
+}
+
+// ============================================================
+// Cursor Models
+// ============================================================
+
+export const CURSOR_MODELS: ModelDefinition[] = [
+ {
+ id: 'auto',
+ name: 'Auto',
+ description: 'Cursor auto-selects the best model',
+ },
+ {
+ id: 'claude-sonnet-4-5-20250929',
+ name: 'Claude Sonnet 4.5',
+ description: 'Anthropic Claude via Cursor',
+ supportsImages: true,
+ },
+ {
+ id: 'gpt-4.1',
+ name: 'GPT-4.1',
+ description: 'OpenAI model via Cursor',
+ },
+];
+
+export const CURSOR_DEFAULT_MODEL = 'auto';
+
+// ============================================================
+// Qwen Models
+// ============================================================
+
+export const QWEN_MODELS: ModelDefinition[] = [
+ {
+ id: 'qwen3-coder-plus',
+ name: 'Qwen3 Coder Plus',
+ description: 'Balanced 32k context model for coding',
+ },
+ {
+ id: 'qwen3-coder-pro',
+ name: 'Qwen3 Coder Pro',
+ description: 'Larger 128k context with stronger reasoning',
+ },
+ {
+ id: 'qwen3-coder',
+ name: 'Qwen3 Coder',
+ description: 'Fast iteration model',
+ },
+];
+
+export const QWEN_DEFAULT_MODEL = 'qwen3-coder-plus';
+
+// ============================================================
+// GLM Models
+// ============================================================
+
+export const GLM_MODELS: ModelDefinition[] = [
+ {
+ id: 'glm-4.6',
+ name: 'GLM 4.6',
+ description: 'Zhipu GLM 4.6 agent runtime',
+ },
+];
+
+export const GLM_DEFAULT_MODEL = 'glm-4.6';
+
+// ============================================================
+// Aggregated Definitions
+// ============================================================
+
+export const CLI_MODEL_DEFINITIONS: Record = {
+ claude: CLAUDE_MODELS,
+ codex: CODEX_MODELS,
+ cursor: CURSOR_MODELS,
+ qwen: QWEN_MODELS,
+ glm: GLM_MODELS,
+};
+
+export const CLI_DEFAULT_MODELS: Record = {
+ claude: CLAUDE_DEFAULT_MODEL,
+ codex: CODEX_DEFAULT_MODEL,
+ cursor: CURSOR_DEFAULT_MODEL,
+ qwen: QWEN_DEFAULT_MODEL,
+ glm: GLM_DEFAULT_MODEL,
+};
+
+// ============================================================
+// Helper Functions
+// ============================================================
+
+/**
+ * Get model definitions for a specific CLI type.
+ */
+export function getModelsForCli(cli: string | null | undefined): ModelDefinition[] {
+ if (!cli) return [];
+ const key = cli.toLowerCase() as AgentCliType;
+ return CLI_MODEL_DEFINITIONS[key] || [];
+}
+
+/**
+ * Get the default model for a CLI type.
+ */
+export function getDefaultModelForCli(cli: string | null | undefined): string {
+ if (!cli) return '';
+ const key = cli.toLowerCase() as AgentCliType;
+ return CLI_DEFAULT_MODELS[key] || '';
+}
+
+/**
+ * Get display name for a model ID.
+ */
+export function getModelDisplayName(
+ cli: string | null | undefined,
+ modelId: string | null | undefined,
+): string {
+ if (!cli || !modelId) return modelId || '';
+ const models = getModelsForCli(cli);
+ const model = models.find((m) => m.id === modelId);
+ return model?.name || modelId;
+}
diff --git a/app/chrome-extension/common/constants.ts b/app/chrome-extension/common/constants.ts
index 6cd5cc4b..bae7ff01 100644
--- a/app/chrome-extension/common/constants.ts
+++ b/app/chrome-extension/common/constants.ts
@@ -56,6 +56,11 @@ export const SUCCESS_MESSAGES = {
SERVER_STOPPED: 'Server stopped successfully',
} as const;
+// External Links
+export const LINKS = {
+ TROUBLESHOOTING: 'https://github.com/hangwin/mcp-chrome/blob/master/docs/TROUBLESHOOTING.md',
+} as const;
+
// File Extensions and MIME Types
export const FILE_TYPES = {
STATIC_EXTENSIONS: [
@@ -77,12 +82,128 @@ export const FILE_TYPES = {
// Network Filtering
export const NETWORK_FILTERS = {
+ // Substring match against full URL (not just hostname) to support patterns like 'facebook.com/tr'
EXCLUDED_DOMAINS: [
+ // Google
'google-analytics.com',
'googletagmanager.com',
- 'facebook.com',
+ 'analytics.google.com',
'doubleclick.net',
'googlesyndication.com',
+ 'googleads.g.doubleclick.net',
+ 'stats.g.doubleclick.net',
+ 'adservice.google.com',
+ 'pagead2.googlesyndication.com',
+ // Amazon
+ 'amazon-adsystem.com',
+ // Microsoft
+ 'bat.bing.com',
+ 'clarity.ms',
+ // Facebook
+ 'connect.facebook.net',
+ 'facebook.com/tr',
+ // Twitter
+ 'analytics.twitter.com',
+ 'ads-twitter.com',
+ // Other ad networks
+ 'ads.yahoo.com',
+ 'adroll.com',
+ 'adnxs.com',
+ 'criteo.com',
+ 'quantserve.com',
+ 'scorecardresearch.com',
+ // Analytics & session recording
+ 'segment.io',
+ 'amplitude.com',
+ 'mixpanel.com',
+ 'optimizely.com',
+ 'static.hotjar.com',
+ 'script.hotjar.com',
+ 'crazyegg.com',
+ 'clicktale.net',
+ 'mouseflow.com',
+ 'fullstory.com',
+ // LinkedIn (tracking pixels)
+ 'linkedin.com/px',
+ ],
+ // Static resource extensions (used when includeStatic=false)
+ STATIC_RESOURCE_EXTENSIONS: [
+ '.jpg',
+ '.jpeg',
+ '.png',
+ '.gif',
+ '.svg',
+ '.webp',
+ '.ico',
+ '.bmp',
+ '.cur',
+ '.css',
+ '.scss',
+ '.less',
+ '.js',
+ '.jsx',
+ '.ts',
+ '.tsx',
+ '.map',
+ '.woff',
+ '.woff2',
+ '.ttf',
+ '.eot',
+ '.otf',
+ '.mp3',
+ '.mp4',
+ '.avi',
+ '.mov',
+ '.wmv',
+ '.flv',
+ '.webm',
+ '.ogg',
+ '.wav',
+ '.pdf',
+ '.zip',
+ '.rar',
+ '.7z',
+ '.iso',
+ '.dmg',
+ '.doc',
+ '.docx',
+ '.xls',
+ '.xlsx',
+ '.ppt',
+ '.pptx',
+ ],
+ // MIME types treated as static/binary (filtered when includeStatic=false)
+ STATIC_MIME_TYPES_TO_FILTER: [
+ 'image/',
+ 'font/',
+ 'audio/',
+ 'video/',
+ 'text/css',
+ 'text/javascript',
+ 'application/javascript',
+ 'application/x-javascript',
+ 'application/pdf',
+ 'application/zip',
+ 'application/octet-stream',
+ ],
+ // API-like MIME types (never filtered by MIME)
+ API_MIME_TYPES: [
+ 'application/json',
+ 'application/xml',
+ 'text/xml',
+ 'text/plain',
+ 'text/event-stream',
+ 'application/x-www-form-urlencoded',
+ 'application/graphql',
+ 'application/grpc',
+ 'application/protobuf',
+ 'application/x-protobuf',
+ 'application/x-json',
+ 'application/ld+json',
+ 'application/problem+json',
+ 'application/problem+xml',
+ 'application/soap+xml',
+ 'application/vnd.api+json',
],
STATIC_RESOURCE_TYPES: ['stylesheet', 'image', 'font', 'media', 'other'],
} as const;
@@ -99,9 +220,21 @@ export const SEMANTIC_CONFIG = {
// Storage Keys
export const STORAGE_KEYS = {
SERVER_STATUS: 'serverStatus',
+ NATIVE_SERVER_PORT: 'nativeServerPort',
+ NATIVE_AUTO_CONNECT_ENABLED: 'nativeAutoConnectEnabled',
SEMANTIC_MODEL: 'selectedModel',
USER_PREFERENCES: 'userPreferences',
VECTOR_INDEX: 'vectorIndex',
+ USERSCRIPTS: 'userscripts',
+ USERSCRIPTS_DISABLED: 'userscripts_disabled',
+ // Record & Replay storage keys
+ RR_FLOWS: 'rr_flows',
+ RR_RUNS: 'rr_runs',
+ RR_PUBLISHED: 'rr_published_flows',
+ RR_SCHEDULES: 'rr_schedules',
+ RR_TRIGGERS: 'rr_triggers',
+ // Persistent recording state (guards resume across navigations/service worker restarts)
+ RR_RECORDING_STATE: 'rr_recording_state',
} as const;
// Notification Configuration
diff --git a/app/chrome-extension/common/element-marker-types.ts b/app/chrome-extension/common/element-marker-types.ts
new file mode 100644
index 00000000..30403fea
--- /dev/null
+++ b/app/chrome-extension/common/element-marker-types.ts
@@ -0,0 +1,83 @@
+// Element marker types shared across background, content scripts, and popup
+
+export type UrlMatchType = 'exact' | 'prefix' | 'host';
+
+export interface ElementMarker {
+ id: string;
+ // Original URL where the marker was created
+ url: string;
+ // Normalized pieces to support matching
+ origin: string; // scheme + host + port
+ host: string; // hostname
+ path: string; // pathname part only
+ matchType: UrlMatchType; // default: 'prefix'
+
+ name: string; // Human-friendly name, e.g., "Login Button"
+ selector: string; // Selector string
+ selectorType?: 'css' | 'xpath'; // Default: css
+ listMode?: boolean; // Whether this marker was created in list mode (allows multiple matches)
+ action?: 'click' | 'fill' | 'custom'; // Intended action hint (optional)
+
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface UpsertMarkerRequest {
+ id?: string;
+ url: string;
+ name: string;
+ selector: string;
+ selectorType?: 'css' | 'xpath';
+ listMode?: boolean;
+ matchType?: UrlMatchType;
+ action?: 'click' | 'fill' | 'custom';
+}
+
+// Validation actions for MCP-integrated verification
+export enum MarkerValidationAction {
+ Hover = 'hover',
+ LeftClick = 'left_click',
+ RightClick = 'right_click',
+ DoubleClick = 'double_click',
+ TypeText = 'type_text',
+ PressKeys = 'press_keys',
+ Scroll = 'scroll',
+}
+
+export interface MarkerValidationRequest {
+ selector: string;
+ selectorType?: 'css' | 'xpath';
+ action: MarkerValidationAction;
+ // Optional payload for certain actions
+ text?: string; // for type_text
+ keys?: string; // for press_keys
+ // Event options for click-like actions
+ button?: 'left' | 'right' | 'middle';
+ bubbles?: boolean;
+ cancelable?: boolean;
+ modifiers?: { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean };
+ // Targeting options
+ coordinates?: { x: number; y: number }; // absolute viewport coords
+ offsetX?: number; // relative to element center if relativeTo = 'element'
+ offsetY?: number;
+ relativeTo?: 'element' | 'viewport';
+ // Navigation options for click-like actions
+ waitForNavigation?: boolean;
+ timeoutMs?: number;
+ // Scroll options
+ scrollDirection?: 'up' | 'down' | 'left' | 'right';
+ scrollAmount?: number; // pixels per tick
+}
+
+export interface MarkerValidationResponse {
+ success: boolean;
+ resolved?: boolean;
+ ref?: string;
+ center?: { x: number; y: number };
+ tool?: { name: string; ok: boolean; error?: string };
+ error?: string;
+}
+
+export interface MarkerQuery {
+ url?: string; // If present, query by URL match; otherwise list all
+}
diff --git a/app/chrome-extension/common/message-types.ts b/app/chrome-extension/common/message-types.ts
index 4cc03c86..96981858 100644
--- a/app/chrome-extension/common/message-types.ts
+++ b/app/chrome-extension/common/message-types.ts
@@ -3,6 +3,8 @@
* Note: Native message types are imported from the shared package
*/
+import type { RealtimeEvent } from 'chrome-mcp-shared';
+
// Message targets for routing
export enum MessageTarget {
Offscreen = 'offscreen',
@@ -21,6 +23,71 @@ export const BACKGROUND_MESSAGE_TYPES = {
REFRESH_SERVER_STATUS: 'refresh_server_status',
SERVER_STATUS_CHANGED: 'server_status_changed',
INITIALIZE_SEMANTIC_ENGINE: 'initialize_semantic_engine',
+ // Record & Replay background control and queries
+ RR_START_RECORDING: 'rr_start_recording',
+ RR_STOP_RECORDING: 'rr_stop_recording',
+ RR_PAUSE_RECORDING: 'rr_pause_recording',
+ RR_RESUME_RECORDING: 'rr_resume_recording',
+ RR_GET_RECORDING_STATUS: 'rr_get_recording_status',
+ RR_LIST_FLOWS: 'rr_list_flows',
+ RR_FLOWS_CHANGED: 'rr_flows_changed',
+ RR_GET_FLOW: 'rr_get_flow',
+ RR_DELETE_FLOW: 'rr_delete_flow',
+ RR_PUBLISH_FLOW: 'rr_publish_flow',
+ RR_UNPUBLISH_FLOW: 'rr_unpublish_flow',
+ RR_RUN_FLOW: 'rr_run_flow',
+ RR_SAVE_FLOW: 'rr_save_flow',
+ RR_EXPORT_FLOW: 'rr_export_flow',
+ RR_EXPORT_ALL: 'rr_export_all',
+ RR_IMPORT_FLOW: 'rr_import_flow',
+ RR_LIST_RUNS: 'rr_list_runs',
+ // Triggers
+ RR_LIST_TRIGGERS: 'rr_list_triggers',
+ RR_SAVE_TRIGGER: 'rr_save_trigger',
+ RR_DELETE_TRIGGER: 'rr_delete_trigger',
+ RR_REFRESH_TRIGGERS: 'rr_refresh_triggers',
+ // Scheduling
+ RR_SCHEDULE_FLOW: 'rr_schedule_flow',
+ RR_UNSCHEDULE_FLOW: 'rr_unschedule_flow',
+ RR_LIST_SCHEDULES: 'rr_list_schedules',
+ // Element marker management
+ ELEMENT_MARKER_LIST_ALL: 'element_marker_list_all',
+ ELEMENT_MARKER_LIST_FOR_URL: 'element_marker_list_for_url',
+ ELEMENT_MARKER_SAVE: 'element_marker_save',
+ ELEMENT_MARKER_UPDATE: 'element_marker_update',
+ ELEMENT_MARKER_DELETE: 'element_marker_delete',
+ ELEMENT_MARKER_VALIDATE: 'element_marker_validate',
+ ELEMENT_MARKER_START: 'element_marker_start_from_popup',
+ // Element picker (human-in-the-loop element selection)
+ ELEMENT_PICKER_UI_EVENT: 'element_picker_ui_event',
+ ELEMENT_PICKER_FRAME_EVENT: 'element_picker_frame_event',
+ // Web editor (in-page visual editing)
+ WEB_EDITOR_TOGGLE: 'web_editor_toggle',
+ WEB_EDITOR_APPLY: 'web_editor_apply',
+ WEB_EDITOR_STATUS_QUERY: 'web_editor_status_query',
+ // Web editor <-> AgentChat integration (Phase 1.1)
+ WEB_EDITOR_APPLY_BATCH: 'web_editor_apply_batch',
+ WEB_EDITOR_TX_CHANGED: 'web_editor_tx_changed',
+ WEB_EDITOR_HIGHLIGHT_ELEMENT: 'web_editor_highlight_element',
+ // Web editor <-> AgentChat integration (Phase 2 - Revert)
+ WEB_EDITOR_REVERT_ELEMENT: 'web_editor_revert_element',
+ // Web editor <-> AgentChat integration - Selection sync
+ WEB_EDITOR_SELECTION_CHANGED: 'web_editor_selection_changed',
+ // Web editor <-> AgentChat integration - Clear selection (sidepanel -> web-editor)
+ WEB_EDITOR_CLEAR_SELECTION: 'web_editor_clear_selection',
+ // Web editor <-> AgentChat integration - Cancel execution
+ WEB_EDITOR_CANCEL_EXECUTION: 'web_editor_cancel_execution',
+ // Web editor props (Phase 7.1.6 early injection)
+ WEB_EDITOR_PROPS_REGISTER_EARLY_INJECTION: 'web_editor_props_register_early_injection',
+ // Web editor props - open source file in VSCode
+ WEB_EDITOR_OPEN_SOURCE: 'web_editor_open_source',
+ // Quick Panel <-> AgentChat integration
+ QUICK_PANEL_SEND_TO_AI: 'quick_panel_send_to_ai',
+ QUICK_PANEL_CANCEL_AI: 'quick_panel_cancel_ai',
+ // Quick Panel Search - Tabs bridge
+ QUICK_PANEL_TABS_QUERY: 'quick_panel_tabs_query',
+ QUICK_PANEL_TAB_ACTIVATE: 'quick_panel_tab_activate',
+ QUICK_PANEL_TAB_CLOSE: 'quick_panel_tab_close',
} as const;
// Offscreen message types
@@ -29,6 +96,10 @@ export const OFFSCREEN_MESSAGE_TYPES = {
SIMILARITY_ENGINE_COMPUTE: 'similarityEngineCompute',
SIMILARITY_ENGINE_BATCH_COMPUTE: 'similarityEngineBatchCompute',
SIMILARITY_ENGINE_STATUS: 'similarityEngineStatus',
+ // GIF encoding
+ GIF_ADD_FRAME: 'gifAddFrame',
+ GIF_FINISH: 'gifFinish',
+ GIF_RESET: 'gifReset',
} as const;
// Content script message types
@@ -41,6 +112,9 @@ export const CONTENT_MESSAGE_TYPES = {
KEYBOARD_HELPER_PING: 'keyboard_helper_ping',
SCREENSHOT_HELPER_PING: 'screenshot_helper_ping',
INTERACTIVE_ELEMENTS_HELPER_PING: 'interactive_elements_helper_ping',
+ ACCESSIBILITY_TREE_HELPER_PING: 'chrome_read_page_ping',
+ WAIT_HELPER_PING: 'wait_helper_ping',
+ DOM_OBSERVER_PING: 'dom_observer_ping',
} as const;
// Tool action message types (for chrome.runtime.sendMessage)
@@ -64,12 +138,44 @@ export const TOOL_MESSAGE_TYPES = {
// Interactive elements
GET_INTERACTIVE_ELEMENTS: 'getInteractiveElements',
+ // Accessibility tree
+ GENERATE_ACCESSIBILITY_TREE: 'generateAccessibilityTree',
+ RESOLVE_REF: 'resolveRef',
+ ENSURE_REF_FOR_SELECTOR: 'ensureRefForSelector',
+ VERIFY_FINGERPRINT: 'verifyFingerprint',
+ DISPATCH_HOVER_FOR_REF: 'dispatchHoverForRef',
+
// Network requests
NETWORK_SEND_REQUEST: 'sendPureNetworkRequest',
+ // Wait helper
+ WAIT_FOR_TEXT: 'waitForText',
+
// Semantic similarity engine
SIMILARITY_ENGINE_INIT: 'similarityEngineInit',
SIMILARITY_ENGINE_COMPUTE_BATCH: 'similarityEngineComputeBatch',
+ // Record & Replay content script bridge
+ RR_RECORDER_CONTROL: 'rr_recorder_control',
+ RR_RECORDER_EVENT: 'rr_recorder_event',
+ // Record & Replay timeline feed (background -> content overlay)
+ RR_TIMELINE_UPDATE: 'rr_timeline_update',
+ // Quick Panel AI streaming events (background -> content script)
+ QUICK_PANEL_AI_EVENT: 'quick_panel_ai_event',
+ // DOM observer trigger bridge
+ SET_DOM_TRIGGERS: 'set_dom_triggers',
+ DOM_TRIGGER_FIRED: 'dom_trigger_fired',
+ // Record & Replay overlay: variable collection
+ COLLECT_VARIABLES: 'collectVariables',
+ // Element marker overlay control (content-side)
+ ELEMENT_MARKER_START: 'element_marker_start',
+ // Element picker (tool-driven, background <-> content scripts)
+ ELEMENT_PICKER_START: 'elementPickerStart',
+ ELEMENT_PICKER_STOP: 'elementPickerStop',
+ ELEMENT_PICKER_SET_ACTIVE_REQUEST: 'elementPickerSetActiveRequest',
+ ELEMENT_PICKER_UI_PING: 'elementPickerUiPing',
+ ELEMENT_PICKER_UI_SHOW: 'elementPickerUiShow',
+ ELEMENT_PICKER_UI_UPDATE: 'elementPickerUiUpdate',
+ ELEMENT_PICKER_UI_HIDE: 'elementPickerUiHide',
} as const;
// Type unions for type safety
@@ -112,3 +218,178 @@ export enum SendMessageType {
SimilarityEngineInit = 'similarityEngineInit',
SimilarityEngineComputeBatch = 'similarityEngineComputeBatch',
}
+
+// ============================================================
+// Quick Panel <-> AgentChat Message Contracts
+// ============================================================
+
+/**
+ * Context information that can be attached to a Quick Panel AI request.
+ * Allows passing page-specific data to enhance the AI's understanding.
+ */
+export interface QuickPanelAIContext {
+ /** Current page URL */
+ pageUrl?: string;
+ /** User's text selection on the page */
+ selectedText?: string;
+ /**
+ * Optional element metadata from the page.
+ * Kept as unknown to avoid tight coupling with specific element types.
+ */
+ elementInfo?: unknown;
+}
+
+/**
+ * Payload for sending a message to AI via Quick Panel.
+ */
+export interface QuickPanelSendToAIPayload {
+ /** The user's instruction/question for the AI */
+ instruction: string;
+ /** Optional contextual information from the page */
+ context?: QuickPanelAIContext;
+}
+
+/**
+ * Response from QUICK_PANEL_SEND_TO_AI message handler.
+ */
+export type QuickPanelSendToAIResponse =
+ | { success: true; requestId: string; sessionId: string }
+ | { success: false; error: string };
+
+/**
+ * Message structure for sending to AI.
+ */
+export interface QuickPanelSendToAIMessage {
+ type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_SEND_TO_AI;
+ payload: QuickPanelSendToAIPayload;
+}
+
+/**
+ * Payload for cancelling an active AI request.
+ */
+export interface QuickPanelCancelAIPayload {
+ /** The request ID to cancel */
+ requestId: string;
+ /**
+ * Optional session ID for fallback when background state is missing.
+ * This can happen after MV3 Service Worker restarts.
+ */
+ sessionId?: string;
+}
+
+/**
+ * Response from QUICK_PANEL_CANCEL_AI message handler.
+ */
+export type QuickPanelCancelAIResponse = { success: true } | { success: false; error: string };
+
+/**
+ * Message structure for cancelling AI request.
+ */
+export interface QuickPanelCancelAIMessage {
+ type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CANCEL_AI;
+ payload: QuickPanelCancelAIPayload;
+}
+
+/**
+ * Message pushed from background to content script with AI streaming events.
+ * Uses the same RealtimeEvent type as AgentChat for consistency.
+ */
+export interface QuickPanelAIEventMessage {
+ action: typeof TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT;
+ requestId: string;
+ sessionId: string;
+ event: RealtimeEvent;
+}
+
+// ============================================================
+// Quick Panel Search - Tabs Bridge Contracts
+// ============================================================
+
+/**
+ * Payload for querying open tabs.
+ */
+export interface QuickPanelTabsQueryPayload {
+ /**
+ * When true (default), query tabs across all windows.
+ * When false, restrict results to the sender's window.
+ */
+ includeAllWindows?: boolean;
+}
+
+/**
+ * Summary of a single tab returned from the background.
+ */
+export interface QuickPanelTabSummary {
+ tabId: number;
+ windowId: number;
+ title: string;
+ url: string;
+ favIconUrl?: string;
+ active: boolean;
+ pinned: boolean;
+ audible: boolean;
+ muted: boolean;
+ index: number;
+ lastAccessed?: number;
+}
+
+/**
+ * Response from QUICK_PANEL_TABS_QUERY message handler.
+ */
+export type QuickPanelTabsQueryResponse =
+ | {
+ success: true;
+ tabs: QuickPanelTabSummary[];
+ currentTabId: number | null;
+ currentWindowId: number | null;
+ }
+ | { success: false; error: string };
+
+/**
+ * Message structure for querying tabs.
+ */
+export interface QuickPanelTabsQueryMessage {
+ type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TABS_QUERY;
+ payload?: QuickPanelTabsQueryPayload;
+}
+
+/**
+ * Payload for activating a tab.
+ */
+export interface QuickPanelActivateTabPayload {
+ tabId: number;
+ windowId?: number;
+}
+
+/**
+ * Response from QUICK_PANEL_TAB_ACTIVATE message handler.
+ */
+export type QuickPanelActivateTabResponse = { success: true } | { success: false; error: string };
+
+/**
+ * Message structure for activating a tab.
+ */
+export interface QuickPanelActivateTabMessage {
+ type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE;
+ payload: QuickPanelActivateTabPayload;
+}
+
+/**
+ * Payload for closing a tab.
+ */
+export interface QuickPanelCloseTabPayload {
+ tabId: number;
+}
+
+/**
+ * Response from QUICK_PANEL_TAB_CLOSE message handler.
+ */
+export type QuickPanelCloseTabResponse = { success: true } | { success: false; error: string };
+
+/**
+ * Message structure for closing a tab.
+ */
+export interface QuickPanelCloseTabMessage {
+ type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE;
+ payload: QuickPanelCloseTabPayload;
+}
diff --git a/app/chrome-extension/common/node-types.ts b/app/chrome-extension/common/node-types.ts
new file mode 100644
index 00000000..38c828c9
--- /dev/null
+++ b/app/chrome-extension/common/node-types.ts
@@ -0,0 +1,14 @@
+// node-types.ts — centralized node type constants for Builder/UI layer
+// Combines all executable Step types with UI-only nodes (e.g., trigger, delay)
+
+import { STEP_TYPES } from './step-types';
+
+export const NODE_TYPES = {
+ // Executable step types (spread from STEP_TYPES)
+ ...STEP_TYPES,
+ // UI-only nodes
+ TRIGGER: 'trigger',
+ DELAY: 'delay',
+} as const;
+
+export type NodeTypeConst = (typeof NODE_TYPES)[keyof typeof NODE_TYPES];
diff --git a/app/chrome-extension/common/rr-v3-keepalive-protocol.ts b/app/chrome-extension/common/rr-v3-keepalive-protocol.ts
new file mode 100644
index 00000000..cf33f7d8
--- /dev/null
+++ b/app/chrome-extension/common/rr-v3-keepalive-protocol.ts
@@ -0,0 +1,26 @@
+/**
+ * @fileoverview RR V3 Keepalive Protocol Constants
+ * @description Shared protocol constants for Background-Offscreen keepalive communication
+ */
+
+/** Keepalive Port 名称 */
+export const RR_V3_KEEPALIVE_PORT_NAME = 'rr_v3_keepalive' as const;
+
+/** Keepalive 消息类型 */
+export type KeepaliveMessageType =
+ | 'keepalive.ping'
+ | 'keepalive.pong'
+ | 'keepalive.start'
+ | 'keepalive.stop';
+
+/** Keepalive 消息 */
+export interface KeepaliveMessage {
+ type: KeepaliveMessageType;
+ timestamp: number;
+}
+
+/** 默认心跳间隔(毫秒) - Offscreen 每隔这个间隔发送 ping */
+export const DEFAULT_KEEPALIVE_PING_INTERVAL_MS = 20_000;
+
+/** 最大心跳间隔(毫秒)- Chrome MV3 SW 约 30s 空闲后终止 */
+export const MAX_KEEPALIVE_PING_INTERVAL_MS = 25_000;
diff --git a/app/chrome-extension/common/step-types.ts b/app/chrome-extension/common/step-types.ts
new file mode 100644
index 00000000..16d56800
--- /dev/null
+++ b/app/chrome-extension/common/step-types.ts
@@ -0,0 +1,4 @@
+// step-types.ts — re-export shared constants to keep single source of truth
+export { STEP_TYPES } from 'chrome-mcp-shared';
+export type StepTypeConst =
+ (typeof import('chrome-mcp-shared'))['STEP_TYPES'][keyof (typeof import('chrome-mcp-shared'))['STEP_TYPES']];
diff --git a/app/chrome-extension/common/web-editor-types.ts b/app/chrome-extension/common/web-editor-types.ts
new file mode 100644
index 00000000..815dbebf
--- /dev/null
+++ b/app/chrome-extension/common/web-editor-types.ts
@@ -0,0 +1,539 @@
+/**
+ * Web Editor V2 - Shared Type Definitions
+ *
+ * This module defines types shared between:
+ * - Background script (injection control)
+ * - Inject script (web-editor-v2.ts)
+ * - Future: UI panels
+ */
+
+// =============================================================================
+// Editor State
+// =============================================================================
+
+/** Current state of the web editor */
+export interface WebEditorState {
+ /** Whether the editor is currently active */
+ active: boolean;
+ /** Editor version for compatibility checks */
+ version: 2;
+}
+
+// =============================================================================
+// Message Protocol (Background <-> Inject Script)
+// =============================================================================
+
+/**
+ * Action types for web editor V2 messages
+ *
+ * IMPORTANT: V2 uses versioned action names (suffix _v2) to avoid
+ * conflicts with V1 when both scripts might be injected in the same tab.
+ * This prevents double-response race conditions.
+ *
+ * V1 uses: web_editor_ping, web_editor_toggle, etc.
+ * V2 uses: web_editor_ping_v2, web_editor_toggle_v2, etc.
+ */
+export const WEB_EDITOR_V2_ACTIONS = {
+ /** Check if V2 editor is injected and get status */
+ PING: 'web_editor_ping_v2',
+ /** Toggle V2 editor on/off */
+ TOGGLE: 'web_editor_toggle_v2',
+ /** Start V2 editor */
+ START: 'web_editor_start_v2',
+ /** Stop V2 editor */
+ STOP: 'web_editor_stop_v2',
+ /** Highlight an element (from sidepanel hover) */
+ HIGHLIGHT_ELEMENT: 'web_editor_highlight_element_v2',
+ /** Revert an element to its original state (Phase 2 - Selective Undo) */
+ REVERT_ELEMENT: 'web_editor_revert_element_v2',
+ /** Clear selection (from sidepanel after send) */
+ CLEAR_SELECTION: 'web_editor_clear_selection_v2',
+} as const;
+
+/**
+ * Legacy V1 action types (for reference and background compatibility)
+ * These are used when USE_WEB_EDITOR_V2 is false
+ */
+export const WEB_EDITOR_V1_ACTIONS = {
+ PING: 'web_editor_ping',
+ TOGGLE: 'web_editor_toggle',
+ START: 'web_editor_start',
+ STOP: 'web_editor_stop',
+ APPLY: 'web_editor_apply',
+} as const;
+
+export type WebEditorV2Action = (typeof WEB_EDITOR_V2_ACTIONS)[keyof typeof WEB_EDITOR_V2_ACTIONS];
+export type WebEditorV1Action = (typeof WEB_EDITOR_V1_ACTIONS)[keyof typeof WEB_EDITOR_V1_ACTIONS];
+
+/** Editor version literal type */
+export type WebEditorVersion = 1 | 2;
+
+/** Ping request (V2) */
+export interface WebEditorV2PingRequest {
+ action: typeof WEB_EDITOR_V2_ACTIONS.PING;
+}
+
+/** Ping response (V2) */
+export interface WebEditorV2PingResponse {
+ status: 'pong';
+ active: boolean;
+ version: 2;
+}
+
+/** Toggle request (V2) */
+export interface WebEditorV2ToggleRequest {
+ action: typeof WEB_EDITOR_V2_ACTIONS.TOGGLE;
+}
+
+/** Toggle response (V2) */
+export interface WebEditorV2ToggleResponse {
+ active: boolean;
+}
+
+/** Start request (V2) */
+export interface WebEditorV2StartRequest {
+ action: typeof WEB_EDITOR_V2_ACTIONS.START;
+}
+
+/** Start response (V2) */
+export interface WebEditorV2StartResponse {
+ active: boolean;
+}
+
+/** Stop request (V2) */
+export interface WebEditorV2StopRequest {
+ action: typeof WEB_EDITOR_V2_ACTIONS.STOP;
+}
+
+/** Stop response (V2) */
+export interface WebEditorV2StopResponse {
+ active: boolean;
+}
+
+/** Union types for V2 type-safe message handling */
+export type WebEditorV2Request =
+ | WebEditorV2PingRequest
+ | WebEditorV2ToggleRequest
+ | WebEditorV2StartRequest
+ | WebEditorV2StopRequest;
+
+export type WebEditorV2Response =
+ | WebEditorV2PingResponse
+ | WebEditorV2ToggleResponse
+ | WebEditorV2StartResponse
+ | WebEditorV2StopResponse;
+
+// =============================================================================
+// Element Locator (Phase 1 - Basic Structure)
+// =============================================================================
+
+/**
+ * Framework debug source information
+ * Extracted from React Fiber or Vue component instance
+ */
+export interface DebugSource {
+ /** Source file path */
+ file: string;
+ /** Line number (1-based) */
+ line?: number;
+ /** Column number (1-based) */
+ column?: number;
+ /** Component name (if available) */
+ componentName?: string;
+}
+
+/**
+ * Element Locator - Primary key for element identification
+ *
+ * Uses multiple strategies to locate elements, supporting:
+ * - HMR/DOM changes recovery
+ * - Cross-session persistence
+ * - Framework-agnostic identification
+ */
+export interface ElementLocator {
+ /** CSS selector candidates (ordered by specificity) */
+ selectors: string[];
+ /** Structural fingerprint for similarity matching */
+ fingerprint: string;
+ /** Framework debug information (React/Vue) */
+ debugSource?: DebugSource;
+ /** DOM tree path (child indices from root) */
+ path: number[];
+ /** iframe selector chain (from top to target frame) - Phase 4 */
+ frameChain?: string[];
+ /** Shadow DOM host selector chain - Phase 2 */
+ shadowHostChain?: string[];
+}
+
+// =============================================================================
+// Transaction System (Phase 1 - Basic Structure, Low Priority)
+// =============================================================================
+
+/** Transaction operation types */
+export type TransactionType = 'style' | 'text' | 'class' | 'move' | 'structure';
+
+/**
+ * Transaction snapshot for undo/redo
+ * Captures element state before/after changes
+ */
+export interface TransactionSnapshot {
+ /** Element locator for re-identification */
+ locator: ElementLocator;
+ /** innerHTML snapshot (for structure changes) */
+ html?: string;
+ /** Changed style properties */
+ styles?: Record;
+ /** Class list tokens (from `class` attribute) */
+ classes?: string[];
+ /** Text content */
+ text?: string;
+}
+
+/**
+ * Move position data
+ * Captures a concrete insertion point under a parent element
+ */
+export interface MoveOperationData {
+ /** Target parent element locator */
+ parentLocator: ElementLocator;
+ /** Insert position index (among element children) */
+ insertIndex: number;
+ /** Anchor sibling element locator (for stable positioning) */
+ anchorLocator?: ElementLocator;
+ /** Position relative to anchor */
+ anchorPosition: 'before' | 'after';
+}
+
+/**
+ * Move transaction data
+ * Captures both source and destination for undo/redo
+ */
+export interface MoveTransactionData {
+ /** Original location before move */
+ from: MoveOperationData;
+ /** Target location after move */
+ to: MoveOperationData;
+}
+
+/**
+ * Structure operation data
+ * For wrap/unwrap/delete/duplicate operations (Phase 5.5)
+ */
+export interface StructureOperationData {
+ /** Structure action type */
+ action: 'wrap' | 'unwrap' | 'delete' | 'duplicate';
+ /** Wrapper tag for wrap/unwrap actions */
+ wrapperTag?: string;
+ /** Wrapper inline styles for wrap/unwrap actions */
+ wrapperStyles?: Record;
+ /**
+ * Deterministic insertion position for undo/redo.
+ * Required for delete (restore) and duplicate (re-create).
+ */
+ position?: MoveOperationData;
+ /**
+ * Serialized element HTML for undo/redo.
+ * Must be a single-root element outerHTML string.
+ * Used by delete (restore original) and duplicate (re-create clone).
+ */
+ html?: string;
+}
+
+/**
+ * Transaction record for undo/redo system
+ */
+export interface Transaction {
+ /** Unique transaction ID */
+ id: string;
+ /** Operation type */
+ type: TransactionType;
+ /** Target element locator */
+ targetLocator: ElementLocator;
+ /**
+ * Stable element identifier for cross-transaction grouping.
+ * Used by AgentChat integration for element chips aggregation.
+ * Optional for backward compatibility with existing transactions.
+ */
+ elementKey?: string;
+ /** State before change */
+ before: TransactionSnapshot;
+ /** State after change */
+ after: TransactionSnapshot;
+ /** Move-specific data */
+ moveData?: MoveTransactionData;
+ /** Structure-specific data */
+ structureData?: StructureOperationData;
+ /** Timestamp */
+ timestamp: number;
+ /** Whether merged with previous transaction */
+ merged: boolean;
+}
+
+// =============================================================================
+// AgentChat Integration Types (Phase 1.1)
+// =============================================================================
+
+/** Stable element identifier for aggregating transactions across UI contexts */
+export type WebEditorElementKey = string;
+
+/**
+ * Net effect payload for a single element aggregated from the undo stack.
+ * Designed to be directly consumable by prompt builders.
+ */
+export interface NetEffectPayload {
+ /** Stable element key */
+ elementKey: WebEditorElementKey;
+ /** Locator snapshot for element re-identification */
+ locator: ElementLocator;
+ /**
+ * Aggregated style changes (first before -> last after).
+ * Contains ONLY the affected properties, not a full style snapshot.
+ * Empty string value means the property was removed/unset.
+ */
+ styleChanges?: {
+ before: Record;
+ after: Record;
+ };
+ /** Aggregated text change (first before -> last after) */
+ textChange?: {
+ before: string;
+ after: string;
+ };
+ /** Aggregated class changes (first before -> last after) */
+ classChanges?: {
+ before: string[];
+ after: string[];
+ };
+}
+
+/** High-level change category for UI display */
+export type ElementChangeType = 'style' | 'text' | 'class' | 'mixed';
+
+/**
+ * Element change summary for Chips rendering in AgentChat.
+ * Aggregates multiple transactions for the same element.
+ */
+export interface ElementChangeSummary {
+ /** Stable element identifier */
+ elementKey: WebEditorElementKey;
+ /** Short label for Chips display (e.g., "button#submit") */
+ label: string;
+ /** Full label for tooltips with more context */
+ fullLabel: string;
+ /** Locator snapshot for highlighting and element recovery */
+ locator: ElementLocator;
+ /** High-level change category */
+ type: ElementChangeType;
+ /** Detailed change statistics for UI tooltips */
+ changes: {
+ style?: {
+ /** Number of new style properties added */
+ added: number;
+ /** Number of style properties removed */
+ removed: number;
+ /** Number of style properties modified */
+ modified: number;
+ /** List of affected style property names */
+ details: string[];
+ };
+ text?: {
+ /** Truncated preview of original text */
+ beforePreview: string;
+ /** Truncated preview of new text */
+ afterPreview: string;
+ };
+ class?: {
+ /** Classes added */
+ added: string[];
+ /** Classes removed */
+ removed: string[];
+ };
+ };
+ /** Contributing transaction IDs in chronological order */
+ transactionIds: string[];
+ /** Net effect payload for batch Apply */
+ netEffect: NetEffectPayload;
+ /** Timestamp of the most recent transaction */
+ updatedAt: number;
+ /** Debug source information if available */
+ debugSource?: DebugSource;
+}
+
+/** Action types for TX change events */
+export type WebEditorTxChangeAction = 'push' | 'merge' | 'undo' | 'redo' | 'clear' | 'rollback';
+
+/**
+ * TX change broadcast payload sent to Sidepanel/AgentChat.
+ * Emitted when the undo stack changes (push, undo, redo, clear).
+ */
+export interface WebEditorTxChangedPayload {
+ /** Source tab ID for multi-tab isolation */
+ tabId: number;
+ /** Action that triggered this change (for UI animations/incremental updates) */
+ action: WebEditorTxChangeAction;
+ /** Aggregated element-level summaries from the current undo stack */
+ elements: ElementChangeSummary[];
+ /** Current undo stack size */
+ undoCount: number;
+ /** Current redo stack size */
+ redoCount: number;
+ /** Whether there are applicable changes (style/text/class) */
+ hasApplicableChanges: boolean;
+ /** Page URL for context */
+ pageUrl?: string;
+}
+
+/**
+ * Batch Apply payload sent from web-editor to background.
+ */
+export interface WebEditorApplyBatchPayload {
+ /** Source tab ID */
+ tabId: number;
+ /** Element changes to apply */
+ elements: ElementChangeSummary[];
+ /** Element keys excluded by user */
+ excludedKeys: WebEditorElementKey[];
+ /** Page URL for context */
+ pageUrl?: string;
+}
+
+/**
+ * Highlight element request sent from AgentChat to the active tab.
+ */
+export interface WebEditorHighlightElementPayload {
+ /** Target tab ID */
+ tabId: number;
+ /** Element key to highlight */
+ elementKey: WebEditorElementKey;
+ /** Locator for element identification */
+ locator: ElementLocator;
+ /** Highlight mode: 'hover' to show, 'clear' to hide */
+ mode: 'hover' | 'clear';
+}
+
+/**
+ * Revert element request sent from AgentChat to the active tab.
+ * Used for Phase 2 - Selective Undo (reverting individual element changes).
+ */
+export interface WebEditorRevertElementPayload {
+ /** Target tab ID */
+ tabId: number;
+ /** Element key to revert */
+ elementKey: WebEditorElementKey;
+}
+
+/**
+ * Revert element response from content script.
+ */
+export interface WebEditorRevertElementResponse {
+ /** Whether the revert was successful */
+ success: boolean;
+ /** What was reverted (for UI feedback) */
+ reverted?: {
+ style?: boolean;
+ text?: boolean;
+ class?: boolean;
+ };
+ /** Error message if revert failed */
+ error?: string;
+}
+
+// =============================================================================
+// Selection Sync Types
+// =============================================================================
+
+/**
+ * Summary of currently selected element.
+ * Lightweight payload for selection sync (no transaction data).
+ */
+export interface SelectedElementSummary {
+ /** Stable element identifier */
+ elementKey: WebEditorElementKey;
+ /** Locator for element identification and highlighting */
+ locator: ElementLocator;
+ /** Short display label (e.g., "div#app") */
+ label: string;
+ /** Full label with context (e.g., "body > div#app") */
+ fullLabel: string;
+ /** Tag name of the element */
+ tagName: string;
+ /** Timestamp for deduplication */
+ updatedAt: number;
+}
+
+/**
+ * Selection change broadcast payload.
+ * Sent immediately when user selects/deselects elements (no debounce).
+ */
+export interface WebEditorSelectionChangedPayload {
+ /** Source tab ID (filled by background from sender.tab.id) */
+ tabId: number;
+ /** Currently selected element, or null if deselected */
+ selected: SelectedElementSummary | null;
+ /** Page URL for context */
+ pageUrl?: string;
+}
+
+// =============================================================================
+// Execution Cancel Types
+// =============================================================================
+
+/**
+ * Payload for canceling an ongoing Apply execution.
+ * Sent from web-editor toolbar or sidepanel to background.
+ */
+export interface WebEditorCancelExecutionPayload {
+ /** Session ID of the execution to cancel */
+ sessionId: string;
+ /** Request ID of the execution to cancel */
+ requestId: string;
+}
+
+/**
+ * Response from cancel execution request.
+ */
+export interface WebEditorCancelExecutionResponse {
+ /** Whether the cancel request was successful */
+ success: boolean;
+ /** Error message if cancellation failed */
+ error?: string;
+}
+
+// =============================================================================
+// Public API Interface
+// =============================================================================
+
+/**
+ * Web Editor V2 Public API
+ * Exposed on window.__MCP_WEB_EDITOR_V2__
+ */
+export interface WebEditorV2Api {
+ /** Start the editor */
+ start: () => void;
+ /** Stop the editor */
+ stop: () => void;
+ /** Toggle editor on/off, returns new state */
+ toggle: () => boolean;
+ /** Get current state */
+ getState: () => WebEditorState;
+ /**
+ * Revert a specific element to its original state (Phase 2 - Selective Undo).
+ * Creates a compensating transaction that can be undone.
+ */
+ revertElement: (elementKey: WebEditorElementKey) => Promise;
+ /**
+ * Clear current selection (called from sidepanel after send).
+ * Triggers deselect and broadcasts null selection.
+ */
+ clearSelection: () => void;
+}
+
+// =============================================================================
+// Global Declaration
+// =============================================================================
+
+declare global {
+ interface Window {
+ __MCP_WEB_EDITOR_V2__?: WebEditorV2Api;
+ }
+}
diff --git a/app/chrome-extension/entrypoints/background/element-marker/element-marker-storage.ts b/app/chrome-extension/entrypoints/background/element-marker/element-marker-storage.ts
new file mode 100644
index 00000000..7a90956e
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/element-marker/element-marker-storage.ts
@@ -0,0 +1,95 @@
+// IndexedDB storage for element markers (URL -> marked selectors)
+// Uses the shared IndexedDbClient for robust transaction handling.
+
+import { IndexedDbClient } from '@/utils/indexeddb-client';
+import type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';
+
+const DB_NAME = 'element_marker_storage';
+const DB_VERSION = 1;
+const STORE = 'markers';
+
+const idb = new IndexedDbClient(DB_NAME, DB_VERSION, (db, oldVersion) => {
+ switch (oldVersion) {
+ case 0: {
+ const store = db.createObjectStore(STORE, { keyPath: 'id' });
+ // Useful indexes for lookups
+ store.createIndex('by_host', 'host', { unique: false });
+ store.createIndex('by_origin', 'origin', { unique: false });
+ store.createIndex('by_path', 'path', { unique: false });
+ }
+ }
+});
+
+function normalizeUrl(raw: string): { url: string; origin: string; host: string; path: string } {
+ try {
+ const u = new URL(raw);
+ return { url: raw, origin: u.origin, host: u.hostname, path: u.pathname };
+ } catch {
+ return { url: raw, origin: '', host: '', path: '' };
+ }
+}
+
+function now(): number {
+ return Date.now();
+}
+
+export async function listAllMarkers(): Promise {
+ return idb.getAll(STORE);
+}
+
+export async function listMarkersForUrl(url: string): Promise {
+ const { origin, path, host } = normalizeUrl(url);
+ const all = await idb.getAll(STORE);
+ // Simple matching policy:
+ // - exact: origin + path must match exactly
+ // - prefix: origin matches and marker.path is a prefix of current path
+ // - host: host matches regardless of path
+ return all.filter((m) => {
+ if (!m) return false;
+ if (m.matchType === 'exact') return m.origin === origin && m.path === path;
+ if (m.matchType === 'host') return !!m.host && m.host === host;
+ // default 'prefix'
+ return m.origin === origin && (m.path ? path.startsWith(m.path) : true);
+ });
+}
+
+export async function saveMarker(req: UpsertMarkerRequest): Promise {
+ const { url: rawUrl, selector } = req;
+ if (!rawUrl || !selector) throw new Error('url and selector are required');
+ const { url, origin, host, path } = normalizeUrl(rawUrl);
+ const ts = now();
+ const marker: ElementMarker = {
+ id: req.id || (globalThis.crypto?.randomUUID?.() ?? `${ts}_${Math.random()}`),
+ url,
+ origin,
+ host,
+ path,
+ matchType: req.matchType || 'prefix',
+ name: req.name || selector,
+ selector,
+ selectorType: req.selectorType || 'css',
+ listMode: req.listMode || false,
+ action: req.action || 'custom',
+ createdAt: ts,
+ updatedAt: ts,
+ };
+ await idb.put(STORE, marker);
+ return marker;
+}
+
+export async function updateMarker(marker: ElementMarker): Promise {
+ const existing = await idb.get(STORE, marker.id);
+ if (!existing) throw new Error('marker not found');
+
+ // Preserve createdAt from existing record, only update updatedAt
+ const updated: ElementMarker = {
+ ...marker,
+ createdAt: existing.createdAt, // Never overwrite createdAt
+ updatedAt: now(),
+ };
+ await idb.put(STORE, updated);
+}
+
+export async function deleteMarker(id: string): Promise {
+ await idb.delete(STORE, id);
+}
diff --git a/app/chrome-extension/entrypoints/background/element-marker/index.ts b/app/chrome-extension/entrypoints/background/element-marker/index.ts
new file mode 100644
index 00000000..01d4a1b1
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/element-marker/index.ts
@@ -0,0 +1,409 @@
+import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
+import type {
+ UpsertMarkerRequest,
+ ElementMarker,
+ MarkerValidationRequest,
+ MarkerValidationAction,
+} from '@/common/element-marker-types';
+import {
+ deleteMarker,
+ listAllMarkers,
+ listMarkersForUrl,
+ saveMarker,
+ updateMarker,
+} from './element-marker-storage';
+import { computerTool } from '@/entrypoints/background/tools/browser/computer';
+import { clickTool } from '@/entrypoints/background/tools/browser/interaction';
+import { keyboardTool } from '@/entrypoints/background/tools/browser/keyboard';
+
+const CONTEXT_MENU_ID = 'element_marker_mark';
+
+/**
+ * Extract error message from MCP tool result
+ */
+function extractToolError(result: any): string | undefined {
+ if (!result) return undefined;
+
+ // Check for error in result content array
+ if (Array.isArray(result.content)) {
+ for (const item of result.content) {
+ if (item?.text) {
+ try {
+ const parsed = JSON.parse(item.text);
+ if (parsed?.error) return parsed.error;
+ if (parsed?.message) return parsed.message;
+ } catch {
+ // Not JSON, use as-is
+ return item.text;
+ }
+ }
+ }
+ }
+
+ // Fallback to direct error field
+ return result.error || (result.isError ? 'unknown tool error' : undefined);
+}
+
+async function ensureContextMenu() {
+ try {
+ // Guard: contextMenus permission may be missing
+ if (!(chrome as any).contextMenus?.create) return;
+ // Remove and re-create our single menu to avoid duplication
+ try {
+ await chrome.contextMenus.remove(CONTEXT_MENU_ID);
+ } catch {}
+ await chrome.contextMenus.create({
+ id: CONTEXT_MENU_ID,
+ title: '标注元素',
+ contexts: ['all'],
+ });
+ } catch (e) {
+ console.warn('ElementMarker: ensureContextMenu failed:', e);
+ }
+}
+
+/**
+ * Check if element-marker.js is already injected in the tab
+ * Uses a short timeout to avoid hanging on unresponsive tabs
+ */
+async function isMarkerInjected(tabId: number): Promise {
+ try {
+ const response = await Promise.race([
+ chrome.tabs.sendMessage(tabId, { action: 'element_marker_ping' }),
+ new Promise((resolve) => setTimeout(() => resolve(null), 300)),
+ ]);
+ return response?.status === 'pong';
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Inject element-marker.js into the tab if not already injected
+ */
+async function injectMarkerHelper(tabId: number) {
+ // Check if already injected via ping
+ const alreadyInjected = await isMarkerInjected(tabId);
+
+ if (!alreadyInjected) {
+ try {
+ await chrome.scripting.executeScript({
+ target: { tabId, allFrames: true },
+ files: ['inject-scripts/element-marker.js'],
+ world: 'ISOLATED',
+ } as any);
+ } catch (e) {
+ // Script injection may fail on some pages (e.g., chrome:// URLs)
+ console.warn('ElementMarker: script injection failed:', e);
+ }
+ }
+
+ try {
+ await chrome.tabs.sendMessage(tabId, { action: 'element_marker_start' } as any);
+ } catch (e) {
+ console.warn('ElementMarker: start overlay failed:', e);
+ }
+}
+
+export function initElementMarkerListeners() {
+ // Ensure context menu on startup
+ ensureContextMenu().catch(() => {});
+
+ // Respond to RR triggers refresh by re-ensuring our menu a bit later
+ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
+ try {
+ switch (message?.type) {
+ // Handle element marker start from popup
+ case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_START: {
+ const tabId = message.tabId;
+ if (typeof tabId !== 'number') {
+ sendResponse({ success: false, error: 'invalid tabId' });
+ return true;
+ }
+ injectMarkerHelper(tabId)
+ .then(() => sendResponse({ success: true }))
+ .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
+ return true;
+ }
+ case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_ALL: {
+ listAllMarkers()
+ .then((markers) => sendResponse({ success: true, markers }))
+ .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
+ return true;
+ }
+ case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_FOR_URL: {
+ const url = String(message.url || '');
+ listMarkersForUrl(url)
+ .then((markers) => sendResponse({ success: true, markers }))
+ .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
+ return true;
+ }
+ case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE: {
+ const req = message.marker as UpsertMarkerRequest;
+ saveMarker(req)
+ .then((marker) => sendResponse({ success: true, marker }))
+ .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
+ return true;
+ }
+ case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_UPDATE: {
+ const marker = message.marker as ElementMarker;
+ updateMarker(marker)
+ .then(() => sendResponse({ success: true }))
+ .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
+ return true;
+ }
+ case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_DELETE: {
+ const id = String(message.id || '');
+ if (!id) {
+ sendResponse({ success: false, error: 'invalid id' });
+ return true;
+ }
+ deleteMarker(id)
+ .then(() => sendResponse({ success: true }))
+ .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
+ return true;
+ }
+ case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_VALIDATE: {
+ // Validate via MCP tool chain
+ (async () => {
+ const req = message as {
+ selector: string;
+ selectorType?: 'css' | 'xpath';
+ action: MarkerValidationAction;
+ listMode?: boolean;
+ text?: string;
+ keys?: string;
+ button?: 'left' | 'right' | 'middle';
+ bubbles?: boolean;
+ cancelable?: boolean;
+ modifiers?: any;
+ coordinates?: { x: number; y: number };
+ offsetX?: number;
+ offsetY?: number;
+ relativeTo?: 'element' | 'viewport';
+ };
+ // enrich typing with optional nav + scroll params
+ (req as any).waitForNavigation = (message as any).waitForNavigation;
+ (req as any).timeoutMs = (message as any).timeoutMs;
+ (req as any).scrollDirection = (message as any).scrollDirection;
+ (req as any).scrollAmount = (message as any).scrollAmount;
+ const selector = String(req.selector || '').trim();
+ const selectorType = (req.selectorType || 'css') as 'css' | 'xpath';
+ const action = req.action as MarkerValidationAction;
+ if (!selector) return sendResponse({ success: false, error: 'selector is required' });
+ const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
+ const tab = tabs[0];
+ if (!tab?.id) return sendResponse({ success: false, error: 'active tab not found' });
+
+ // 1) Ensure helper
+ try {
+ await chrome.scripting.executeScript({
+ target: { tabId: tab.id, allFrames: true },
+ files: ['inject-scripts/accessibility-tree-helper.js'],
+ world: 'ISOLATED',
+ } as any);
+ } catch {}
+
+ // 2) Resolve selector -> ref/center via helper (same as tools)
+ let ensured: any;
+ try {
+ ensured = await chrome.tabs.sendMessage(tab.id, {
+ action: 'ensureRefForSelector',
+ selector,
+ isXPath: selectorType === 'xpath',
+ allowMultiple: !!req.listMode,
+ } as any);
+ } catch (e) {
+ return sendResponse({
+ success: false,
+ error: String(e instanceof Error ? e.message : e),
+ });
+ }
+ if (!ensured || !ensured.success || !ensured.ref) {
+ return sendResponse({
+ success: false,
+ error: ensured?.error || 'failed to resolve selector',
+ });
+ }
+
+ const base = {
+ success: true,
+ resolved: true,
+ ref: ensured.ref,
+ center: ensured.center,
+ } as any;
+
+ // Compute optional coordinates from offsets
+ let coords: { x: number; y: number } | undefined = undefined;
+ if (
+ req.coordinates &&
+ typeof req.coordinates.x === 'number' &&
+ typeof req.coordinates.y === 'number'
+ ) {
+ coords = { x: Math.round(req.coordinates.x), y: Math.round(req.coordinates.y) };
+ } else if (
+ req.relativeTo === 'element' &&
+ ensured.center &&
+ (typeof req.offsetX === 'number' || typeof req.offsetY === 'number')
+ ) {
+ const dx = Number.isFinite(req.offsetX as any) ? (req.offsetX as number) : 0;
+ const dy = Number.isFinite(req.offsetY as any) ? (req.offsetY as number) : 0;
+ coords = { x: ensured.center.x + dx, y: ensured.center.y + dy };
+ }
+
+ // 3) Dispatch to appropriate tool for end-to-end validation
+ try {
+ switch (action) {
+ case 'hover': {
+ const r = await computerTool.execute(
+ coords
+ ? { action: 'hover', coordinates: coords }
+ : ({ action: 'hover', ref: ensured.ref } as any),
+ );
+ const error = r.isError ? extractToolError(r) : undefined;
+ base.tool = { name: 'computer.hover', ok: !r.isError, error };
+ break;
+ }
+ case 'left_click': {
+ const r = await clickTool.execute({
+ ...(coords ? { coordinates: coords } : { ref: ensured.ref }),
+ waitForNavigation: !!req.waitForNavigation,
+ timeout: Number.isFinite(req.timeoutMs as any)
+ ? (req.timeoutMs as number)
+ : 3000,
+ button: (req.button || 'left') as any,
+ modifiers: req.modifiers || {},
+ } as any);
+ const error = r.isError ? extractToolError(r) : undefined;
+ base.tool = { name: 'interaction.click', ok: !r.isError, error };
+ break;
+ }
+ case 'double_click': {
+ const r = await clickTool.execute({
+ ...(coords ? { coordinates: coords } : { ref: ensured.ref }),
+ double: true,
+ waitForNavigation: !!req.waitForNavigation,
+ timeout: Number.isFinite(req.timeoutMs as any)
+ ? (req.timeoutMs as number)
+ : 3000,
+ button: (req.button || 'left') as any,
+ modifiers: req.modifiers || {},
+ } as any);
+ const error = r.isError ? extractToolError(r) : undefined;
+ base.tool = { name: 'interaction.click(double)', ok: !r.isError, error };
+ break;
+ }
+ case 'right_click': {
+ const r = await clickTool.execute({
+ ...(coords ? { coordinates: coords } : { ref: ensured.ref }),
+ waitForNavigation: !!req.waitForNavigation,
+ timeout: Number.isFinite(req.timeoutMs as any)
+ ? (req.timeoutMs as number)
+ : 3000,
+ button: 'right',
+ modifiers: req.modifiers || {},
+ } as any);
+ const error = r.isError ? extractToolError(r) : undefined;
+ base.tool = { name: 'interaction.click(right)', ok: !r.isError, error };
+ break;
+ }
+ case 'scroll': {
+ const direction = (req as any).scrollDirection || 'down';
+ const amount = Number.isFinite((req as any).scrollAmount)
+ ? Number((req as any).scrollAmount)
+ : 300;
+ const payload = coords
+ ? {
+ action: 'scroll',
+ scrollDirection: direction,
+ scrollAmount: amount,
+ coordinates: coords,
+ }
+ : ({
+ action: 'scroll',
+ scrollDirection: direction,
+ scrollAmount: amount,
+ ref: ensured.ref,
+ } as any);
+ const r = await computerTool.execute(payload as any);
+ const error = r.isError ? extractToolError(r) : undefined;
+ base.tool = { name: 'computer.scroll', ok: !r.isError, error };
+ break;
+ }
+ case 'type_text': {
+ const text = String(req.text || '');
+ const r = await computerTool.execute({ action: 'type', ref: ensured.ref, text });
+ const error = r.isError ? extractToolError(r) : undefined;
+ base.tool = { name: 'computer.type', ok: !r.isError, error };
+ break;
+ }
+ case 'press_keys': {
+ const keys = String(req.keys || '');
+ // Focus first by ref to ensure key target
+ try {
+ await clickTool.execute({
+ ref: ensured.ref,
+ waitForNavigation: false,
+ timeout: 2000,
+ });
+ } catch {}
+ const r = await keyboardTool.execute({ keys, delay: 0 } as any);
+ const error = r.isError ? extractToolError(r) : undefined;
+ base.tool = { name: 'keyboard.simulate', ok: !r.isError, error };
+ break;
+ }
+ default: {
+ base.tool = { name: 'noop', ok: true };
+ }
+ }
+ } catch (e) {
+ console.warn('[ElementMarker] Validation failed before tool execution', e);
+ base.tool = {
+ name: 'unknown',
+ ok: false,
+ error: String(e instanceof Error ? e.message : e),
+ };
+ }
+
+ // Log tool failures for debugging
+ if (base.tool && base.tool.ok === false) {
+ console.warn('[ElementMarker] Tool validation failure', {
+ action,
+ toolName: base.tool.name,
+ error: base.tool.error,
+ selector,
+ selectorType,
+ });
+ }
+
+ return sendResponse(base);
+ })();
+ return true;
+ }
+ // When RR refresh (or similar) happens, re-add our menu
+ case BACKGROUND_MESSAGE_TYPES.RR_REFRESH_TRIGGERS:
+ case BACKGROUND_MESSAGE_TYPES.RR_SAVE_TRIGGER:
+ case BACKGROUND_MESSAGE_TYPES.RR_DELETE_TRIGGER: {
+ setTimeout(() => ensureContextMenu().catch(() => {}), 300);
+ break;
+ }
+ }
+ } catch (e) {
+ sendResponse({ success: false, error: (e as any)?.message || String(e) });
+ }
+ return false;
+ });
+
+ // Context menu click routing
+ if ((chrome as any).contextMenus?.onClicked?.addListener) {
+ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
+ try {
+ if (info.menuItemId === CONTEXT_MENU_ID && tab?.id) {
+ await injectMarkerHelper(tab.id);
+ }
+ } catch (e) {
+ console.warn('ElementMarker: context menu click failed:', e);
+ }
+ });
+ }
+}
diff --git a/app/chrome-extension/entrypoints/background/index.ts b/app/chrome-extension/entrypoints/background/index.ts
index ee592910..fcac7e3b 100644
--- a/app/chrome-extension/entrypoints/background/index.ts
+++ b/app/chrome-extension/entrypoints/background/index.ts
@@ -5,16 +5,65 @@ import {
} from './semantic-similarity';
import { initStorageManagerListener } from './storage-manager';
import { cleanupModelCache } from '@/utils/semantic-similarity-engine';
+import { initRecordReplayListeners } from './record-replay';
+import { initElementMarkerListeners } from './element-marker';
+import { initWebEditorListeners } from './web-editor';
+import { initQuickPanelAgentHandler } from './quick-panel/agent-handler';
+import { initQuickPanelCommands } from './quick-panel/commands';
+import { initQuickPanelTabsHandler } from './quick-panel/tabs-handler';
+
+// Record-Replay V3 (feature flag)
+import { bootstrapV3 } from './record-replay-v3/bootstrap';
+
+/**
+ * Feature flag for RR-V3
+ * Set to true to enable the new Record-Replay V3 engine
+ */
+const ENABLE_RR_V3 = true;
/**
* Background script entry point
* Initializes all background services and listeners
*/
export default defineBackground(() => {
+ // Open welcome page on first install
+ chrome.runtime.onInstalled.addListener((details) => {
+ if (details.reason === 'install') {
+ // Open the welcome/onboarding page for new installations
+ chrome.tabs.create({
+ url: chrome.runtime.getURL('/welcome.html'),
+ });
+ }
+ });
+
// Initialize core services
initNativeHostListener();
initSemanticSimilarityListener();
initStorageManagerListener();
+ // Record & Replay V1/V2 listeners
+ initRecordReplayListeners();
+
+ // Record & Replay V3 (new engine)
+ if (ENABLE_RR_V3) {
+ bootstrapV3()
+ .then((runtime) => {
+ console.log(`[RR-V3] Bootstrap complete, ownerId: ${runtime.ownerId}`);
+ })
+ .catch((error) => {
+ console.error('[RR-V3] Bootstrap failed:', error);
+ });
+ }
+
+ // Element marker: context menu + CRUD listeners
+ initElementMarkerListeners();
+ // Web editor: toggle edit-mode overlay
+ initWebEditorListeners();
+ // Quick Panel: send messages to AgentChat via background-stream bridge
+ initQuickPanelAgentHandler();
+ // Quick Panel: tabs search bridge for content script UI
+ initQuickPanelTabsHandler();
+ // Quick Panel: keyboard shortcut handler
+ initQuickPanelCommands();
// Conditionally initialize semantic similarity engine if model cache exists
initializeSemanticEngineIfCached()
diff --git a/app/chrome-extension/entrypoints/background/keepalive-manager.ts b/app/chrome-extension/entrypoints/background/keepalive-manager.ts
new file mode 100644
index 00000000..ca1c20f7
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/keepalive-manager.ts
@@ -0,0 +1,87 @@
+/**
+ * @fileoverview Keepalive Manager
+ * @description Global singleton service for managing Service Worker keepalive.
+ *
+ * This module provides a unified interface for acquiring and releasing keepalive
+ * references. Multiple modules can acquire keepalive independently using tags,
+ * and the underlying keepalive mechanism will remain active as long as at least
+ * one reference is held.
+ */
+
+import {
+ createOffscreenKeepaliveController,
+ type KeepaliveController,
+} from './record-replay-v3/engine/keepalive/offscreen-keepalive';
+
+const LOG_PREFIX = '[KeepaliveManager]';
+
+/**
+ * Singleton keepalive controller instance.
+ * Created lazily to avoid initialization issues during module loading.
+ */
+let controller: KeepaliveController | null = null;
+
+/**
+ * Get or create the singleton keepalive controller.
+ */
+function getController(): KeepaliveController {
+ if (!controller) {
+ controller = createOffscreenKeepaliveController({ logger: console });
+ console.debug(`${LOG_PREFIX} Controller initialized`);
+ }
+ return controller;
+}
+
+/**
+ * Acquire a keepalive reference with a tag.
+ *
+ * @param tag - Identifier for the reference (e.g., 'native-host', 'rr-engine')
+ * @returns A release function to call when keepalive is no longer needed
+ *
+ * @example
+ * ```typescript
+ * const release = acquireKeepalive('native-host');
+ * // ... do work that needs SW to stay alive ...
+ * release(); // Release when done
+ * ```
+ */
+export function acquireKeepalive(tag: string): () => void {
+ try {
+ const release = getController().acquire(tag);
+ console.debug(`${LOG_PREFIX} Acquired keepalive for tag: ${tag}`);
+ return () => {
+ try {
+ release();
+ console.debug(`${LOG_PREFIX} Released keepalive for tag: ${tag}`);
+ } catch (error) {
+ console.warn(`${LOG_PREFIX} Failed to release keepalive for ${tag}:`, error);
+ }
+ };
+ } catch (error) {
+ console.warn(`${LOG_PREFIX} Failed to acquire keepalive for ${tag}:`, error);
+ return () => {};
+ }
+}
+
+/**
+ * Check if keepalive is currently active (any references held).
+ */
+export function isKeepaliveActive(): boolean {
+ try {
+ return getController().isActive();
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Get the current keepalive reference count.
+ * Useful for debugging.
+ */
+export function getKeepaliveRefCount(): number {
+ try {
+ return getController().getRefCount();
+ } catch {
+ return 0;
+ }
+}
diff --git a/app/chrome-extension/entrypoints/background/native-host.ts b/app/chrome-extension/entrypoints/background/native-host.ts
index 72a9df39..6cc79e18 100644
--- a/app/chrome-extension/entrypoints/background/native-host.ts
+++ b/app/chrome-extension/entrypoints/background/native-host.ts
@@ -1,18 +1,32 @@
import { NativeMessageType } from 'chrome-mcp-shared';
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
-import {
- NATIVE_HOST,
- ICONS,
- NOTIFICATIONS,
- STORAGE_KEYS,
- ERROR_MESSAGES,
- SUCCESS_MESSAGES,
-} from '@/common/constants';
+import { NATIVE_HOST, STORAGE_KEYS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '@/common/constants';
import { handleCallTool } from './tools';
+import { listPublished, getFlow } from './record-replay/flow-store';
+import { acquireKeepalive } from './keepalive-manager';
+
+const LOG_PREFIX = '[NativeHost]';
let nativePort: chrome.runtime.Port | null = null;
export const HOST_NAME = NATIVE_HOST.NAME;
+// ==================== Reconnect Configuration ====================
+
+const RECONNECT_BASE_DELAY_MS = 500;
+const RECONNECT_MAX_DELAY_MS = 60_000;
+const RECONNECT_MAX_FAST_ATTEMPTS = 8;
+const RECONNECT_COOLDOWN_DELAY_MS = 5 * 60_000;
+
+// ==================== Auto-connect State ====================
+
+let keepaliveRelease: (() => void) | null = null;
+let autoConnectEnabled = true;
+let autoConnectLoaded = false;
+let ensurePromise: Promise | null = null;
+let reconnectTimer: ReturnType | null = null;
+let reconnectAttempts = 0;
+let manualDisconnect = false;
+
/**
* Server status management interface
*/
@@ -70,26 +84,266 @@ function broadcastServerStatusChange(status: ServerStatus): void {
});
}
+// ==================== Port Normalization ====================
+
+/**
+ * Normalize a port value to a valid port number or null.
+ */
+function normalizePort(value: unknown): number | null {
+ const n =
+ typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;
+ if (!Number.isFinite(n)) return null;
+ const port = Math.floor(n);
+ if (port <= 0 || port > 65535) return null;
+ return port;
+}
+
+// ==================== Reconnect Utilities ====================
+
+/**
+ * Add jitter to a delay value to avoid thundering herd.
+ */
+function withJitter(ms: number): number {
+ const ratio = 0.7 + Math.random() * 0.6;
+ return Math.max(0, Math.round(ms * ratio));
+}
+
+/**
+ * Calculate reconnect delay based on attempt number.
+ * Uses exponential backoff with jitter, then switches to cooldown interval.
+ */
+function getReconnectDelayMs(attempt: number): number {
+ if (attempt >= RECONNECT_MAX_FAST_ATTEMPTS) {
+ return withJitter(RECONNECT_COOLDOWN_DELAY_MS);
+ }
+ const delay = Math.min(RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt), RECONNECT_MAX_DELAY_MS);
+ return withJitter(delay);
+}
+
+/**
+ * Clear the reconnect timer if active.
+ */
+function clearReconnectTimer(): void {
+ if (!reconnectTimer) return;
+ clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+}
+
+/**
+ * Reset reconnect state after successful connection.
+ */
+function resetReconnectState(): void {
+ reconnectAttempts = 0;
+ clearReconnectTimer();
+}
+
+// ==================== Keepalive Management ====================
+
+/**
+ * Sync keepalive hold based on autoConnectEnabled state.
+ * When auto-connect is enabled, we hold a keepalive reference to keep SW alive.
+ */
+function syncKeepaliveHold(): void {
+ if (autoConnectEnabled) {
+ if (!keepaliveRelease) {
+ keepaliveRelease = acquireKeepalive('native-host');
+ console.debug(`${LOG_PREFIX} Acquired keepalive`);
+ }
+ return;
+ }
+ if (keepaliveRelease) {
+ try {
+ keepaliveRelease();
+ console.debug(`${LOG_PREFIX} Released keepalive`);
+ } catch {
+ // Ignore
+ }
+ keepaliveRelease = null;
+ }
+}
+
+// ==================== Auto-connect Settings ====================
+
+/**
+ * Load the nativeAutoConnectEnabled setting from storage.
+ */
+async function loadNativeAutoConnectEnabled(): Promise {
+ try {
+ const result = await chrome.storage.local.get([STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]);
+ const raw = result[STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED];
+ if (typeof raw === 'boolean') return raw;
+ } catch (error) {
+ console.warn(`${LOG_PREFIX} Failed to load nativeAutoConnectEnabled`, error);
+ }
+ return true; // Default to enabled
+}
+
+/**
+ * Set the nativeAutoConnectEnabled setting and persist to storage.
+ */
+async function setNativeAutoConnectEnabled(enabled: boolean): Promise {
+ autoConnectEnabled = enabled;
+ autoConnectLoaded = true;
+ try {
+ await chrome.storage.local.set({ [STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]: enabled });
+ console.debug(`${LOG_PREFIX} Set nativeAutoConnectEnabled=${enabled}`);
+ } catch (error) {
+ console.warn(`${LOG_PREFIX} Failed to persist nativeAutoConnectEnabled`, error);
+ }
+ syncKeepaliveHold();
+}
+
+// ==================== Port Preference ====================
+
+/**
+ * Get the preferred port for connecting to native server.
+ * Priority: explicit override > user preference > last known port > default
+ */
+async function getPreferredPort(override?: unknown): Promise {
+ const explicit = normalizePort(override);
+ if (explicit) return explicit;
+
+ try {
+ const result = await chrome.storage.local.get([
+ STORAGE_KEYS.NATIVE_SERVER_PORT,
+ STORAGE_KEYS.SERVER_STATUS,
+ ]);
+
+ const userPort = normalizePort(result[STORAGE_KEYS.NATIVE_SERVER_PORT]);
+ if (userPort) return userPort;
+
+ const status = result[STORAGE_KEYS.SERVER_STATUS] as Partial | undefined;
+ const statusPort = normalizePort(status?.port);
+ if (statusPort) return statusPort;
+ } catch (error) {
+ console.warn(`${LOG_PREFIX} Failed to read preferred port`, error);
+ }
+
+ const inMemoryPort = normalizePort(currentServerStatus.port);
+ if (inMemoryPort) return inMemoryPort;
+
+ return NATIVE_HOST.DEFAULT_PORT;
+}
+
+// ==================== Reconnect Scheduling ====================
+
+/**
+ * Schedule a reconnect attempt with exponential backoff.
+ */
+function scheduleReconnect(reason: string): void {
+ if (nativePort) return;
+ if (manualDisconnect) return;
+ if (!autoConnectEnabled) return;
+ if (reconnectTimer) return;
+
+ const delay = getReconnectDelayMs(reconnectAttempts);
+ console.debug(
+ `${LOG_PREFIX} Reconnect scheduled in ${delay}ms (attempt=${reconnectAttempts}, reason=${reason})`,
+ );
+
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null;
+ if (nativePort) return;
+ if (manualDisconnect || !autoConnectEnabled) return;
+
+ reconnectAttempts += 1;
+ void ensureNativeConnected(`reconnect:${reason}`).catch(() => {});
+ }, delay);
+}
+
+// ==================== Server Status Update ====================
+
+/**
+ * Mark server as stopped and broadcast the change.
+ */
+async function markServerStopped(reason: string): Promise {
+ currentServerStatus = {
+ isRunning: false,
+ port: currentServerStatus.port,
+ lastUpdated: Date.now(),
+ };
+ try {
+ await saveServerStatus(currentServerStatus);
+ } catch {
+ // Ignore
+ }
+ broadcastServerStatusChange(currentServerStatus);
+ console.debug(`${LOG_PREFIX} Server marked stopped (${reason})`);
+}
+
+// ==================== Core Ensure Function ====================
+
+/**
+ * Ensure native connection is established.
+ * This is the main entry point for auto-connect logic.
+ *
+ * @param trigger - Description of what triggered this call (for logging)
+ * @param portOverride - Optional explicit port to use
+ * @returns Whether the connection is now established
+ */
+async function ensureNativeConnected(trigger: string, portOverride?: unknown): Promise {
+ // Concurrency protection: only one ensure flow at a time
+ if (ensurePromise) return ensurePromise;
+
+ ensurePromise = (async () => {
+ // Load auto-connect setting if not yet loaded
+ if (!autoConnectLoaded) {
+ autoConnectEnabled = await loadNativeAutoConnectEnabled();
+ autoConnectLoaded = true;
+ syncKeepaliveHold();
+ }
+
+ // If auto-connect is disabled, do nothing
+ if (!autoConnectEnabled) {
+ console.debug(`${LOG_PREFIX} Auto-connect disabled, skipping ensure (trigger=${trigger})`);
+ return false;
+ }
+
+ // Sync keepalive hold
+ syncKeepaliveHold();
+
+ // Already connected
+ if (nativePort) {
+ console.debug(`${LOG_PREFIX} Already connected (trigger=${trigger})`);
+ return true;
+ }
+
+ // Get the port to use
+ const port = await getPreferredPort(portOverride);
+ console.debug(`${LOG_PREFIX} Attempting connection on port ${port} (trigger=${trigger})`);
+
+ // Attempt connection
+ const ok = connectNativeHost(port);
+ if (!ok) {
+ console.warn(`${LOG_PREFIX} Connection failed (trigger=${trigger})`);
+ scheduleReconnect(`connect_failed:${trigger}`);
+ return false;
+ }
+
+ console.debug(`${LOG_PREFIX} Connection initiated successfully (trigger=${trigger})`);
+ // Note: Don't reset reconnect state here. Wait for SERVER_STARTED confirmation.
+ // Chrome may return a Port but disconnect immediately if native host is missing.
+ return true;
+ })().finally(() => {
+ ensurePromise = null;
+ });
+
+ return ensurePromise;
+}
+
/**
* Connect to the native messaging host
+ * @returns Whether the connection was initiated successfully
*/
-export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT) {
+export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT): boolean {
if (nativePort) {
- return;
+ return true;
}
try {
nativePort = chrome.runtime.connectNative(HOST_NAME);
nativePort.onMessage.addListener(async (message) => {
- // chrome.notifications.create({
- // type: NOTIFICATIONS.TYPE,
- // iconUrl: chrome.runtime.getURL(ICONS.NOTIFICATION),
- // title: 'Message from native host',
- // message: `Received data from host: ${JSON.stringify(message)}`,
- // priority: NOTIFICATIONS.PRIORITY,
- // });
-
if (message.type === NativeMessageType.PROCESS_DATA && message.requestId) {
const requestId = message.requestId;
const requestPayload = message.payload;
@@ -124,6 +378,34 @@ export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT) {
},
});
}
+ } else if (message.type === 'rr_list_published_flows' && message.requestId) {
+ const requestId = message.requestId;
+ try {
+ const published = await listPublished();
+ const items = [] as any[];
+ for (const p of published) {
+ const flow = await getFlow(p.id);
+ if (!flow) continue;
+ items.push({
+ id: p.id,
+ slug: p.slug,
+ version: p.version,
+ name: p.name,
+ description: p.description || flow.description || '',
+ variables: flow.variables || [],
+ meta: flow.meta || {},
+ });
+ }
+ nativePort?.postMessage({
+ responseToRequestId: requestId,
+ payload: { status: 'success', items },
+ });
+ } catch (error: any) {
+ nativePort?.postMessage({
+ responseToRequestId: requestId,
+ payload: { status: 'error', error: error?.message || String(error) },
+ });
+ }
} else if (message.type === NativeMessageType.SERVER_STARTED) {
const port = message.payload?.port;
currentServerStatus = {
@@ -133,6 +415,8 @@ export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT) {
};
await saveServerStatus(currentServerStatus);
broadcastServerStatusChange(currentServerStatus);
+ // Server is confirmed running - now we can reset reconnect state
+ resetReconnectState();
console.log(`${SUCCESS_MESSAGES.SERVER_STARTED} on port ${port}`);
} else if (message.type === NativeMessageType.SERVER_STOPPED) {
currentServerStatus = {
@@ -154,13 +438,29 @@ export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT) {
});
nativePort.onDisconnect.addListener(() => {
- console.error(ERROR_MESSAGES.NATIVE_DISCONNECTED, chrome.runtime.lastError);
+ console.warn(ERROR_MESSAGES.NATIVE_DISCONNECTED, chrome.runtime.lastError);
nativePort = null;
+
+ // Mark server as stopped since native host disconnection means server is down
+ void markServerStopped('native_port_disconnected');
+
+ // Handle reconnection based on disconnect reason
+ if (manualDisconnect) {
+ manualDisconnect = false;
+ return;
+ }
+ if (!autoConnectEnabled) return;
+ scheduleReconnect('native_port_disconnected');
});
nativePort.postMessage({ type: NativeMessageType.START, payload: { port } });
+ // Note: Don't reset reconnect state here. Wait for SERVER_STARTED confirmation.
+ // Chrome may return a Port but disconnect immediately if native host is missing.
+ return true;
} catch (error) {
- console.error(ERROR_MESSAGES.NATIVE_CONNECTION_FAILED, error);
+ console.warn(ERROR_MESSAGES.NATIVE_CONNECTION_FAILED, error);
+ nativePort = null;
+ return false;
}
}
@@ -177,34 +477,108 @@ export const initNativeHostListener = () => {
console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);
});
- chrome.runtime.onStartup.addListener(connectNativeHost);
+ // Auto-connect on SW activation (covers SW restart after idle termination)
+ void ensureNativeConnected('sw_startup').catch(() => {});
+
+ // Auto-connect on Chrome browser startup
+ chrome.runtime.onStartup.addListener(() => {
+ void ensureNativeConnected('onStartup').catch(() => {});
+ });
+
+ // Auto-connect on extension install/update
+ chrome.runtime.onInstalled.addListener(() => {
+ void ensureNativeConnected('onInstalled').catch(() => {});
+ });
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
- if (
- message === NativeMessageType.CONNECT_NATIVE ||
- message.type === NativeMessageType.CONNECT_NATIVE
- ) {
- const port =
- typeof message === 'object' && message.port ? message.port : NATIVE_HOST.DEFAULT_PORT;
- connectNativeHost(port);
- sendResponse({ success: true, port });
+ // Allow UI to call tools directly
+ if (message && message.type === 'call_tool' && message.name) {
+ handleCallTool({ name: message.name, args: message.args })
+ .then((res) => sendResponse({ success: true, result: res }))
+ .catch((err) =>
+ sendResponse({ success: false, error: err instanceof Error ? err.message : String(err) }),
+ );
+ return true;
+ }
+
+ const msgType = typeof message === 'string' ? message : message?.type;
+
+ // ENSURE_NATIVE: Trigger ensure without changing autoConnectEnabled
+ if (msgType === NativeMessageType.ENSURE_NATIVE) {
+ const portOverride = typeof message === 'object' ? message.port : undefined;
+ ensureNativeConnected('ui_ensure', portOverride)
+ .then((connected) => {
+ sendResponse({ success: true, connected, autoConnectEnabled });
+ })
+ .catch((e) => {
+ sendResponse({ success: false, connected: nativePort !== null, error: String(e) });
+ });
+ return true;
+ }
+
+ // CONNECT_NATIVE: Explicit user connect, re-enables auto-connect
+ if (msgType === NativeMessageType.CONNECT_NATIVE) {
+ const portOverride = typeof message === 'object' ? message.port : undefined;
+ const normalized = normalizePort(portOverride);
+
+ (async () => {
+ // Explicit user connect: re-enable auto-connect
+ await setNativeAutoConnectEnabled(true);
+
+ if (normalized) {
+ // Best-effort: persist preferred port
+ try {
+ await chrome.storage.local.set({ [STORAGE_KEYS.NATIVE_SERVER_PORT]: normalized });
+ } catch {
+ // Ignore
+ }
+ }
+
+ return ensureNativeConnected('ui_connect', normalized ?? undefined);
+ })()
+ .then((connected) => {
+ sendResponse({ success: true, connected });
+ })
+ .catch((e) => {
+ sendResponse({ success: false, connected: nativePort !== null, error: String(e) });
+ });
return true;
}
- if (message.type === NativeMessageType.PING_NATIVE) {
+ if (msgType === NativeMessageType.PING_NATIVE) {
const connected = nativePort !== null;
- sendResponse({ connected });
+ sendResponse({ connected, autoConnectEnabled });
return true;
}
- if (message.type === NativeMessageType.DISCONNECT_NATIVE) {
- if (nativePort) {
- nativePort.disconnect();
- nativePort = null;
- sendResponse({ success: true });
- } else {
- sendResponse({ success: false, error: 'No active connection' });
- }
+ // DISCONNECT_NATIVE: Explicit user disconnect, disables auto-connect
+ if (msgType === NativeMessageType.DISCONNECT_NATIVE) {
+ (async () => {
+ // Explicit user disconnect: disable auto-connect and stop reconnect loop
+ await setNativeAutoConnectEnabled(false);
+ clearReconnectTimer();
+ reconnectAttempts = 0;
+ syncKeepaliveHold();
+
+ if (nativePort) {
+ // Only set manualDisconnect if we actually have a port to disconnect.
+ // This prevents the flag from persisting when there's no active connection.
+ manualDisconnect = true;
+ try {
+ nativePort.disconnect();
+ } catch {
+ // Ignore
+ }
+ nativePort = null;
+ }
+ await markServerStopped('manual_disconnect');
+ })()
+ .then(() => {
+ sendResponse({ success: true });
+ })
+ .catch((e) => {
+ sendResponse({ success: false, error: String(e) });
+ });
return true;
}
diff --git a/app/chrome-extension/entrypoints/background/quick-panel/agent-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/agent-handler.ts
new file mode 100644
index 00000000..c6fec7f9
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/quick-panel/agent-handler.ts
@@ -0,0 +1,779 @@
+/**
+ * Quick Panel Agent Handler
+ *
+ * Background service that bridges Quick Panel (content script) with the native-server Agent.
+ * Handles message routing, SSE streaming, and lifecycle management for AI chat requests.
+ *
+ * Architecture:
+ * - Quick Panel sends QUICK_PANEL_SEND_TO_AI via chrome.runtime.sendMessage
+ * - This handler subscribes to SSE first, then fires POST /act
+ * - Incoming RealtimeEvents are filtered by requestId and forwarded to the originating tab
+ * - Keepalive is explicitly managed to prevent MV3 Service Worker suspension during streaming
+ *
+ * @see https://developer.chrome.com/docs/extensions/mv3/service_workers/
+ */
+
+import type { AgentActRequest, RealtimeEvent } from 'chrome-mcp-shared';
+import { NativeMessageType } from 'chrome-mcp-shared';
+
+import { NATIVE_HOST, STORAGE_KEYS } from '@/common/constants';
+import {
+ BACKGROUND_MESSAGE_TYPES,
+ TOOL_MESSAGE_TYPES,
+ type QuickPanelAIEventMessage,
+ type QuickPanelCancelAIMessage,
+ type QuickPanelCancelAIResponse,
+ type QuickPanelSendToAIMessage,
+ type QuickPanelSendToAIResponse,
+} from '@/common/message-types';
+import { acquireKeepalive } from '../keepalive-manager';
+import { openAgentChatSidepanel } from '../utils/sidepanel';
+
+// ============================================================
+// Constants
+// ============================================================
+
+const LOG_PREFIX = '[QuickPanelAgent]';
+const KEEPALIVE_TAG = 'quick-panel-ai';
+
+/** Storage key for AgentChat selected session ID (owned by sidepanel composables) */
+const STORAGE_KEY_SELECTED_SESSION = 'agent-selected-session-id';
+
+/** Timeout for initial SSE connection establishment */
+const SSE_CONNECT_TIMEOUT_MS = 3000;
+
+/** Safety timeout for entire request lifecycle (15 minutes) */
+const REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
+
+/** Flag indicating SSE connection was successful */
+const SSE_CONNECTED = Symbol('SSE_CONNECTED');
+
+/** Flag indicating SSE connection timed out but we should continue */
+const SSE_TIMEOUT = Symbol('SSE_TIMEOUT');
+
+// ============================================================
+// Types
+// ============================================================
+
+/**
+ * Represents an active streaming request from Quick Panel.
+ *
+ * Background maintains this state to:
+ * 1. Route SSE events to the correct tab
+ * 2. Manage keepalive lifecycle
+ * 3. Handle cancellation and cleanup
+ */
+interface ActiveRequest {
+ readonly requestId: string;
+ readonly sessionId: string;
+ readonly instruction: string;
+ readonly tabId: number;
+ readonly windowId?: number;
+ readonly frameId?: number;
+ readonly port: number;
+ readonly createdAt: number;
+ readonly abortController: AbortController;
+ readonly releaseKeepalive: () => void;
+ readonly timeoutId: ReturnType;
+}
+
+// ============================================================
+// State
+// ============================================================
+
+/** Active streaming requests indexed by requestId */
+const activeRequests = new Map();
+
+/** Initialization flag to prevent duplicate listeners */
+let initialized = false;
+
+// ============================================================
+// Utility Functions
+// ============================================================
+
+function normalizeString(value: unknown): string {
+ return typeof value === 'string' ? value : '';
+}
+
+function normalizePort(value: unknown): number | null {
+ const num =
+ typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;
+
+ if (!Number.isFinite(num)) return null;
+
+ const port = Math.floor(num);
+ if (port <= 0 || port > 65535) return null;
+
+ return port;
+}
+
+function createRequestId(): string {
+ // Prefer crypto.randomUUID for proper UUID format
+ try {
+ const id = crypto?.randomUUID?.();
+ if (id) return id;
+ } catch {
+ // Fallback for environments without crypto.randomUUID
+ }
+ return `req_${Date.now()}_${Math.random().toString(16).slice(2)}`;
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function isTerminalStatus(status: string): boolean {
+ return status === 'completed' || status === 'error' || status === 'cancelled';
+}
+
+// ============================================================
+// Event Factories
+// ============================================================
+
+function createErrorEvent(sessionId: string, requestId: string, error: string): RealtimeEvent {
+ return {
+ type: 'error',
+ error: error || 'Unknown error',
+ data: { sessionId, requestId },
+ };
+}
+
+function createCancelledStatusEvent(
+ sessionId: string,
+ requestId: string,
+ message?: string,
+): RealtimeEvent {
+ return {
+ type: 'status',
+ data: {
+ sessionId,
+ status: 'cancelled',
+ requestId,
+ message: message || 'Cancelled by user',
+ },
+ };
+}
+
+// ============================================================
+// Event Forwarding
+// ============================================================
+
+/**
+ * Forward a RealtimeEvent to the Quick Panel in the originating tab.
+ * Handles receiver unavailability gracefully by cleaning up the request.
+ */
+function forwardEventToQuickPanel(request: ActiveRequest, event: RealtimeEvent): void {
+ const message: QuickPanelAIEventMessage = {
+ action: TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT,
+ requestId: request.requestId,
+ sessionId: request.sessionId,
+ event,
+ };
+
+ const sendOptions =
+ typeof request.frameId === 'number' ? { frameId: request.frameId } : undefined;
+
+ const sendPromise = sendOptions
+ ? chrome.tabs.sendMessage(request.tabId, message, sendOptions)
+ : chrome.tabs.sendMessage(request.tabId, message);
+
+ sendPromise.catch((err) => {
+ const msg = err instanceof Error ? err.message : String(err);
+
+ // Detect receiver unavailability (tab closed, navigated, Quick Panel closed)
+ const receiverGone =
+ msg.includes('Receiving end does not exist') ||
+ msg.includes('No tab with id') ||
+ msg.includes('The message port closed');
+
+ if (receiverGone) {
+ cleanupRequest(request.requestId, 'receiver_unavailable');
+ }
+ });
+}
+
+// ============================================================
+// Request Lifecycle Management
+// ============================================================
+
+/**
+ * Clean up an active request and release all associated resources.
+ * Idempotent - safe to call multiple times.
+ */
+function cleanupRequest(requestId: string, reason: string): void {
+ const request = activeRequests.get(requestId);
+ if (!request) return;
+
+ activeRequests.delete(requestId);
+
+ // Clear timeout
+ try {
+ clearTimeout(request.timeoutId);
+ } catch {
+ // Ignore
+ }
+
+ // Abort SSE connection
+ try {
+ request.abortController.abort();
+ } catch {
+ // Ignore
+ }
+
+ // Release keepalive
+ try {
+ request.releaseKeepalive();
+ } catch {
+ // Ignore
+ }
+
+ console.debug(`${LOG_PREFIX} Cleaned up request ${requestId} (${reason})`);
+}
+
+// ============================================================
+// Session Validation
+// ============================================================
+
+/**
+ * Validate that the selected session exists on the native server.
+ * Returns false if the session is invalid or server is unreachable.
+ */
+async function validateSession(port: number, sessionId: string): Promise {
+ const url = `http://127.0.0.1:${port}/agent/sessions/${encodeURIComponent(sessionId)}`;
+ try {
+ const response = await fetch(url);
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
+
+// ============================================================
+// SSE Event Filtering
+// ============================================================
+
+/**
+ * Determine if a RealtimeEvent should be forwarded for a specific requestId.
+ *
+ * Events without requestId (connected, heartbeat) are session-level signals
+ * and are not forwarded to avoid confusion with request-specific events.
+ */
+function shouldForwardEvent(event: RealtimeEvent, requestId: string): boolean {
+ switch (event.type) {
+ case 'message':
+ return event.data?.requestId === requestId;
+ case 'status':
+ return event.data?.requestId === requestId;
+ case 'usage':
+ return event.data?.requestId === requestId;
+ case 'error':
+ return event.data?.requestId === requestId;
+ case 'connected':
+ case 'heartbeat':
+ // Session-level signals, not request-scoped
+ return false;
+ default:
+ return false;
+ }
+}
+
+// ============================================================
+// SSE Subscription
+// ============================================================
+
+interface SseSubscription {
+ /**
+ * Resolves with true when SSE connection is established.
+ * Resolves with false if connection failed (request was cleaned up).
+ */
+ ready: Promise;
+ /** Resolves when SSE stream ends (normally or due to error/abort) */
+ done: Promise;
+}
+
+/**
+ * Create an SSE subscription for the request's session.
+ *
+ * The subscription:
+ * 1. Connects to the session's /stream endpoint
+ * 2. Filters events by requestId
+ * 3. Forwards matching events to Quick Panel
+ * 4. Triggers cleanup on terminal status
+ *
+ * @returns SseSubscription with ready promise that resolves to:
+ * - true: SSE connected successfully
+ * - false: SSE failed (request was cleaned up, don't send /act)
+ */
+function createSseSubscription(request: ActiveRequest): SseSubscription {
+ // Track whether ready has been resolved
+ let readySettled = false;
+ let readyResolve: (connected: boolean) => void;
+
+ const ready = new Promise((resolve) => {
+ readyResolve = resolve;
+ });
+
+ // Helper to resolve ready exactly once
+ const settleReady = (connected: boolean): void => {
+ if (readySettled) return;
+ readySettled = true;
+ readyResolve(connected);
+ };
+
+ const done = (async () => {
+ const sseUrl = `http://127.0.0.1:${request.port}/agent/chat/${encodeURIComponent(request.sessionId)}/stream`;
+
+ try {
+ const response = await fetch(sseUrl, {
+ method: 'GET',
+ headers: { Accept: 'text/event-stream' },
+ signal: request.abortController.signal,
+ });
+
+ if (!response.ok || !response.body) {
+ throw new Error(`SSE stream unavailable (HTTP ${response.status})`);
+ }
+
+ // Signal that SSE is connected successfully
+ settleReady(true);
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ // Read and parse SSE stream
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() ?? '';
+
+ for (const line of lines) {
+ if (!line.startsWith('data:')) continue;
+ const raw = line.slice(5).trim();
+ if (!raw) continue;
+
+ try {
+ const event = JSON.parse(raw) as RealtimeEvent;
+
+ // Filter by requestId to prevent cross-request leakage
+ if (!shouldForwardEvent(event, request.requestId)) {
+ continue;
+ }
+
+ forwardEventToQuickPanel(request, event);
+
+ // Cleanup on terminal status
+ if (event.type === 'status' && event.data?.requestId === request.requestId) {
+ if (isTerminalStatus(event.data.status)) {
+ cleanupRequest(request.requestId, `terminal_status:${event.data.status}`);
+ return;
+ }
+ }
+ } catch {
+ // Ignore parse errors (best-effort stream processing)
+ }
+ }
+ }
+ } catch (err) {
+ // AbortError is intentional (cancellation or cleanup)
+ if (err instanceof Error && err.name === 'AbortError') {
+ // Signal not connected if aborted before connecting
+ settleReady(false);
+ return;
+ }
+
+ // Surface error to UI and cleanup if request is still active
+ if (activeRequests.has(request.requestId)) {
+ const msg = err instanceof Error ? err.message : String(err);
+ forwardEventToQuickPanel(
+ request,
+ createErrorEvent(request.sessionId, request.requestId, msg),
+ );
+ cleanupRequest(request.requestId, 'sse_error');
+ }
+
+ // Signal failed connection
+ settleReady(false);
+ }
+ })();
+
+ return { ready, done };
+}
+
+// ============================================================
+// Agent API
+// ============================================================
+
+/**
+ * Send the act request to native-server.
+ * The server will emit events via SSE which are already being subscribed.
+ *
+ * @param request - Active request context
+ * @throws Error if request was cancelled/aborted or HTTP request fails
+ */
+async function postActRequest(request: ActiveRequest): Promise {
+ // Check if request was cancelled before sending
+ if (request.abortController.signal.aborted) {
+ throw new Error('Request was cancelled');
+ }
+
+ const url = `http://127.0.0.1:${request.port}/agent/chat/${encodeURIComponent(request.sessionId)}/act`;
+
+ const payload: AgentActRequest = {
+ instruction: request.instruction,
+ // Ensures session-level config is loaded (engine, model, options, project binding)
+ dbSessionId: request.sessionId,
+ // Enables SSE-first flow and requestId filtering on session-scoped streams
+ requestId: request.requestId,
+ };
+
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ signal: request.abortController.signal,
+ });
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => '');
+ throw new Error(text || `HTTP ${response.status}`);
+ }
+}
+
+/**
+ * Cancel an active request on the native-server.
+ */
+async function cancelRequestOnServer(
+ port: number,
+ sessionId: string,
+ requestId: string,
+): Promise {
+ const url = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/cancel/${encodeURIComponent(requestId)}`;
+ try {
+ await fetch(url, { method: 'DELETE' });
+ } catch {
+ // Best-effort: cancellation might still succeed if request already ended
+ }
+}
+
+// ============================================================
+// Request Orchestration
+// ============================================================
+
+/**
+ * Check if the request is still active and not cancelled.
+ * Used as a guard before each async operation to handle race conditions.
+ */
+function isRequestStillActive(request: ActiveRequest): boolean {
+ return activeRequests.has(request.requestId) && !request.abortController.signal.aborted;
+}
+
+/**
+ * Main orchestration function for starting a Quick Panel AI request.
+ *
+ * Flow:
+ * 1. Ensure native server is running
+ * 2. Validate session exists
+ * 3. Open sidepanel (best-effort)
+ * 4. Start SSE subscription (wait for connection)
+ * 5. Fire act request
+ * 6. Let SSE handle event forwarding and cleanup
+ *
+ * @remarks
+ * Guards are placed after each async operation to handle cancellation races.
+ */
+async function startRequest(request: ActiveRequest): Promise {
+ try {
+ // Best-effort: ensure native server is running
+ await chrome.runtime.sendMessage({ type: NativeMessageType.ENSURE_NATIVE }).catch(() => null);
+
+ // Guard: check if cancelled during ENSURE_NATIVE
+ if (!isRequestStillActive(request)) return;
+
+ // Validate session still exists
+ const sessionValid = await validateSession(request.port, request.sessionId);
+
+ // Guard: check if cancelled during validation
+ if (!isRequestStillActive(request)) return;
+
+ if (!sessionValid) {
+ forwardEventToQuickPanel(
+ request,
+ createErrorEvent(
+ request.sessionId,
+ request.requestId,
+ 'Selected Agent session is not available. Please open AgentChat and select a valid session.',
+ ),
+ );
+ // Open sidepanel without deep-linking to invalid session
+ openAgentChatSidepanel(request.tabId, request.windowId).catch(() => {});
+ cleanupRequest(request.requestId, 'session_invalid');
+ return;
+ }
+
+ // Best-effort: open sidepanel deep-linked to current session
+ openAgentChatSidepanel(request.tabId, request.windowId, request.sessionId).catch(() => {});
+
+ // Start SSE subscription BEFORE sending act request to avoid missing early events
+ const sse = createSseSubscription(request);
+
+ // Wait for SSE connection with timeout
+ // The race returns either:
+ // - boolean from sse.ready (true=connected, false=failed)
+ // - undefined from timeout (treat as "proceed with caution")
+ const sseResult = await Promise.race([
+ sse.ready,
+ sleep(SSE_CONNECT_TIMEOUT_MS).then(() => SSE_TIMEOUT),
+ ]);
+
+ // Guard: check if cancelled during SSE connection
+ if (!isRequestStillActive(request)) return;
+
+ // If SSE explicitly failed (returned false), don't send /act
+ // The SSE subscription already cleaned up and sent error to UI
+ if (sseResult === false) {
+ console.debug(`${LOG_PREFIX} SSE failed for ${request.requestId}, not sending /act`);
+ return;
+ }
+
+ // If SSE timed out, log warning but continue (degraded experience)
+ if (sseResult === SSE_TIMEOUT) {
+ console.warn(
+ `${LOG_PREFIX} SSE connection timed out for ${request.requestId}, proceeding anyway`,
+ );
+ }
+
+ // Fire the act request
+ await postActRequest(request);
+
+ // SSE subscription continues running and will handle cleanup on terminal status
+ void sse.done;
+ } catch (err) {
+ // Abort errors are expected during cancellation
+ if (err instanceof Error && err.name === 'AbortError') {
+ return;
+ }
+
+ // Request may have been cleaned up already
+ if (!activeRequests.has(request.requestId)) return;
+
+ const msg = err instanceof Error ? err.message : String(err);
+ forwardEventToQuickPanel(request, createErrorEvent(request.sessionId, request.requestId, msg));
+ cleanupRequest(request.requestId, 'start_failed');
+ }
+}
+
+// ============================================================
+// Message Handlers
+// ============================================================
+
+/**
+ * Handle QUICK_PANEL_SEND_TO_AI message.
+ * Creates a new streaming request and starts the orchestration flow.
+ */
+async function handleSendToAI(
+ message: QuickPanelSendToAIMessage,
+ sender: chrome.runtime.MessageSender,
+): Promise {
+ const tabId = sender?.tab?.id;
+ const windowId = sender?.tab?.windowId;
+ const frameId = typeof sender?.frameId === 'number' ? sender.frameId : undefined;
+
+ if (typeof tabId !== 'number') {
+ return { success: false, error: 'Quick Panel request must originate from a tab.' };
+ }
+
+ const instruction = normalizeString(message?.payload?.instruction).trim();
+ if (!instruction) {
+ return { success: false, error: 'instruction is required' };
+ }
+
+ // Read server port and selected session from storage
+ const stored = await chrome.storage.local.get([
+ STORAGE_KEYS.NATIVE_SERVER_PORT,
+ STORAGE_KEY_SELECTED_SESSION,
+ ]);
+
+ const port = normalizePort(stored?.[STORAGE_KEYS.NATIVE_SERVER_PORT]) ?? NATIVE_HOST.DEFAULT_PORT;
+ const sessionId = normalizeString(stored?.[STORAGE_KEY_SELECTED_SESSION]).trim();
+
+ if (!sessionId) {
+ // No session selected: open sidepanel for user to select/create one
+ openAgentChatSidepanel(tabId, windowId).catch(() => {});
+ return {
+ success: false,
+ error:
+ 'No Agent session selected. Please open AgentChat, select or create a session, then try again.',
+ };
+ }
+
+ // Create request state
+ const requestId = createRequestId();
+ const releaseKeepalive = acquireKeepalive(KEEPALIVE_TAG);
+ const abortController = new AbortController();
+
+ // Safety timeout to prevent infinite streaming
+ const timeoutId = setTimeout(() => {
+ const activeRequest = activeRequests.get(requestId);
+ if (!activeRequest) return;
+
+ forwardEventToQuickPanel(
+ activeRequest,
+ createErrorEvent(
+ activeRequest.sessionId,
+ activeRequest.requestId,
+ 'Quick Panel stream timed out. Please continue in AgentChat sidepanel.',
+ ),
+ );
+ cleanupRequest(requestId, 'timeout');
+ }, REQUEST_TIMEOUT_MS);
+
+ const request: ActiveRequest = {
+ requestId,
+ sessionId,
+ instruction,
+ tabId,
+ windowId: typeof windowId === 'number' ? windowId : undefined,
+ frameId,
+ port,
+ createdAt: Date.now(),
+ abortController,
+ releaseKeepalive,
+ timeoutId,
+ };
+
+ activeRequests.set(requestId, request);
+
+ // Start the request asynchronously (don't await)
+ void startRequest(request);
+
+ return { success: true, requestId, sessionId };
+}
+
+/**
+ * Handle QUICK_PANEL_CANCEL_AI message.
+ * Cancels an active request both locally and on the server.
+ */
+async function handleCancelAI(
+ message: QuickPanelCancelAIMessage,
+ sender: chrome.runtime.MessageSender,
+): Promise {
+ const tabId = sender?.tab?.id;
+ const frameId = typeof sender?.frameId === 'number' ? sender.frameId : undefined;
+
+ if (typeof tabId !== 'number') {
+ return { success: false, error: 'Cancel request must originate from a tab.' };
+ }
+
+ const requestId = normalizeString(message?.payload?.requestId).trim();
+ const fallbackSessionId = normalizeString(message?.payload?.sessionId).trim();
+
+ if (!requestId) {
+ return { success: false, error: 'requestId is required' };
+ }
+
+ const activeRequest = activeRequests.get(requestId);
+ const sessionId = activeRequest?.sessionId || fallbackSessionId;
+
+ if (!sessionId) {
+ return {
+ success: false,
+ error: 'Unknown sessionId for this request. Please cancel from AgentChat sidepanel.',
+ };
+ }
+
+ // Abort SSE immediately for responsive UX
+ if (activeRequest) {
+ try {
+ activeRequest.abortController.abort();
+ } catch {
+ // Ignore
+ }
+ }
+
+ // Determine port
+ let port = activeRequest?.port;
+ if (!port) {
+ const stored = await chrome.storage.local.get([STORAGE_KEYS.NATIVE_SERVER_PORT]);
+ port = normalizePort(stored?.[STORAGE_KEYS.NATIVE_SERVER_PORT]) ?? NATIVE_HOST.DEFAULT_PORT;
+ }
+
+ // Cancel on server (async, don't await)
+ void cancelRequestOnServer(port, sessionId, requestId);
+
+ // Send synthetic cancelled status to UI
+ const cancelledEvent = createCancelledStatusEvent(sessionId, requestId);
+ const eventMessage: QuickPanelAIEventMessage = {
+ action: TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT,
+ requestId,
+ sessionId,
+ event: cancelledEvent,
+ };
+
+ const sendOptions = typeof frameId === 'number' ? { frameId } : undefined;
+ const sendPromise = sendOptions
+ ? chrome.tabs.sendMessage(tabId, eventMessage, sendOptions)
+ : chrome.tabs.sendMessage(tabId, eventMessage);
+
+ sendPromise
+ .catch(() => {})
+ .finally(() => {
+ cleanupRequest(requestId, 'cancelled_by_user');
+ });
+
+ return { success: true };
+}
+
+// ============================================================
+// Initialization
+// ============================================================
+
+/**
+ * Initialize the Quick Panel Agent Handler.
+ * Sets up message listeners and tab cleanup handlers.
+ */
+export function initQuickPanelAgentHandler(): void {
+ if (initialized) return;
+ initialized = true;
+
+ // Message listener for Quick Panel messages
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ // Handle QUICK_PANEL_SEND_TO_AI
+ if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_SEND_TO_AI) {
+ handleSendToAI(message as QuickPanelSendToAIMessage, sender)
+ .then(sendResponse)
+ .catch((err) => {
+ const msg = err instanceof Error ? err.message : String(err);
+ sendResponse({ success: false, error: msg || 'Unknown error' });
+ });
+ return true; // Async response
+ }
+
+ // Handle QUICK_PANEL_CANCEL_AI
+ if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CANCEL_AI) {
+ handleCancelAI(message as QuickPanelCancelAIMessage, sender)
+ .then(sendResponse)
+ .catch((err) => {
+ const msg = err instanceof Error ? err.message : String(err);
+ sendResponse({ success: false, error: msg || 'Unknown error' });
+ });
+ return true; // Async response
+ }
+
+ return false;
+ });
+
+ // Clean up requests when their tab is closed
+ chrome.tabs.onRemoved.addListener((tabId) => {
+ for (const [requestId, request] of activeRequests) {
+ if (request.tabId === tabId) {
+ cleanupRequest(requestId, 'tab_removed');
+ }
+ }
+ });
+
+ console.debug(`${LOG_PREFIX} Initialized`);
+}
diff --git a/app/chrome-extension/entrypoints/background/quick-panel/commands.ts b/app/chrome-extension/entrypoints/background/quick-panel/commands.ts
new file mode 100644
index 00000000..a9425e2f
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/quick-panel/commands.ts
@@ -0,0 +1,124 @@
+/**
+ * Quick Panel Commands Handler
+ *
+ * Handles keyboard shortcuts for Quick Panel functionality.
+ * Listens for the 'toggle_quick_panel' command and sends toggle message
+ * to the content script in the active tab.
+ */
+
+// ============================================================
+// Constants
+// ============================================================
+
+const COMMAND_KEY = 'toggle_quick_panel';
+const LOG_PREFIX = '[QuickPanelCommands]';
+
+// ============================================================
+// Helpers
+// ============================================================
+
+/**
+ * Get the ID of the currently active tab
+ */
+async function getActiveTabId(): Promise {
+ try {
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
+ return tab?.id ?? null;
+ } catch (err) {
+ console.warn(`${LOG_PREFIX} Failed to get active tab:`, err);
+ return null;
+ }
+}
+
+/**
+ * Check if a tab can receive content scripts
+ */
+function isValidTabUrl(url?: string): boolean {
+ if (!url) return false;
+
+ // Cannot inject into browser internal pages
+ const invalidPrefixes = [
+ 'chrome://',
+ 'chrome-extension://',
+ 'edge://',
+ 'about:',
+ 'moz-extension://',
+ 'devtools://',
+ 'view-source:',
+ 'data:',
+ // 'file://',
+ ];
+
+ return !invalidPrefixes.some((prefix) => url.startsWith(prefix));
+}
+
+// ============================================================
+// Main Handler
+// ============================================================
+
+/**
+ * Toggle Quick Panel in the active tab
+ */
+async function toggleQuickPanelInActiveTab(): Promise {
+ const tabId = await getActiveTabId();
+ if (tabId === null) {
+ console.warn(`${LOG_PREFIX} No active tab found`);
+ return;
+ }
+
+ // Get tab info to check URL validity
+ try {
+ const tab = await chrome.tabs.get(tabId);
+ if (!isValidTabUrl(tab.url)) {
+ console.warn(`${LOG_PREFIX} Cannot inject into tab URL: ${tab.url}`);
+ return;
+ }
+ } catch (err) {
+ console.warn(`${LOG_PREFIX} Failed to get tab info:`, err);
+ return;
+ }
+
+ // Send toggle message to content script
+ try {
+ const response = await chrome.tabs.sendMessage(tabId, { action: 'toggle_quick_panel' });
+ if (response?.success) {
+ console.log(`${LOG_PREFIX} Quick Panel toggled, visible: ${response.visible}`);
+ } else {
+ console.warn(`${LOG_PREFIX} Toggle failed:`, response?.error);
+ }
+ } catch (err) {
+ // Content script may not be loaded yet; this is expected on some pages
+ console.warn(
+ `${LOG_PREFIX} Failed to send toggle message (content script may not be loaded):`,
+ err,
+ );
+ }
+}
+
+// ============================================================
+// Initialization
+// ============================================================
+
+/**
+ * Initialize Quick Panel keyboard command listener
+ */
+export function initQuickPanelCommands(): void {
+ console.log(`${LOG_PREFIX} initQuickPanelCommands called`);
+ chrome.commands.onCommand.addListener(async (command) => {
+ console.log(`${LOG_PREFIX} onCommand received:`, command);
+ if (command !== COMMAND_KEY) {
+ console.log(`${LOG_PREFIX} Command not matched, expected:`, COMMAND_KEY);
+ return;
+ }
+ console.log(`${LOG_PREFIX} Command matched, calling toggleQuickPanelInActiveTab...`);
+
+ try {
+ await toggleQuickPanelInActiveTab();
+ console.log(`${LOG_PREFIX} toggleQuickPanelInActiveTab completed`);
+ } catch (err) {
+ console.error(`${LOG_PREFIX} Command handler error:`, err);
+ }
+ });
+
+ console.log(`${LOG_PREFIX} Command listener registered for: ${COMMAND_KEY}`);
+}
diff --git a/app/chrome-extension/entrypoints/background/quick-panel/tabs-handler.ts b/app/chrome-extension/entrypoints/background/quick-panel/tabs-handler.ts
new file mode 100644
index 00000000..1a248ca6
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/quick-panel/tabs-handler.ts
@@ -0,0 +1,230 @@
+/**
+ * Quick Panel Tabs Handler
+ *
+ * Background service worker bridge for Quick Panel (content script) to:
+ * - Enumerate tabs for search suggestions
+ * - Activate a selected tab
+ * - Close a tab
+ *
+ * Note: Content scripts cannot access chrome.tabs.* directly.
+ */
+
+import {
+ BACKGROUND_MESSAGE_TYPES,
+ type QuickPanelActivateTabMessage,
+ type QuickPanelActivateTabResponse,
+ type QuickPanelCloseTabMessage,
+ type QuickPanelCloseTabResponse,
+ type QuickPanelTabSummary,
+ type QuickPanelTabsQueryMessage,
+ type QuickPanelTabsQueryResponse,
+} from '@/common/message-types';
+
+// ============================================================
+// Constants
+// ============================================================
+
+const LOG_PREFIX = '[QuickPanelTabs]';
+
+// ============================================================
+// Helpers
+// ============================================================
+
+function isValidTabId(value: unknown): value is number {
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
+}
+
+function isValidWindowId(value: unknown): value is number {
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
+}
+
+function normalizeBoolean(value: unknown): boolean {
+ return value === true;
+}
+
+function getLastAccessed(tab: chrome.tabs.Tab): number | undefined {
+ const anyTab = tab as unknown as { lastAccessed?: unknown };
+ const value = anyTab.lastAccessed;
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
+}
+
+function safeErrorMessage(err: unknown): string {
+ if (err instanceof Error) {
+ return err.message || String(err);
+ }
+ return String(err);
+}
+
+/**
+ * Convert a chrome.tabs.Tab to our summary format.
+ * Returns null if tab is invalid.
+ */
+function toTabSummary(tab: chrome.tabs.Tab): QuickPanelTabSummary | null {
+ if (!isValidTabId(tab.id)) return null;
+
+ const windowId = isValidWindowId(tab.windowId) ? tab.windowId : null;
+ if (windowId === null) return null;
+
+ return {
+ tabId: tab.id,
+ windowId,
+ title: tab.title ?? '',
+ url: tab.url ?? '',
+ favIconUrl: tab.favIconUrl ?? undefined,
+ active: normalizeBoolean(tab.active),
+ pinned: normalizeBoolean(tab.pinned),
+ audible: normalizeBoolean(tab.audible),
+ muted: normalizeBoolean(tab.mutedInfo?.muted),
+ index: typeof tab.index === 'number' && Number.isFinite(tab.index) ? tab.index : 0,
+ lastAccessed: getLastAccessed(tab),
+ };
+}
+
+// ============================================================
+// Message Handlers
+// ============================================================
+
+async function handleTabsQuery(
+ message: QuickPanelTabsQueryMessage,
+ sender: chrome.runtime.MessageSender,
+): Promise {
+ try {
+ const includeAllWindows = message.payload?.includeAllWindows ?? true;
+
+ // Extract current context from sender
+ const currentWindowId = isValidWindowId(sender.tab?.windowId) ? sender.tab!.windowId : null;
+ const currentTabId = isValidTabId(sender.tab?.id) ? sender.tab!.id : null;
+
+ // Quick Panel should only be called from content scripts (which have sender.tab)
+ // Reject requests without valid sender tab context for security
+ if (!includeAllWindows && currentWindowId === null) {
+ return {
+ success: false,
+ error: 'Invalid request: sender tab context required for window-scoped queries',
+ };
+ }
+
+ // Build query info based on scope
+ const queryInfo: chrome.tabs.QueryInfo = includeAllWindows
+ ? {}
+ : { windowId: currentWindowId! };
+
+ const tabs = await chrome.tabs.query(queryInfo);
+
+ // Convert to summaries, filtering out invalid tabs
+ const summaries: QuickPanelTabSummary[] = [];
+ for (const tab of tabs) {
+ const summary = toTabSummary(tab);
+ if (summary) {
+ summaries.push(summary);
+ }
+ }
+
+ return {
+ success: true,
+ tabs: summaries,
+ currentTabId,
+ currentWindowId,
+ };
+ } catch (err) {
+ console.warn(`${LOG_PREFIX} Error querying tabs:`, err);
+ return {
+ success: false,
+ error: safeErrorMessage(err) || 'Failed to query tabs',
+ };
+ }
+}
+
+async function handleActivateTab(
+ message: QuickPanelActivateTabMessage,
+): Promise {
+ try {
+ const tabId = message.payload?.tabId;
+ const windowId = message.payload?.windowId;
+
+ if (!isValidTabId(tabId)) {
+ return { success: false, error: 'Invalid tabId' };
+ }
+
+ // Focus the window first if provided
+ if (isValidWindowId(windowId)) {
+ try {
+ await chrome.windows.update(windowId, { focused: true });
+ } catch {
+ // Best-effort: tab activation may still succeed without focusing window.
+ }
+ }
+
+ // Activate the tab
+ await chrome.tabs.update(tabId, { active: true });
+
+ return { success: true };
+ } catch (err) {
+ console.warn(`${LOG_PREFIX} Error activating tab:`, err);
+ return {
+ success: false,
+ error: safeErrorMessage(err) || 'Failed to activate tab',
+ };
+ }
+}
+
+async function handleCloseTab(
+ message: QuickPanelCloseTabMessage,
+): Promise {
+ try {
+ const tabId = message.payload?.tabId;
+
+ if (!isValidTabId(tabId)) {
+ return { success: false, error: 'Invalid tabId' };
+ }
+
+ await chrome.tabs.remove(tabId);
+
+ return { success: true };
+ } catch (err) {
+ console.warn(`${LOG_PREFIX} Error closing tab:`, err);
+ return {
+ success: false,
+ error: safeErrorMessage(err) || 'Failed to close tab',
+ };
+ }
+}
+
+// ============================================================
+// Initialization
+// ============================================================
+
+let initialized = false;
+
+/**
+ * Initialize the Quick Panel Tabs handler.
+ * Safe to call multiple times - subsequent calls are no-ops.
+ */
+export function initQuickPanelTabsHandler(): void {
+ if (initialized) return;
+ initialized = true;
+
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ // Tabs query
+ if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TABS_QUERY) {
+ handleTabsQuery(message as QuickPanelTabsQueryMessage, sender).then(sendResponse);
+ return true; // Will respond asynchronously
+ }
+
+ // Tab activate
+ if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE) {
+ handleActivateTab(message as QuickPanelActivateTabMessage).then(sendResponse);
+ return true;
+ }
+
+ // Tab close
+ if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE) {
+ handleCloseTab(message as QuickPanelCloseTabMessage).then(sendResponse);
+ return true;
+ }
+
+ return false; // Not handled by this listener
+ });
+
+ console.debug(`${LOG_PREFIX} Initialized`);
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/bootstrap.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/bootstrap.ts
new file mode 100644
index 00000000..75704c46
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/bootstrap.ts
@@ -0,0 +1,469 @@
+/**
+ * @fileoverview Record-Replay V3 composition root (bootstrap)
+ * @description
+ * Wires storage, events, scheduler, triggers and RPC for the MV3 background service worker.
+ *
+ * 设计说明:
+ * - 必须先执行 recoverFromCrash() 再启动 scheduler.start()
+ * - 使用全局单例 keepalive-manager 避免多个控制器冲突
+ * - RunExecutor 使用 RunRunner 执行实际的 Flow
+ */
+
+import type { UnixMillis } from './domain/json';
+import type { RunId } from './domain/ids';
+import { RR_ERROR_CODES, createRRError, type RRError } from './domain/errors';
+
+import type { StoragePort } from './engine/storage/storage-port';
+import { StorageBackedEventsBus, type EventsBus } from './engine/transport/events-bus';
+
+import { DEFAULT_QUEUE_CONFIG, type RunQueueItem } from './engine/queue/queue';
+import { createLeaseManager, generateOwnerId, type LeaseManager } from './engine/queue/leasing';
+import { createRunScheduler, type RunExecutor, type RunScheduler } from './engine/queue/scheduler';
+import { recoverFromCrash } from './engine/recovery/recovery-coordinator';
+
+import { RpcServer } from './engine/transport/rpc-server';
+
+import { createTriggerManager, type TriggerManager } from './engine/triggers/trigger-manager';
+import { createUrlTriggerHandlerFactory } from './engine/triggers/url-trigger';
+import { createCommandTriggerHandlerFactory } from './engine/triggers/command-trigger';
+import { createContextMenuTriggerHandlerFactory } from './engine/triggers/context-menu-trigger';
+import { createDomTriggerHandlerFactory } from './engine/triggers/dom-trigger';
+import { createCronTriggerHandlerFactory } from './engine/triggers/cron-trigger';
+import { createIntervalTriggerHandlerFactory } from './engine/triggers/interval-trigger';
+import { createOnceTriggerHandlerFactory } from './engine/triggers/once-trigger';
+import { createManualTriggerHandlerFactory } from './engine/triggers/manual-trigger';
+
+import { createChromeArtifactService } from './engine/kernel/artifacts';
+import { createRunRunnerFactory, type RunRunnerFactory } from './engine/kernel/runner';
+import {
+ createDebugController,
+ createRunnerRegistry,
+ type DebugController,
+ type RunnerRegistry,
+} from './engine/kernel/debug-controller';
+
+import { PluginRegistry } from './engine/plugins/registry';
+import {
+ registerV2ReplayNodesAsV3Nodes,
+ DEFAULT_V2_EXCLUDE_LIST,
+} from './engine/plugins/register-v2-replay-nodes';
+
+import { acquireKeepalive } from '../keepalive-manager';
+import { createStoragePort } from './index';
+
+// ==================== Types ====================
+
+type Logger = Pick;
+
+/**
+ * V3 运行时句柄
+ */
+export interface V3Runtime {
+ ownerId: string;
+ storage: StoragePort;
+ events: EventsBus;
+ leaseManager: LeaseManager;
+ scheduler: RunScheduler;
+ runners: RunnerRegistry;
+ debugController: DebugController;
+ triggers: TriggerManager;
+ rpcServer: RpcServer;
+ stop(): Promise;
+}
+
+// ==================== Singleton State ====================
+
+let runtime: V3Runtime | null = null;
+let bootstrapPromise: Promise | null = null;
+
+// ==================== Utilities ====================
+
+function errorMessage(err: unknown): string {
+ if (err instanceof Error) return err.message;
+ if (err && typeof err === 'object' && 'message' in err)
+ return String((err as { message: unknown }).message);
+ return String(err);
+}
+
+function isFiniteNumber(v: unknown): v is number {
+ return typeof v === 'number' && Number.isFinite(v);
+}
+
+async function tabExists(tabId: number): Promise {
+ try {
+ await chrome.tabs.get(tabId);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function createEphemeralTab(logger: Logger): Promise {
+ const tab = await chrome.tabs.create({ url: 'about:blank', active: false });
+ if (tab.id === undefined) {
+ throw new Error('chrome.tabs.create returned a tab without id');
+ }
+ logger.debug(`[RR-V3] Allocated ephemeral tab ${tab.id}`);
+ return tab.id;
+}
+
+async function safeRemoveTab(tabId: number, logger: Logger): Promise {
+ try {
+ await chrome.tabs.remove(tabId);
+ } catch (e) {
+ logger.debug(`[RR-V3] Failed to close tab ${tabId}:`, e);
+ }
+}
+
+/**
+ * 解析运行 Run 所需的 Tab ID
+ * 优先级: run.tabId > queue.tabId > trigger.sourceTabId > 创建新 Tab
+ */
+async function resolveRunTab(input: {
+ runTabId?: number;
+ queueTabId?: number;
+ triggerTabId?: number;
+ logger: Logger;
+}): Promise<{ tabId: number; shouldClose: boolean }> {
+ const candidates = [input.runTabId, input.queueTabId, input.triggerTabId].filter(
+ (x): x is number => isFiniteNumber(x),
+ );
+
+ for (const tabId of candidates) {
+ if (await tabExists(tabId)) {
+ return { tabId, shouldClose: false };
+ }
+ }
+
+ const tabId = await createEphemeralTab(input.logger);
+ return { tabId, shouldClose: true };
+}
+
+/**
+ * 将 Run 标记为失败
+ * 注意:会重新读取最新的 RunRecord 以获取正确的 startedAt
+ */
+async function failRun(
+ deps: { storage: StoragePort; events: EventsBus; now: () => UnixMillis; logger: Logger },
+ runId: RunId,
+ error: RRError,
+): Promise {
+ const finishedAt = deps.now();
+
+ // 重新获取最新的 run 记录以获取正确的 startedAt
+ let startedAt = finishedAt;
+ try {
+ const latestRun = await deps.storage.runs.get(runId);
+ if (latestRun?.startedAt !== undefined) {
+ startedAt = latestRun.startedAt;
+ }
+ } catch {
+ // ignore - use finishedAt as startedAt
+ }
+
+ const tookMs = Math.max(0, finishedAt - startedAt);
+
+ try {
+ await deps.storage.runs.patch(runId, {
+ status: 'failed',
+ finishedAt,
+ tookMs,
+ error,
+ });
+ } catch (e) {
+ deps.logger.error(`[RR-V3] Failed to patch run "${runId}" as failed:`, e);
+ return;
+ }
+
+ try {
+ await deps.events.append({ runId, type: 'run.failed', error });
+ } catch (e) {
+ deps.logger.warn(`[RR-V3] Failed to append run.failed for "${runId}":`, e);
+ }
+}
+
+// ==================== Run Executor ====================
+
+/**
+ * 创建默认的 RunExecutor
+ * 使用 RunRunner 执行 Flow
+ */
+function createDefaultRunExecutor(deps: {
+ storage: StoragePort;
+ events: EventsBus;
+ runnerFactory: RunRunnerFactory;
+ runners: RunnerRegistry;
+ now: () => UnixMillis;
+ logger: Logger;
+}): RunExecutor {
+ return async (item: RunQueueItem): Promise => {
+ const runId = item.id;
+
+ // 1. 获取 RunRecord
+ const run = await deps.storage.runs.get(runId);
+ if (!run) {
+ deps.logger.warn(`[RR-V3] RunRecord not found for queue item "${runId}", skipping execution`);
+ return;
+ }
+
+ // 2. 获取 Flow
+ const flow = await deps.storage.flows.get(item.flowId);
+ if (!flow) {
+ await failRun(
+ deps,
+ runId,
+ createRRError(RR_ERROR_CODES.VALIDATION_ERROR, `Flow "${item.flowId}" not found`),
+ );
+ return;
+ }
+
+ // 3. 解析 Tab ID
+ const { tabId, shouldClose } = await resolveRunTab({
+ runTabId: run.tabId,
+ queueTabId: item.tabId,
+ triggerTabId: item.trigger?.sourceTabId,
+ logger: deps.logger,
+ });
+
+ // 4. 同步 attempt 到 RunRecord
+ try {
+ await deps.storage.runs.patch(runId, {
+ attempt: item.attempt,
+ maxAttempts: item.maxAttempts,
+ tabId,
+ });
+ } catch (e) {
+ deps.logger.debug(`[RR-V3] Failed to patch run "${runId}" attempt/tabId:`, e);
+ }
+
+ // 5. 执行 Run
+ let runner;
+ try {
+ runner = deps.runnerFactory.create(runId, {
+ flow,
+ tabId,
+ args: item.args,
+ startNodeId: run.startNodeId,
+ debug: item.debug,
+ });
+
+ // 注册到 RunnerRegistry,供 DebugController 和 RPC 使用
+ deps.runners.register(runId, runner);
+
+ await runner.start();
+ } catch (e) {
+ await failRun(
+ deps,
+ runId,
+ createRRError(RR_ERROR_CODES.INTERNAL, `Executor crashed: ${errorMessage(e)}`),
+ );
+ } finally {
+ // 6. 注销 Runner
+ if (runner) {
+ deps.runners.unregister(runId);
+ }
+
+ // 7. 清理临时 Tab
+ if (shouldClose) {
+ await safeRemoveTab(tabId, deps.logger);
+ }
+ }
+ };
+}
+
+// ==================== Bootstrap ====================
+
+/**
+ * 启动 RR-V3 运行时
+ * @returns 运行时句柄
+ */
+export async function bootstrapV3(): Promise {
+ if (runtime) return runtime;
+ if (bootstrapPromise) return bootstrapPromise;
+
+ bootstrapPromise = (async () => {
+ const logger: Logger = console;
+ const now = (): UnixMillis => Date.now();
+
+ logger.info('[RR-V3] Bootstrapping...');
+
+ // 1) Storage
+ const storage = createStoragePort();
+
+ // 2) EventsBus
+ const events: EventsBus = new StorageBackedEventsBus(storage.events);
+
+ // 3) Lease owner identity (per SW instance)
+ const ownerId = generateOwnerId();
+ logger.debug(`[RR-V3] Owner ID: ${ownerId}`);
+
+ // 4) LeaseManager
+ const leaseManager = createLeaseManager(storage.queue, DEFAULT_QUEUE_CONFIG);
+
+ // 5) RunnerRegistry + DebugController
+ const runners = createRunnerRegistry();
+ const debugController = createDebugController({ storage, events, runners });
+
+ // 6) Keepalive (reuse global singleton to avoid multiple controllers fighting)
+ const keepalive = {
+ acquire: (tag: string) => acquireKeepalive(`rr_v3:${tag}`),
+ };
+
+ // 7) PluginRegistry - register V2 action handlers as V3 nodes
+ const plugins = new PluginRegistry();
+ const registeredNodes = registerV2ReplayNodesAsV3Nodes(plugins, {
+ // Exclude control directives that V3 runner doesn't support
+ exclude: [...DEFAULT_V2_EXCLUDE_LIST],
+ });
+ logger.debug(`[RR-V3] Registered ${registeredNodes.length} V2 action handlers as V3 nodes`);
+
+ // 8) RunExecutor via RunRunnerFactory
+ const runnerFactory = createRunRunnerFactory({
+ storage,
+ events,
+ plugins,
+ artifactService: createChromeArtifactService(),
+ now,
+ });
+
+ const execute = createDefaultRunExecutor({
+ storage,
+ events,
+ runnerFactory,
+ runners,
+ now,
+ logger,
+ });
+
+ // 7) Scheduler
+ const scheduler = createRunScheduler({
+ queue: storage.queue,
+ leaseManager,
+ keepalive,
+ config: DEFAULT_QUEUE_CONFIG,
+ ownerId,
+ execute,
+ now,
+ logger,
+ });
+
+ // 8) TriggerManager
+ const triggers = createTriggerManager({
+ storage,
+ events,
+ scheduler,
+ handlerFactories: {
+ url: createUrlTriggerHandlerFactory({ logger }),
+ command: createCommandTriggerHandlerFactory({ logger }),
+ contextMenu: createContextMenuTriggerHandlerFactory({ logger }),
+ dom: createDomTriggerHandlerFactory({ logger }),
+ cron: createCronTriggerHandlerFactory({ logger, now }),
+ interval: createIntervalTriggerHandlerFactory({ logger }),
+ once: createOnceTriggerHandlerFactory({ logger }),
+ manual: createManualTriggerHandlerFactory({ logger }),
+ },
+ now,
+ logger,
+ });
+
+ // 10) RpcServer (created but started after recovery)
+ const rpcServer = new RpcServer({
+ storage,
+ events,
+ scheduler,
+ debugController,
+ runners,
+ triggerManager: triggers,
+ now,
+ });
+
+ // Cleanup helper for error recovery
+ const cleanup = async (): Promise => {
+ try {
+ rpcServer.stop();
+ } catch {
+ /* ignore */
+ }
+ try {
+ await triggers.stop();
+ } catch {
+ /* ignore */
+ }
+ try {
+ scheduler.stop();
+ } catch {
+ /* ignore */
+ }
+ try {
+ leaseManager.dispose();
+ } catch {
+ /* ignore */
+ }
+ try {
+ debugController.stop();
+ } catch {
+ /* ignore */
+ }
+ };
+
+ try {
+ // 10) Recovery - MUST run before scheduler.start()
+ logger.info('[RR-V3] Running crash recovery...');
+ await recoverFromCrash({ storage, events, ownerId, now, logger });
+
+ // 11) Start components
+ scheduler.start();
+ await triggers.start();
+ rpcServer.start();
+
+ logger.info('[RR-V3] Bootstrap complete');
+ } catch (e) {
+ await cleanup();
+ throw e;
+ }
+
+ // Build runtime handle
+ runtime = {
+ ownerId,
+ storage,
+ events,
+ leaseManager,
+ scheduler,
+ runners,
+ debugController,
+ triggers,
+ rpcServer,
+ stop: async () => {
+ logger.info('[RR-V3] Stopping...');
+ // Stop order: RPC first (block new requests) -> triggers -> scheduler -> lease -> debug
+ rpcServer.stop();
+ await triggers.stop().catch(() => {});
+ scheduler.stop();
+ leaseManager.dispose();
+ debugController.stop();
+ runtime = null;
+ logger.info('[RR-V3] Stopped');
+ },
+ };
+
+ return runtime;
+ })().finally(() => {
+ bootstrapPromise = null;
+ });
+
+ return bootstrapPromise;
+}
+
+/**
+ * 获取当前运行时(如果已启动)
+ */
+export function getV3Runtime(): V3Runtime | null {
+ return runtime;
+}
+
+/**
+ * 检查 V3 是否已启动
+ */
+export function isV3Running(): boolean {
+ return runtime !== null;
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/debug.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/debug.ts
new file mode 100644
index 00000000..ed540643
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/debug.ts
@@ -0,0 +1,88 @@
+/**
+ * @fileoverview 调试器类型定义
+ * @description 定义 Record-Replay V3 中的调试器状态和协议
+ */
+
+import type { JsonValue } from './json';
+import type { NodeId, RunId } from './ids';
+import type { PauseReason } from './events';
+
+/**
+ * 断点定义
+ */
+export interface Breakpoint {
+ /** 断点所在节点 ID */
+ nodeId: NodeId;
+ /** 是否启用 */
+ enabled: boolean;
+}
+
+/**
+ * 调试器状态
+ * @description 描述调试器当前的连接和执行状态
+ */
+export interface DebuggerState {
+ /** 关联的 Run ID */
+ runId: RunId;
+ /** 调试器连接状态 */
+ status: 'attached' | 'detached';
+ /** 执行状态 */
+ execution: 'running' | 'paused';
+ /** 暂停原因(仅当 execution='paused' 时有效) */
+ pauseReason?: PauseReason;
+ /** 当前节点 ID */
+ currentNodeId?: NodeId;
+ /** 断点列表 */
+ breakpoints: Breakpoint[];
+ /** 单步模式 */
+ stepMode?: 'none' | 'stepOver';
+}
+
+/**
+ * 调试器命令
+ * @description 客户端发送给调试器的命令
+ */
+export type DebuggerCommand =
+ // ===== 连接控制 =====
+ | { type: 'debug.attach'; runId: RunId }
+ | { type: 'debug.detach'; runId: RunId }
+
+ // ===== 执行控制 =====
+ | { type: 'debug.pause'; runId: RunId }
+ | { type: 'debug.resume'; runId: RunId }
+ | { type: 'debug.stepOver'; runId: RunId }
+
+ // ===== 断点管理 =====
+ | { type: 'debug.setBreakpoints'; runId: RunId; nodeIds: NodeId[] }
+ | { type: 'debug.addBreakpoint'; runId: RunId; nodeId: NodeId }
+ | { type: 'debug.removeBreakpoint'; runId: RunId; nodeId: NodeId }
+
+ // ===== 状态查询 =====
+ | { type: 'debug.getState'; runId: RunId }
+
+ // ===== 变量操作 =====
+ | { type: 'debug.getVar'; runId: RunId; name: string }
+ | { type: 'debug.setVar'; runId: RunId; name: string; value: JsonValue };
+
+/** 调试器命令类型(从联合类型提取) */
+export type DebuggerCommandType = DebuggerCommand['type'];
+
+/**
+ * 调试器命令响应
+ */
+export type DebuggerResponse =
+ | { ok: true; state?: DebuggerState; value?: JsonValue }
+ | { ok: false; error: string };
+
+/**
+ * 创建初始调试器状态
+ */
+export function createInitialDebuggerState(runId: RunId): DebuggerState {
+ return {
+ runId,
+ status: 'detached',
+ execution: 'running',
+ breakpoints: [],
+ stepMode: 'none',
+ };
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/errors.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/errors.ts
new file mode 100644
index 00000000..d0f6cc5c
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/errors.ts
@@ -0,0 +1,92 @@
+/**
+ * @fileoverview 错误类型定义
+ * @description 定义 Record-Replay V3 中使用的错误码和错误类型
+ */
+
+import type { JsonValue } from './json';
+
+/** 错误码常量 */
+export const RR_ERROR_CODES = {
+ // ===== 验证错误 =====
+ /** 通用验证错误 */
+ VALIDATION_ERROR: 'VALIDATION_ERROR',
+ /** 不支持的节点类型 */
+ UNSUPPORTED_NODE: 'UNSUPPORTED_NODE',
+ /** DAG 结构无效 */
+ DAG_INVALID: 'DAG_INVALID',
+ /** DAG 存在循环 */
+ DAG_CYCLE: 'DAG_CYCLE',
+
+ // ===== 运行时错误 =====
+ /** 操作超时 */
+ TIMEOUT: 'TIMEOUT',
+ /** Tab 未找到 */
+ TAB_NOT_FOUND: 'TAB_NOT_FOUND',
+ /** Frame 未找到 */
+ FRAME_NOT_FOUND: 'FRAME_NOT_FOUND',
+ /** 目标元素未找到 */
+ TARGET_NOT_FOUND: 'TARGET_NOT_FOUND',
+ /** 元素不可见 */
+ ELEMENT_NOT_VISIBLE: 'ELEMENT_NOT_VISIBLE',
+ /** 导航失败 */
+ NAVIGATION_FAILED: 'NAVIGATION_FAILED',
+ /** 网络请求失败 */
+ NETWORK_REQUEST_FAILED: 'NETWORK_REQUEST_FAILED',
+
+ // ===== 脚本/工具错误 =====
+ /** 脚本执行失败 */
+ SCRIPT_FAILED: 'SCRIPT_FAILED',
+ /** 权限被拒绝 */
+ PERMISSION_DENIED: 'PERMISSION_DENIED',
+ /** 工具执行错误 */
+ TOOL_ERROR: 'TOOL_ERROR',
+
+ // ===== 控制错误 =====
+ /** Run 被取消 */
+ RUN_CANCELED: 'RUN_CANCELED',
+ /** Run 被暂停 */
+ RUN_PAUSED: 'RUN_PAUSED',
+
+ // ===== 内部错误 =====
+ /** 内部错误 */
+ INTERNAL: 'INTERNAL',
+ /** 不变量违规 */
+ INVARIANT_VIOLATION: 'INVARIANT_VIOLATION',
+} as const;
+
+/** 错误码类型 */
+export type RRErrorCode = (typeof RR_ERROR_CODES)[keyof typeof RR_ERROR_CODES];
+
+/**
+ * Record-Replay 错误接口
+ * @description 统一的错误表示,支持错误链和可重试标记
+ */
+export interface RRError {
+ /** 错误码 */
+ code: RRErrorCode;
+ /** 错误消息 */
+ message: string;
+ /** 附加数据 */
+ data?: JsonValue;
+ /** 是否可重试 */
+ retryable?: boolean;
+ /** 原因错误(错误链) */
+ cause?: RRError;
+}
+
+/**
+ * 创建 RRError 的工厂函数
+ */
+export function createRRError(
+ code: RRErrorCode,
+ message: string,
+ options?: { data?: JsonValue; retryable?: boolean; cause?: RRError },
+): RRError {
+ return {
+ code,
+ message,
+ ...(options?.data !== undefined && { data: options.data }),
+ ...(options?.retryable !== undefined && { retryable: options.retryable }),
+ ...(options?.cause !== undefined && { cause: options.cause }),
+ };
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/events.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/events.ts
new file mode 100644
index 00000000..8f87137c
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/events.ts
@@ -0,0 +1,185 @@
+/**
+ * @fileoverview 事件类型定义
+ * @description 定义 Record-Replay V3 中的运行事件和状态
+ */
+
+import type { JsonObject, JsonValue, UnixMillis } from './json';
+import type { EdgeLabel, FlowId, NodeId, RunId } from './ids';
+import type { RRError } from './errors';
+import type { TriggerFireContext } from './triggers';
+
+/** 取消订阅函数类型 */
+export type Unsubscribe = () => void;
+
+/** Run 状态 */
+export type RunStatus = 'queued' | 'running' | 'paused' | 'succeeded' | 'failed' | 'canceled';
+
+/**
+ * 事件基础接口
+ * @description 所有事件的公共字段
+ */
+export interface EventBase {
+ /** 所属 Run ID */
+ runId: RunId;
+ /** 事件时间戳 */
+ ts: UnixMillis;
+ /** 单调递增序列号 */
+ seq: number;
+}
+
+/**
+ * 暂停原因
+ * @description 描述 Run 暂停的原因
+ */
+export type PauseReason =
+ | { kind: 'breakpoint'; nodeId: NodeId }
+ | { kind: 'step'; nodeId: NodeId }
+ | { kind: 'command' }
+ | { kind: 'policy'; nodeId: NodeId; reason: string };
+
+/** 恢复原因 */
+export type RecoveryReason = 'sw_restart' | 'lease_expired';
+
+/**
+ * Run 事件联合类型
+ * @description 所有可能的运行时事件
+ */
+export type RunEvent =
+ // ===== Run 生命周期事件 =====
+ | (EventBase & { type: 'run.queued'; flowId: FlowId })
+ | (EventBase & { type: 'run.started'; flowId: FlowId; tabId: number })
+ | (EventBase & { type: 'run.paused'; reason: PauseReason; nodeId?: NodeId })
+ | (EventBase & { type: 'run.resumed' })
+ | (EventBase & {
+ type: 'run.recovered';
+ /** 恢复原因 */
+ reason: RecoveryReason;
+ /** 恢复前状态 */
+ fromStatus: 'running' | 'paused';
+ /** 恢复后状态 */
+ toStatus: 'queued';
+ /** 原 ownerId(用于审计) */
+ prevOwnerId?: string;
+ })
+ | (EventBase & { type: 'run.canceled'; reason?: string })
+ | (EventBase & { type: 'run.succeeded'; tookMs: number; outputs?: JsonObject })
+ | (EventBase & { type: 'run.failed'; error: RRError; nodeId?: NodeId })
+
+ // ===== Node 执行事件 =====
+ | (EventBase & { type: 'node.queued'; nodeId: NodeId })
+ | (EventBase & { type: 'node.started'; nodeId: NodeId; attempt: number })
+ | (EventBase & {
+ type: 'node.succeeded';
+ nodeId: NodeId;
+ tookMs: number;
+ next?: { kind: 'edgeLabel'; label: EdgeLabel } | { kind: 'end' };
+ })
+ | (EventBase & {
+ type: 'node.failed';
+ nodeId: NodeId;
+ attempt: number;
+ error: RRError;
+ decision: 'retry' | 'continue' | 'stop' | 'goto';
+ })
+ | (EventBase & { type: 'node.skipped'; nodeId: NodeId; reason: 'disabled' | 'unreachable' })
+
+ // ===== 变量和日志事件 =====
+ | (EventBase & {
+ type: 'vars.patch';
+ patch: Array<{ op: 'set' | 'delete'; name: string; value?: JsonValue }>;
+ })
+ | (EventBase & { type: 'artifact.screenshot'; nodeId: NodeId; data: string; savedAs?: string })
+ | (EventBase & {
+ type: 'log';
+ level: 'debug' | 'info' | 'warn' | 'error';
+ message: string;
+ data?: JsonValue;
+ });
+
+/** Run 事件类型(从联合类型提取) */
+export type RunEventType = RunEvent['type'];
+
+/**
+ * 分布式 Omit(保留联合类型)
+ */
+type DistributiveOmit = T extends unknown ? Omit : never;
+
+/**
+ * Run 事件输入类型
+ * @description seq 必须由 storage 层原子分配(通过 RunRecordV3.nextSeq)
+ * ts 可选,默认为 Date.now()
+ */
+export type RunEventInput = DistributiveOmit & {
+ ts?: UnixMillis;
+};
+
+/** Run Schema 版本 */
+export const RUN_SCHEMA_VERSION = 3 as const;
+
+/**
+ * Run 记录 V3
+ * @description 存储在 IndexedDB 中的 Run 摘要记录
+ */
+export interface RunRecordV3 {
+ /** Schema 版本 */
+ schemaVersion: typeof RUN_SCHEMA_VERSION;
+ /** Run 唯一标识符 */
+ id: RunId;
+ /** 关联的 Flow ID */
+ flowId: FlowId;
+
+ /** 当前状态 */
+ status: RunStatus;
+ /** 创建时间 */
+ createdAt: UnixMillis;
+ /** 最后更新时间 */
+ updatedAt: UnixMillis;
+
+ /** 开始执行时间 */
+ startedAt?: UnixMillis;
+ /** 结束时间 */
+ finishedAt?: UnixMillis;
+ /** 总耗时(毫秒) */
+ tookMs?: number;
+
+ /** 绑定的 Tab ID(每 Run 独占) */
+ tabId?: number;
+ /** 起始节点 ID(如果不是默认入口) */
+ startNodeId?: NodeId;
+ /** 当前执行节点 ID */
+ currentNodeId?: NodeId;
+
+ /** 当前尝试次数 */
+ attempt: number;
+ /** 最大尝试次数 */
+ maxAttempts: number;
+
+ /** 运行参数 */
+ args?: JsonObject;
+ /** 触发器上下文 */
+ trigger?: TriggerFireContext;
+ /** 调试配置 */
+ debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean };
+
+ /** 错误信息(如果失败) */
+ error?: RRError;
+ /** 输出结果 */
+ outputs?: JsonObject;
+
+ /** 下一个事件序列号(缓存字段) */
+ nextSeq: number;
+}
+
+/**
+ * 判断 Run 是否已终止
+ */
+export function isTerminalStatus(status: RunStatus): boolean {
+ return status === 'succeeded' || status === 'failed' || status === 'canceled';
+}
+
+/**
+ * 判断 Run 是否正在执行
+ */
+export function isActiveStatus(status: RunStatus): boolean {
+ return status === 'running' || status === 'paused';
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/flow.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/flow.ts
new file mode 100644
index 00000000..9a236c85
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/flow.ts
@@ -0,0 +1,119 @@
+/**
+ * @fileoverview Flow 类型定义
+ * @description 定义 Record-Replay V3 中的 Flow IR(中间表示)
+ */
+
+import type { ISODateTimeString, JsonObject } from './json';
+import type { EdgeId, EdgeLabel, FlowId, NodeId } from './ids';
+import type { FlowPolicy, NodePolicy } from './policy';
+import type { VariableDefinition } from './variables';
+
+/** Flow Schema 版本 */
+export const FLOW_SCHEMA_VERSION = 3 as const;
+
+/**
+ * Edge V3
+ * @description DAG 中的边,连接两个节点
+ */
+export interface EdgeV3 {
+ /** Edge 唯一标识符 */
+ id: EdgeId;
+ /** 源节点 ID */
+ from: NodeId;
+ /** 目标节点 ID */
+ to: NodeId;
+ /** 边标签(用于条件分支和错误处理) */
+ label?: EdgeLabel;
+}
+
+/** 节点类型(可扩展) */
+export type NodeKind = string;
+
+/**
+ * Node V3
+ * @description DAG 中的节点,代表一个可执行的操作
+ */
+export interface NodeV3 {
+ /** Node 唯一标识符 */
+ id: NodeId;
+ /** 节点类型 */
+ kind: NodeKind;
+ /** 节点名称(用于显示) */
+ name?: string;
+ /** 是否禁用 */
+ disabled?: boolean;
+ /** 节点级策略 */
+ policy?: NodePolicy;
+ /** 节点配置(类型由 kind 决定) */
+ config: JsonObject;
+ /** UI 布局信息 */
+ ui?: { x: number; y: number };
+}
+
+/**
+ * Flow 元数据绑定
+ * @description 定义 Flow 与特定域名/路径/URL 的关联
+ */
+export interface FlowBinding {
+ kind: 'domain' | 'path' | 'url';
+ value: string;
+}
+
+/**
+ * Flow V3
+ * @description 完整的 Flow 定义,包含节点、边和配置
+ */
+export interface FlowV3 {
+ /** Schema 版本 */
+ schemaVersion: typeof FLOW_SCHEMA_VERSION;
+ /** Flow 唯一标识符 */
+ id: FlowId;
+ /** Flow 名称 */
+ name: string;
+ /** Flow 描述 */
+ description?: string;
+ /** 创建时间 */
+ createdAt: ISODateTimeString;
+ /** 更新时间 */
+ updatedAt: ISODateTimeString;
+
+ /** 入口节点 ID(显式指定,不依赖入度推断) */
+ entryNodeId: NodeId;
+ /** 节点列表 */
+ nodes: NodeV3[];
+ /** 边列表 */
+ edges: EdgeV3[];
+
+ /** 变量定义 */
+ variables?: VariableDefinition[];
+ /** Flow 级策略 */
+ policy?: FlowPolicy;
+ /** 元数据 */
+ meta?: {
+ /** 标签 */
+ tags?: string[];
+ /** 绑定规则 */
+ bindings?: FlowBinding[];
+ };
+}
+
+/**
+ * 根据 ID 查找节点
+ */
+export function findNodeById(flow: FlowV3, nodeId: NodeId): NodeV3 | undefined {
+ return flow.nodes.find((n) => n.id === nodeId);
+}
+
+/**
+ * 查找从指定节点出发的所有边
+ */
+export function findEdgesFrom(flow: FlowV3, nodeId: NodeId): EdgeV3[] {
+ return flow.edges.filter((e) => e.from === nodeId);
+}
+
+/**
+ * 查找指向指定节点的所有边
+ */
+export function findEdgesTo(flow: FlowV3, nodeId: NodeId): EdgeV3[] {
+ return flow.edges.filter((e) => e.to === nodeId);
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/ids.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/ids.ts
new file mode 100644
index 00000000..b3f98dca
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/ids.ts
@@ -0,0 +1,37 @@
+/**
+ * @fileoverview ID 类型定义
+ * @description 定义 Record-Replay V3 中使用的各种 ID 类型
+ */
+
+/** Flow 唯一标识符 */
+export type FlowId = string;
+
+/** Node 唯一标识符 */
+export type NodeId = string;
+
+/** Edge 唯一标识符 */
+export type EdgeId = string;
+
+/** Run 唯一标识符 */
+export type RunId = string;
+
+/** Trigger 唯一标识符 */
+export type TriggerId = string;
+
+/** Edge 标签类型 */
+export type EdgeLabel = string;
+
+/** 预定义的 Edge 标签常量 */
+export const EDGE_LABELS = {
+ /** 默认边 */
+ DEFAULT: 'default',
+ /** 错误处理边 */
+ ON_ERROR: 'onError',
+ /** 条件为真时的边 */
+ TRUE: 'true',
+ /** 条件为假时的边 */
+ FALSE: 'false',
+} as const;
+
+/** Edge 标签类型(从常量推导) */
+export type EdgeLabelValue = (typeof EDGE_LABELS)[keyof typeof EDGE_LABELS];
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/index.ts
new file mode 100644
index 00000000..3010d101
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/index.ts
@@ -0,0 +1,31 @@
+/**
+ * @fileoverview Domain 层导出入口
+ * @description 导出所有 Domain 类型定义
+ */
+
+// JSON 基础类型
+export * from './json';
+
+// ID 类型
+export * from './ids';
+
+// 错误类型
+export * from './errors';
+
+// 策略类型
+export * from './policy';
+
+// 变量类型
+export * from './variables';
+
+// Flow 类型
+export * from './flow';
+
+// 事件类型
+export * from './events';
+
+// 调试器类型
+export * from './debug';
+
+// 触发器类型
+export * from './triggers';
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/json.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/json.ts
new file mode 100644
index 00000000..d723583d
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/json.ts
@@ -0,0 +1,24 @@
+/**
+ * @fileoverview JSON 基础类型定义
+ * @description 定义 Record-Replay V3 中使用的 JSON 相关类型
+ */
+
+/** JSON 原始类型 */
+export type JsonPrimitive = string | number | boolean | null;
+
+/** JSON 对象类型 */
+export interface JsonObject {
+ [key: string]: JsonValue;
+}
+
+/** JSON 数组类型 */
+export type JsonArray = JsonValue[];
+
+/** 任意 JSON 值类型 */
+export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
+
+/** ISO 8601 日期时间字符串 */
+export type ISODateTimeString = string;
+
+/** Unix 毫秒时间戳 */
+export type UnixMillis = number;
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/policy.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/policy.ts
new file mode 100644
index 00000000..e4f90c0c
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/policy.ts
@@ -0,0 +1,115 @@
+/**
+ * @fileoverview 策略类型定义
+ * @description 定义 Record-Replay V3 中使用的超时、重试、错误处理和工件策略
+ */
+
+import type { EdgeLabel, NodeId } from './ids';
+import type { RRErrorCode } from './errors';
+import type { UnixMillis } from './json';
+
+/**
+ * 超时策略
+ * @description 定义操作的超时时间和作用范围
+ */
+export interface TimeoutPolicy {
+ /** 超时时间(毫秒) */
+ ms: UnixMillis;
+ /** 超时范围:attempt=每次尝试, node=整个节点执行 */
+ scope?: 'attempt' | 'node';
+}
+
+/**
+ * 重试策略
+ * @description 定义失败后的重试行为
+ */
+export interface RetryPolicy {
+ /** 最大重试次数 */
+ retries: number;
+ /** 重试间隔(毫秒) */
+ intervalMs: UnixMillis;
+ /** 退避策略:none=固定间隔, exp=指数退避, linear=线性增长 */
+ backoff?: 'none' | 'exp' | 'linear';
+ /** 最大重试间隔(毫秒) */
+ maxIntervalMs?: UnixMillis;
+ /** 抖动策略:none=无抖动, full=完全随机 */
+ jitter?: 'none' | 'full';
+ /** 仅在这些错误码时重试 */
+ retryOn?: ReadonlyArray;
+}
+
+/**
+ * 错误处理策略
+ * @description 定义节点执行失败后的处理方式
+ */
+export type OnErrorPolicy =
+ | { kind: 'stop' }
+ | { kind: 'continue'; as?: 'warning' | 'error' }
+ | {
+ kind: 'goto';
+ target: { kind: 'edgeLabel'; label: EdgeLabel } | { kind: 'node'; nodeId: NodeId };
+ }
+ | { kind: 'retry'; override?: Partial };
+
+/**
+ * 工件策略
+ * @description 定义截图和日志收集的行为
+ */
+export interface ArtifactPolicy {
+ /** 截图策略:never=从不, onFailure=失败时, always=总是 */
+ screenshot?: 'never' | 'onFailure' | 'always';
+ /** 截图保存路径模板 */
+ saveScreenshotAs?: string;
+ /** 是否包含控制台日志 */
+ includeConsole?: boolean;
+ /** 是否包含网络请求 */
+ includeNetwork?: boolean;
+}
+
+/**
+ * 节点级策略
+ * @description 单个节点的执行策略配置
+ */
+export interface NodePolicy {
+ /** 超时策略 */
+ timeout?: TimeoutPolicy;
+ /** 重试策略 */
+ retry?: RetryPolicy;
+ /** 错误处理策略 */
+ onError?: OnErrorPolicy;
+ /** 工件策略 */
+ artifacts?: ArtifactPolicy;
+}
+
+/**
+ * Flow 级策略
+ * @description 整个 Flow 的执行策略配置
+ */
+export interface FlowPolicy {
+ /** 默认节点策略 */
+ defaultNodePolicy?: NodePolicy;
+ /** 不支持节点的处理策略 */
+ unsupportedNodePolicy?: OnErrorPolicy;
+ /** Run 总超时时间(毫秒) */
+ runTimeoutMs?: UnixMillis;
+}
+
+/**
+ * 合并节点策略
+ * @description 将 Flow 级默认策略与节点级策略合并
+ */
+export function mergeNodePolicy(
+ flowDefault: NodePolicy | undefined,
+ nodePolicy: NodePolicy | undefined,
+): NodePolicy {
+ if (!flowDefault) return nodePolicy ?? {};
+ if (!nodePolicy) return flowDefault;
+
+ return {
+ timeout: nodePolicy.timeout ?? flowDefault.timeout,
+ retry: nodePolicy.retry ?? flowDefault.retry,
+ onError: nodePolicy.onError ?? flowDefault.onError,
+ artifacts: nodePolicy.artifacts
+ ? { ...flowDefault.artifacts, ...nodePolicy.artifacts }
+ : flowDefault.artifacts,
+ };
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/triggers.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/triggers.ts
new file mode 100644
index 00000000..37f2c1b2
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/triggers.ts
@@ -0,0 +1,143 @@
+/**
+ * @fileoverview 触发器类型定义
+ * @description 定义 Record-Replay V3 中的触发器规范
+ */
+
+import type { JsonObject, UnixMillis } from './json';
+import type { FlowId, TriggerId } from './ids';
+
+/** 触发器类型 */
+export type TriggerKind =
+ | 'manual'
+ | 'url'
+ | 'cron'
+ | 'interval'
+ | 'once'
+ | 'command'
+ | 'contextMenu'
+ | 'dom';
+
+/**
+ * 触发器基础接口
+ */
+export interface TriggerSpecBase {
+ /** 触发器 ID */
+ id: TriggerId;
+ /** 触发器类型 */
+ kind: TriggerKind;
+ /** 是否启用 */
+ enabled: boolean;
+ /** 关联的 Flow ID */
+ flowId: FlowId;
+ /** 传递给 Flow 的参数 */
+ args?: JsonObject;
+}
+
+/**
+ * URL 匹配规则
+ */
+export interface UrlMatchRule {
+ kind: 'url' | 'domain' | 'path';
+ value: string;
+}
+
+/**
+ * 触发器规范联合类型
+ */
+export type TriggerSpec =
+ // 手动触发
+ | (TriggerSpecBase & { kind: 'manual' })
+
+ // URL 触发
+ | (TriggerSpecBase & {
+ kind: 'url';
+ match: UrlMatchRule[];
+ })
+
+ // Cron 定时触发
+ | (TriggerSpecBase & {
+ kind: 'cron';
+ cron: string;
+ timezone?: string;
+ })
+
+ // Interval 定时触发(固定间隔重复)
+ | (TriggerSpecBase & {
+ kind: 'interval';
+ /** 间隔分钟数,最小为 1 */
+ periodMinutes: number;
+ })
+
+ // Once 定时触发(指定时间触发一次后自动禁用)
+ | (TriggerSpecBase & {
+ kind: 'once';
+ /** 触发时间戳 (Unix milliseconds) */
+ whenMs: UnixMillis;
+ })
+
+ // 快捷键触发
+ | (TriggerSpecBase & {
+ kind: 'command';
+ commandKey: string;
+ })
+
+ // 右键菜单触发
+ | (TriggerSpecBase & {
+ kind: 'contextMenu';
+ title: string;
+ contexts?: ReadonlyArray;
+ })
+
+ // DOM 元素出现触发
+ | (TriggerSpecBase & {
+ kind: 'dom';
+ selector: string;
+ appear?: boolean;
+ once?: boolean;
+ debounceMs?: UnixMillis;
+ });
+
+/**
+ * 触发器触发上下文
+ * @description 描述触发器被触发时的上下文信息
+ */
+export interface TriggerFireContext {
+ /** 触发器 ID */
+ triggerId: TriggerId;
+ /** 触发器类型 */
+ kind: TriggerKind;
+ /** 触发时间 */
+ firedAt: UnixMillis;
+ /** 来源 Tab ID */
+ sourceTabId?: number;
+ /** 来源 URL */
+ sourceUrl?: string;
+}
+
+/**
+ * 根据触发器类型获取类型化的触发器规范
+ */
+export type TriggerSpecByKind = Extract;
+
+/**
+ * 判断触发器是否启用
+ */
+export function isTriggerEnabled(trigger: TriggerSpec): boolean {
+ return trigger.enabled;
+}
+
+/**
+ * 创建触发器触发上下文
+ */
+export function createTriggerFireContext(
+ trigger: TriggerSpec,
+ options?: { sourceTabId?: number; sourceUrl?: string },
+): TriggerFireContext {
+ return {
+ triggerId: trigger.id,
+ kind: trigger.kind,
+ firedAt: Date.now(),
+ sourceTabId: options?.sourceTabId,
+ sourceUrl: options?.sourceUrl,
+ };
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/domain/variables.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/variables.ts
new file mode 100644
index 00000000..7651703d
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/domain/variables.ts
@@ -0,0 +1,98 @@
+/**
+ * @fileoverview 变量类型定义
+ * @description 定义 Record-Replay V3 中使用的变量指针和持久化变量
+ */
+
+import type { JsonValue, UnixMillis } from './json';
+
+/** 变量名称 */
+export type VariableName = string;
+
+/** 持久化变量名称(以 $ 开头) */
+export type PersistentVariableName = `$${string}`;
+
+/** 变量作用域 */
+export type VariableScope = 'run' | 'flow' | 'persistent';
+
+/**
+ * 变量指针
+ * @description 指向变量的引用,支持 JSON path 访问
+ */
+export interface VariablePointer {
+ /** 变量作用域 */
+ scope: VariableScope;
+ /** 变量名称 */
+ name: VariableName;
+ /** JSON path(用于访问嵌套属性) */
+ path?: ReadonlyArray;
+}
+
+/**
+ * 变量定义
+ * @description Flow 中声明的变量
+ */
+export interface VariableDefinition {
+ /** 变量名称 */
+ name: VariableName;
+ /** 显示标签 */
+ label?: string;
+ /** 描述 */
+ description?: string;
+ /** 是否敏感(不显示/导出) */
+ sensitive?: boolean;
+ /** 是否必需 */
+ required?: boolean;
+ /** 默认值 */
+ default?: JsonValue;
+ /** 作用域(不含 persistent,persistent 通过 $ 前缀判断) */
+ scope?: Exclude;
+}
+
+/**
+ * 持久化变量记录
+ * @description 存储在 IndexedDB 中的持久化变量
+ */
+export interface PersistentVarRecord {
+ /** 变量键(以 $ 开头) */
+ key: PersistentVariableName;
+ /** 变量值 */
+ value: JsonValue;
+ /** 最后更新时间 */
+ updatedAt: UnixMillis;
+ /** 版本号(单调递增,用于 LWW 和调试) */
+ version: number;
+}
+
+/**
+ * 判断变量名是否为持久化变量
+ */
+export function isPersistentVariable(name: string): name is PersistentVariableName {
+ return name.startsWith('$');
+}
+
+/**
+ * 解析变量指针字符串
+ * @example "$user.name" -> { scope: 'persistent', name: '$user', path: ['name'] }
+ */
+export function parseVariablePointer(ref: string): VariablePointer | null {
+ if (!ref) return null;
+
+ const parts = ref.split('.');
+ const name = parts[0];
+ const path = parts.slice(1);
+
+ if (isPersistentVariable(name)) {
+ return {
+ scope: 'persistent',
+ name,
+ path: path.length > 0 ? path : undefined,
+ };
+ }
+
+ // 默认为 run 作用域
+ return {
+ scope: 'run',
+ name,
+ path: path.length > 0 ? path : undefined,
+ };
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/index.ts
new file mode 100644
index 00000000..0d3a6792
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/index.ts
@@ -0,0 +1,27 @@
+/**
+ * @fileoverview Engine 层导出入口
+ */
+
+// Kernel
+export * from './kernel';
+
+// Queue
+export * from './queue';
+
+// Plugins
+export * from './plugins';
+
+// Transport
+export * from './transport';
+
+// Keepalive
+export * from './keepalive';
+
+// Recovery
+export * from './recovery';
+
+// Triggers
+export * from './triggers';
+
+// Storage Port
+export * from './storage';
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/index.ts
new file mode 100644
index 00000000..4f28d18a
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/index.ts
@@ -0,0 +1,5 @@
+/**
+ * @fileoverview Keepalive 模块导出入口
+ */
+
+export * from './offscreen-keepalive';
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/offscreen-keepalive.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/offscreen-keepalive.ts
new file mode 100644
index 00000000..3f50220b
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/offscreen-keepalive.ts
@@ -0,0 +1,451 @@
+/**
+ * @fileoverview Offscreen Keepalive Controller
+ * @description Keeps the MV3 service worker alive using an Offscreen Document + Port heartbeat.
+ *
+ * Architecture:
+ * - Background (this module) listens for an Offscreen Port connection.
+ * - Offscreen connects and sends heartbeat pings.
+ * - Background replies with pong and controls the heartbeat via `start`/`stop`.
+ *
+ * Contract:
+ * - When at least one keepalive reference is held, keepalive must be running.
+ * - When the reference count drops to zero, keepalive must fully stop (no ping loop, no Port, no reconnect).
+ */
+
+import { offscreenManager } from '@/utils/offscreen-manager';
+import {
+ RR_V3_KEEPALIVE_PORT_NAME,
+ type KeepaliveMessage,
+} from '@/common/rr-v3-keepalive-protocol';
+
+// ==================== Runtime Control Protocol ====================
+
+const KEEPALIVE_CONTROL_MESSAGE_TYPE = 'rr_v3_keepalive.control' as const;
+
+type KeepaliveControlCommand = 'start' | 'stop';
+
+interface KeepaliveControlMessage {
+ type: typeof KEEPALIVE_CONTROL_MESSAGE_TYPE;
+ command: KeepaliveControlCommand;
+}
+
+// ==================== Types ====================
+
+/**
+ * Keepalive controller interface.
+ * @description Manages Service Worker keepalive state.
+ */
+export interface KeepaliveController {
+ /**
+ * Acquire (increment reference count).
+ * @param tag Tag used for debugging.
+ * @returns Release function.
+ */
+ acquire(tag: string): () => void;
+
+ /** Whether any keepalive reference is currently held. */
+ isActive(): boolean;
+
+ /** Current reference count. */
+ getRefCount(): number;
+
+ /** Release all references (primarily for testing). */
+ releaseAll(): void;
+}
+
+/**
+ * Offscreen keepalive options.
+ */
+export interface OffscreenKeepaliveOptions {
+ /** Logger. */
+ logger?: Pick;
+}
+
+// ==================== Factory ====================
+
+/**
+ * Create an Offscreen keepalive controller.
+ * @description Reuses the global OffscreenManager to avoid creating multiple Offscreen Documents concurrently.
+ */
+export function createOffscreenKeepaliveController(
+ options: OffscreenKeepaliveOptions = {},
+): KeepaliveController {
+ return new OffscreenKeepaliveControllerImpl(options);
+}
+
+/**
+ * Create a NotImplemented KeepaliveController.
+ * @description Placeholder implementation.
+ */
+export function createNotImplementedKeepaliveController(): KeepaliveController {
+ return {
+ acquire: () => {
+ console.warn('[KeepaliveController] Not implemented, returning no-op release');
+ return () => {};
+ },
+ isActive: () => false,
+ getRefCount: () => 0,
+ releaseAll: () => {},
+ };
+}
+
+// ==================== Implementation ====================
+
+/**
+ * Offscreen keepalive controller implementation.
+ */
+class OffscreenKeepaliveControllerImpl implements KeepaliveController {
+ private readonly refs = new Map();
+ private totalRefs = 0;
+
+ private offscreenPort: chrome.runtime.Port | null = null;
+ private connectionListenerRegistered = false;
+
+ // Used to serialize async operations to avoid races.
+ private syncPromise: Promise = Promise.resolve();
+
+ private readonly logger: Pick;
+
+ constructor(options: OffscreenKeepaliveOptions) {
+ this.logger = options.logger ?? console;
+ // Register listener eagerly to avoid missing Offscreen connect events.
+ // This prevents race conditions where Offscreen connects before we start listening.
+ this.ensureConnectionListener();
+ }
+
+ acquire(tag: string): () => void {
+ this.totalRefs += 1;
+
+ const count = this.refs.get(tag) ?? 0;
+ this.refs.set(tag, count + 1);
+
+ this.logger.debug(`[OffscreenKeepalive] acquire(${tag}), totalRefs=${this.totalRefs}`);
+
+ // Start keepalive when the first reference is acquired.
+ if (this.totalRefs === 1) {
+ this.scheduleSync();
+ }
+
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+
+ if (this.totalRefs > 0) {
+ this.totalRefs -= 1;
+ }
+
+ const currentCount = this.refs.get(tag) ?? 0;
+ if (currentCount <= 1) {
+ this.refs.delete(tag);
+ } else {
+ this.refs.set(tag, currentCount - 1);
+ }
+
+ this.logger.debug(`[OffscreenKeepalive] release(${tag}), totalRefs=${this.totalRefs}`);
+
+ // Stop keepalive when the reference count drops to zero.
+ if (this.totalRefs === 0) {
+ this.scheduleSync();
+ }
+ };
+ }
+
+ isActive(): boolean {
+ return this.totalRefs > 0;
+ }
+
+ getRefCount(): number {
+ return this.totalRefs;
+ }
+
+ releaseAll(): void {
+ if (this.totalRefs === 0) return;
+
+ this.logger.debug('[OffscreenKeepalive] releaseAll()');
+ this.refs.clear();
+ this.totalRefs = 0;
+ this.scheduleSync();
+ }
+
+ /**
+ * Get the current reference counts grouped by tag.
+ * @description Useful for debugging.
+ */
+ getRefsByTag(): Record {
+ return Object.fromEntries(this.refs);
+ }
+
+ // ==================== Private Methods ====================
+
+ /**
+ * Schedule a sync operation.
+ * @description Serializes async operations to avoid races.
+ */
+ private scheduleSync(): void {
+ this.syncPromise = this.syncPromise
+ .catch(() => {
+ // Ignore previous operation errors.
+ })
+ .then(() => this.syncOnce())
+ .catch((e) => {
+ this.logger.warn('[OffscreenKeepalive] sync failed:', e);
+ });
+ }
+
+ /**
+ * Perform a single sync step based on the current ref count.
+ */
+ private async syncOnce(): Promise {
+ if (this.totalRefs > 0) {
+ // Ensure listener exists before Offscreen connects (race prevention).
+ this.ensureConnectionListener();
+
+ // Ensure the Offscreen document exists.
+ await offscreenManager.ensureOffscreenDocument();
+
+ // Re-check after await: state may have changed while we were creating the document.
+ if (this.totalRefs === 0) {
+ await this.teardown();
+ return;
+ }
+
+ // Send start command via runtime message (works even if Port is not connected).
+ await this.sendRuntimeControl('start');
+ // Also send via Port if connected.
+ this.sendStartCommand();
+ } else {
+ // Send stop via Port first (if connected).
+ this.sendStopCommand();
+ // Then send via runtime message to ensure Offscreen stops.
+ await this.sendRuntimeControl('stop');
+ await this.teardown();
+ }
+ }
+
+ /**
+ * Clean up resources.
+ */
+ private async teardown(): Promise {
+ this.disconnectPort();
+ // Note: We do not close the Offscreen Document here because it may be used by other modules.
+ // If Offscreen Document lifecycle needs ref-counting, it should be implemented in OffscreenManager.
+ }
+
+ /**
+ * Ensure the Port connection listener is registered.
+ */
+ private ensureConnectionListener(): void {
+ if (this.connectionListenerRegistered) return;
+
+ if (typeof chrome === 'undefined' || !chrome.runtime?.onConnect) {
+ this.logger.warn('[OffscreenKeepalive] chrome.runtime.onConnect not available');
+ return;
+ }
+
+ chrome.runtime.onConnect.addListener(this.handleConnect);
+ this.connectionListenerRegistered = true;
+
+ this.logger.debug('[OffscreenKeepalive] Connection listener registered');
+ }
+
+ /**
+ * Handle Port connections from Offscreen.
+ */
+ private handleConnect = (port: chrome.runtime.Port): void => {
+ if (port.name !== RR_V3_KEEPALIVE_PORT_NAME) return;
+
+ this.logger.debug('[OffscreenKeepalive] Offscreen connected');
+
+ // Store Port reference.
+ this.offscreenPort = port;
+
+ // Listen to messages.
+ port.onMessage.addListener(this.handlePortMessage);
+
+ // Listen to disconnect.
+ port.onDisconnect.addListener(() => {
+ this.logger.debug('[OffscreenKeepalive] Offscreen disconnected');
+ if (this.offscreenPort === port) {
+ this.offscreenPort = null;
+ }
+ });
+
+ // If active, send the start command.
+ if (this.totalRefs > 0) {
+ this.sendStartCommand();
+ }
+ };
+
+ /**
+ * Handle messages from Offscreen.
+ */
+ private handlePortMessage = (msg: unknown): void => {
+ const m = msg as Partial | null;
+ if (!m || typeof m !== 'object') return;
+
+ if (m.type === 'keepalive.ping') {
+ this.logger.debug('[OffscreenKeepalive] Received ping, sending pong');
+ this.sendPong();
+ }
+ };
+
+ /**
+ * Disconnect the Port.
+ */
+ private disconnectPort(): void {
+ if (!this.offscreenPort) return;
+
+ const port = this.offscreenPort;
+ this.offscreenPort = null;
+
+ try {
+ port.disconnect();
+ } catch {
+ // Port may already be disconnected.
+ }
+
+ this.logger.debug('[OffscreenKeepalive] Port disconnected');
+ }
+
+ /**
+ * Send the start command to Offscreen (Port channel).
+ */
+ private sendStartCommand(): void {
+ if (!this.offscreenPort) return;
+
+ const msg: KeepaliveMessage = {
+ type: 'keepalive.start',
+ timestamp: Date.now(),
+ };
+
+ try {
+ this.offscreenPort.postMessage(msg);
+ this.logger.debug('[OffscreenKeepalive] Sent start command via Port');
+ } catch (e) {
+ this.logger.warn('[OffscreenKeepalive] Failed to send start command:', e);
+ }
+ }
+
+ /**
+ * Send the stop command to Offscreen (Port channel).
+ */
+ private sendStopCommand(): void {
+ if (!this.offscreenPort) return;
+
+ const msg: KeepaliveMessage = {
+ type: 'keepalive.stop',
+ timestamp: Date.now(),
+ };
+
+ try {
+ this.offscreenPort.postMessage(msg);
+ this.logger.debug('[OffscreenKeepalive] Sent stop command via Port');
+ } catch (e) {
+ this.logger.warn('[OffscreenKeepalive] Failed to send stop command:', e);
+ }
+ }
+
+ /**
+ * Send a pong response.
+ */
+ private sendPong(): void {
+ if (!this.offscreenPort) return;
+
+ const msg: KeepaliveMessage = {
+ type: 'keepalive.pong',
+ timestamp: Date.now(),
+ };
+
+ try {
+ this.offscreenPort.postMessage(msg);
+ } catch (e) {
+ this.logger.warn('[OffscreenKeepalive] Failed to send pong:', e);
+ }
+ }
+
+ /**
+ * Send a runtime control command to Offscreen.
+ * This is the control plane used to start/stop keepalive even when the Port is not connected.
+ */
+ private async sendRuntimeControl(command: KeepaliveControlCommand): Promise {
+ if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
+ this.logger.warn('[OffscreenKeepalive] chrome.runtime.sendMessage not available');
+ return;
+ }
+
+ const msg: KeepaliveControlMessage = {
+ type: KEEPALIVE_CONTROL_MESSAGE_TYPE,
+ command,
+ };
+
+ // Retry with delays for start command (Offscreen document may not be ready yet).
+ const delaysMs = command === 'start' ? [0, 50, 200] : [0];
+ for (const delayMs of delaysMs) {
+ if (delayMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ }
+ try {
+ await chrome.runtime.sendMessage(msg);
+ this.logger.debug(`[OffscreenKeepalive] Sent runtime ${command} command`);
+ return;
+ } catch {
+ // Best-effort: Offscreen document may not be ready yet.
+ }
+ }
+
+ this.logger.warn(`[OffscreenKeepalive] Failed to send runtime ${command} command`);
+ }
+}
+
+// ==================== Test Utilities ====================
+
+/**
+ * In-memory keepalive controller.
+ * @description For tests only: tracks reference counts without using Offscreen.
+ */
+export class InMemoryKeepaliveController implements KeepaliveController {
+ private refs = new Map();
+
+ acquire(tag: string): () => void {
+ const count = this.refs.get(tag) ?? 0;
+ this.refs.set(tag, count + 1);
+
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+
+ const currentCount = this.refs.get(tag) ?? 0;
+ if (currentCount <= 1) {
+ this.refs.delete(tag);
+ } else {
+ this.refs.set(tag, currentCount - 1);
+ }
+ };
+ }
+
+ isActive(): boolean {
+ return this.refs.size > 0;
+ }
+
+ getRefCount(): number {
+ let total = 0;
+ for (const count of this.refs.values()) {
+ total += count;
+ }
+ return total;
+ }
+
+ releaseAll(): void {
+ this.refs.clear();
+ }
+
+ /**
+ * Get the current reference counts grouped by tag.
+ * @description Useful for debugging.
+ */
+ getRefsByTag(): Record {
+ return Object.fromEntries(this.refs);
+ }
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/artifacts.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/artifacts.ts
new file mode 100644
index 00000000..6cadb734
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/artifacts.ts
@@ -0,0 +1,193 @@
+/**
+ * @fileoverview 工件(Artifacts)接口
+ * @description 定义截图等工件的获取和存储接口
+ */
+
+import type { NodeId, RunId } from '../../domain/ids';
+import type { RRError } from '../../domain/errors';
+import { RR_ERROR_CODES, createRRError } from '../../domain/errors';
+
+/**
+ * 截图结果
+ */
+export type ScreenshotResult = { ok: true; base64: string } | { ok: false; error: RRError };
+
+/**
+ * 工件服务接口
+ * @description 提供工件获取和存储功能
+ */
+export interface ArtifactService {
+ /**
+ * 截取页面截图
+ * @param tabId Tab ID
+ * @param options 截图选项
+ */
+ screenshot(
+ tabId: number,
+ options?: {
+ format?: 'png' | 'jpeg';
+ quality?: number;
+ },
+ ): Promise;
+
+ /**
+ * 保存截图
+ * @param runId Run ID
+ * @param nodeId Node ID
+ * @param base64 截图数据
+ * @param filename 文件名(可选)
+ */
+ saveScreenshot(
+ runId: RunId,
+ nodeId: NodeId,
+ base64: string,
+ filename?: string,
+ ): Promise<{ savedAs: string } | { error: RRError }>;
+}
+
+/**
+ * 创建 NotImplemented 的 ArtifactService
+ * @description Phase 0-1 占位实现
+ */
+export function createNotImplementedArtifactService(): ArtifactService {
+ return {
+ screenshot: async () => ({
+ ok: false,
+ error: createRRError(RR_ERROR_CODES.INTERNAL, 'ArtifactService.screenshot not implemented'),
+ }),
+ saveScreenshot: async () => ({
+ error: createRRError(
+ RR_ERROR_CODES.INTERNAL,
+ 'ArtifactService.saveScreenshot not implemented',
+ ),
+ }),
+ };
+}
+
+/**
+ * 创建基于 chrome.tabs.captureVisibleTab 的 ArtifactService
+ * @description 使用 Chrome API 截取可见标签页
+ */
+export function createChromeArtifactService(): ArtifactService {
+ // In-memory storage for screenshots (could be replaced with IndexedDB)
+ const screenshotStore = new Map();
+
+ return {
+ screenshot: async (tabId, options) => {
+ try {
+ // Get the window ID for the tab
+ const tab = await chrome.tabs.get(tabId);
+ if (!tab.windowId) {
+ return {
+ ok: false,
+ error: createRRError(RR_ERROR_CODES.INTERNAL, `Tab ${tabId} has no window`),
+ };
+ }
+
+ // Capture the visible tab
+ const format = options?.format ?? 'png';
+ const quality = options?.quality ?? 100;
+
+ const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
+ format,
+ quality: format === 'jpeg' ? quality : undefined,
+ });
+
+ // Extract base64 from data URL
+ const base64Match = dataUrl.match(/^data:image\/\w+;base64,(.+)$/);
+ if (!base64Match) {
+ return {
+ ok: false,
+ error: createRRError(RR_ERROR_CODES.INTERNAL, 'Invalid screenshot data URL'),
+ };
+ }
+
+ return { ok: true, base64: base64Match[1] };
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ return {
+ ok: false,
+ error: createRRError(RR_ERROR_CODES.INTERNAL, `Screenshot failed: ${message}`),
+ };
+ }
+ },
+
+ saveScreenshot: async (runId, nodeId, base64, filename) => {
+ try {
+ // Generate filename if not provided
+ const savedAs = filename ?? `${runId}_${nodeId}_${Date.now()}.png`;
+ const key = `${runId}/${savedAs}`;
+
+ // Store in memory (in production, this would go to IndexedDB or cloud storage)
+ screenshotStore.set(key, base64);
+
+ return { savedAs };
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ return {
+ error: createRRError(RR_ERROR_CODES.INTERNAL, `Save screenshot failed: ${message}`),
+ };
+ }
+ },
+ };
+}
+
+/**
+ * 工件策略执行器
+ * @description 根据策略配置决定是否获取工件
+ */
+export interface ArtifactPolicyExecutor {
+ /**
+ * 执行截图策略
+ * @param policy 截图策略
+ * @param context 上下文
+ */
+ executeScreenshotPolicy(
+ policy: 'never' | 'onFailure' | 'always',
+ context: {
+ tabId: number;
+ runId: RunId;
+ nodeId: NodeId;
+ failed: boolean;
+ saveAs?: string;
+ },
+ ): Promise<{ captured: boolean; savedAs?: string; error?: RRError }>;
+}
+
+/**
+ * 创建默认的工件策略执行器
+ */
+export function createArtifactPolicyExecutor(service: ArtifactService): ArtifactPolicyExecutor {
+ return {
+ executeScreenshotPolicy: async (policy, context) => {
+ // 根据策略决定是否截图
+ const shouldCapture = policy === 'always' || (policy === 'onFailure' && context.failed);
+
+ if (!shouldCapture) {
+ return { captured: false };
+ }
+
+ // 截图
+ const result = await service.screenshot(context.tabId);
+ if (!result.ok) {
+ return { captured: false, error: result.error };
+ }
+
+ // 保存(如果指定了文件名)
+ if (context.saveAs) {
+ const saveResult = await service.saveScreenshot(
+ context.runId,
+ context.nodeId,
+ result.base64,
+ context.saveAs,
+ );
+ if ('error' in saveResult) {
+ return { captured: true, error: saveResult.error };
+ }
+ return { captured: true, savedAs: saveResult.savedAs };
+ }
+
+ return { captured: true };
+ },
+ };
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/breakpoints.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/breakpoints.ts
new file mode 100644
index 00000000..53105a86
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/breakpoints.ts
@@ -0,0 +1,187 @@
+/**
+ * @fileoverview 断点管理器
+ * @description 管理调试断点的添加、删除和命中检测
+ */
+
+import type { NodeId, RunId } from '../../domain/ids';
+import type { Breakpoint, DebuggerState } from '../../domain/debug';
+
+/**
+ * 断点管理器
+ * @description 管理单个 Run 的断点
+ */
+export class BreakpointManager {
+ private breakpoints = new Map();
+ private stepMode: 'none' | 'stepOver' = 'none';
+
+ constructor(initialBreakpoints?: NodeId[]) {
+ if (initialBreakpoints) {
+ for (const nodeId of initialBreakpoints) {
+ this.add(nodeId);
+ }
+ }
+ }
+
+ /**
+ * 添加断点
+ */
+ add(nodeId: NodeId): void {
+ this.breakpoints.set(nodeId, { nodeId, enabled: true });
+ }
+
+ /**
+ * 删除断点
+ */
+ remove(nodeId: NodeId): void {
+ this.breakpoints.delete(nodeId);
+ }
+
+ /**
+ * 设置断点列表(替换所有现有断点)
+ */
+ setAll(nodeIds: NodeId[]): void {
+ this.breakpoints.clear();
+ for (const nodeId of nodeIds) {
+ this.add(nodeId);
+ }
+ }
+
+ /**
+ * 启用断点
+ */
+ enable(nodeId: NodeId): void {
+ const bp = this.breakpoints.get(nodeId);
+ if (bp) {
+ bp.enabled = true;
+ }
+ }
+
+ /**
+ * 禁用断点
+ */
+ disable(nodeId: NodeId): void {
+ const bp = this.breakpoints.get(nodeId);
+ if (bp) {
+ bp.enabled = false;
+ }
+ }
+
+ /**
+ * 检查节点是否有启用的断点
+ */
+ hasBreakpoint(nodeId: NodeId): boolean {
+ const bp = this.breakpoints.get(nodeId);
+ return bp?.enabled ?? false;
+ }
+
+ /**
+ * 检查是否应该在节点处暂停
+ * @description 考虑断点和单步模式
+ */
+ shouldPauseAt(nodeId: NodeId): boolean {
+ // 如果在单步模式,总是暂停
+ if (this.stepMode === 'stepOver') {
+ return true;
+ }
+ // 否则检查断点
+ return this.hasBreakpoint(nodeId);
+ }
+
+ /**
+ * 获取所有断点
+ */
+ getAll(): Breakpoint[] {
+ return Array.from(this.breakpoints.values());
+ }
+
+ /**
+ * 获取启用的断点
+ */
+ getEnabled(): Breakpoint[] {
+ return this.getAll().filter((bp) => bp.enabled);
+ }
+
+ /**
+ * 设置单步模式
+ */
+ setStepMode(mode: 'none' | 'stepOver'): void {
+ this.stepMode = mode;
+ }
+
+ /**
+ * 获取单步模式
+ */
+ getStepMode(): 'none' | 'stepOver' {
+ return this.stepMode;
+ }
+
+ /**
+ * 清除所有断点
+ */
+ clear(): void {
+ this.breakpoints.clear();
+ this.stepMode = 'none';
+ }
+}
+
+/**
+ * 断点管理器注册表
+ * @description 管理多个 Run 的断点管理器
+ */
+export class BreakpointRegistry {
+ private managers = new Map();
+
+ /**
+ * 获取或创建断点管理器
+ */
+ getOrCreate(runId: RunId, initialBreakpoints?: NodeId[]): BreakpointManager {
+ let manager = this.managers.get(runId);
+ if (!manager) {
+ manager = new BreakpointManager(initialBreakpoints);
+ this.managers.set(runId, manager);
+ }
+ return manager;
+ }
+
+ /**
+ * 获取断点管理器
+ */
+ get(runId: RunId): BreakpointManager | undefined {
+ return this.managers.get(runId);
+ }
+
+ /**
+ * 删除断点管理器
+ */
+ remove(runId: RunId): void {
+ this.managers.delete(runId);
+ }
+
+ /**
+ * 清空所有
+ */
+ clear(): void {
+ this.managers.clear();
+ }
+}
+
+/** 全局断点注册表 */
+let globalBreakpointRegistry: BreakpointRegistry | null = null;
+
+/**
+ * 获取全局断点注册表
+ */
+export function getBreakpointRegistry(): BreakpointRegistry {
+ if (!globalBreakpointRegistry) {
+ globalBreakpointRegistry = new BreakpointRegistry();
+ }
+ return globalBreakpointRegistry;
+}
+
+/**
+ * 重置全局断点注册表
+ * @description 主要用于测试
+ */
+export function resetBreakpointRegistry(): void {
+ globalBreakpointRegistry = null;
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/debug-controller.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/debug-controller.ts
new file mode 100644
index 00000000..1200e303
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/debug-controller.ts
@@ -0,0 +1,485 @@
+/**
+ * @fileoverview Debug Controller
+ * @description Central control plane for debugging - command routing, state aggregation, and UI push
+ */
+
+import type { NodeId, RunId } from '../../domain/ids';
+import type { JsonValue } from '../../domain/json';
+import type { PauseReason, RunEvent, Unsubscribe } from '../../domain/events';
+import type {
+ DebuggerCommand,
+ DebuggerResponse,
+ DebuggerState,
+ Breakpoint,
+} from '../../domain/debug';
+import { createInitialDebuggerState } from '../../domain/debug';
+
+import type { StoragePort } from '../storage/storage-port';
+import type { EventsBus } from '../transport/events-bus';
+import type { RunRunner } from './runner';
+import { BreakpointManager, getBreakpointRegistry } from './breakpoints';
+
+/**
+ * Runner registry for managing active runners
+ */
+export interface RunnerRegistry {
+ get(runId: RunId): RunRunner | undefined;
+ register(runId: RunId, runner: RunRunner): void;
+ unregister(runId: RunId): void;
+ list(): RunId[];
+}
+
+/**
+ * Create a simple runner registry
+ */
+export function createRunnerRegistry(): RunnerRegistry {
+ const runners = new Map();
+ return {
+ get: (runId) => runners.get(runId),
+ register: (runId, runner) => runners.set(runId, runner),
+ unregister: (runId) => runners.delete(runId),
+ list: () => Array.from(runners.keys()),
+ };
+}
+
+/**
+ * Debug session state (per-run)
+ */
+interface DebugSession {
+ runId: RunId;
+ attached: boolean;
+ lastPauseReason?: PauseReason;
+ lastKnownNodeId?: NodeId;
+ lastKnownExecution: 'running' | 'paused';
+}
+
+/**
+ * Debug state listener
+ */
+type DebugStateListener = (state: DebuggerState) => void;
+
+/**
+ * Debug Controller Configuration
+ */
+export interface DebugControllerConfig {
+ storage: StoragePort;
+ events: EventsBus;
+ runners: RunnerRegistry;
+}
+
+/**
+ * Debug Controller
+ * @description Single entry point for all debug operations
+ */
+export class DebugController {
+ private readonly storage: StoragePort;
+ private readonly events: EventsBus;
+ private readonly runners: RunnerRegistry;
+
+ private readonly sessions = new Map();
+ private readonly listeners = new Map>();
+ private eventUnsubscribe: Unsubscribe | null = null;
+
+ constructor(config: DebugControllerConfig) {
+ this.storage = config.storage;
+ this.events = config.events;
+ this.runners = config.runners;
+ }
+
+ /**
+ * Start the debug controller
+ */
+ start(): void {
+ // Subscribe to all events to track pause/resume state
+ this.eventUnsubscribe = this.events.subscribe((event) => {
+ this.handleEvent(event);
+ });
+ }
+
+ /**
+ * Stop the debug controller
+ */
+ stop(): void {
+ if (this.eventUnsubscribe) {
+ this.eventUnsubscribe();
+ this.eventUnsubscribe = null;
+ }
+ this.sessions.clear();
+ this.listeners.clear();
+ }
+
+ /**
+ * Handle a debug command
+ */
+ async handle(cmd: DebuggerCommand): Promise {
+ try {
+ switch (cmd.type) {
+ case 'debug.attach':
+ return this.handleAttach(cmd.runId);
+
+ case 'debug.detach':
+ return this.handleDetach(cmd.runId);
+
+ case 'debug.pause':
+ return this.handlePause(cmd.runId);
+
+ case 'debug.resume':
+ return this.handleResume(cmd.runId);
+
+ case 'debug.stepOver':
+ return this.handleStepOver(cmd.runId);
+
+ case 'debug.setBreakpoints':
+ return this.handleSetBreakpoints(cmd.runId, cmd.nodeIds);
+
+ case 'debug.addBreakpoint':
+ return this.handleAddBreakpoint(cmd.runId, cmd.nodeId);
+
+ case 'debug.removeBreakpoint':
+ return this.handleRemoveBreakpoint(cmd.runId, cmd.nodeId);
+
+ case 'debug.getState':
+ return this.handleGetState(cmd.runId);
+
+ case 'debug.getVar':
+ return this.handleGetVar(cmd.runId, cmd.name);
+
+ case 'debug.setVar':
+ return this.handleSetVar(cmd.runId, cmd.name, cmd.value);
+
+ default:
+ return { ok: false, error: `Unknown debug command: ${(cmd as { type: string }).type}` };
+ }
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ return { ok: false, error: message };
+ }
+ }
+
+ /**
+ * Subscribe to debug state changes
+ */
+ subscribe(listener: DebugStateListener, filter?: { runId?: RunId }): Unsubscribe {
+ const key = filter?.runId ?? null;
+ let set = this.listeners.get(key);
+ if (!set) {
+ set = new Set();
+ this.listeners.set(key, set);
+ }
+ set.add(listener);
+
+ return () => {
+ set?.delete(listener);
+ if (set?.size === 0) {
+ this.listeners.delete(key);
+ }
+ };
+ }
+
+ /**
+ * Get current debug state for a run
+ */
+ async getState(runId: RunId): Promise {
+ const session = this.sessions.get(runId);
+ const run = await this.storage.runs.get(runId);
+ const bpManager = getBreakpointRegistry().get(runId);
+
+ const state: DebuggerState = {
+ runId,
+ status: session?.attached ? 'attached' : 'detached',
+ execution: session?.lastKnownExecution ?? (run?.status === 'paused' ? 'paused' : 'running'),
+ pauseReason: session?.lastPauseReason,
+ currentNodeId: session?.lastKnownNodeId ?? run?.currentNodeId,
+ breakpoints: bpManager?.getAll() ?? [],
+ stepMode: bpManager?.getStepMode() ?? 'none',
+ };
+
+ return state;
+ }
+
+ // ==================== Command Handlers ====================
+
+ private async handleAttach(runId: RunId): Promise {
+ const run = await this.storage.runs.get(runId);
+ if (!run) {
+ return { ok: false, error: `Run "${runId}" not found` };
+ }
+
+ // Create or update session
+ let session = this.sessions.get(runId);
+ if (!session) {
+ session = {
+ runId,
+ attached: true,
+ lastKnownExecution: run.status === 'paused' ? 'paused' : 'running',
+ lastKnownNodeId: run.currentNodeId,
+ };
+ this.sessions.set(runId, session);
+ } else {
+ session.attached = true;
+ }
+
+ // Get or create breakpoint manager
+ getBreakpointRegistry().getOrCreate(runId, run.debug?.breakpoints);
+
+ const state = await this.getState(runId);
+ this.notifyStateChange(runId, state);
+ return { ok: true, state };
+ }
+
+ private async handleDetach(runId: RunId): Promise {
+ const session = this.sessions.get(runId);
+ if (session) {
+ session.attached = false;
+ }
+
+ const state = await this.getState(runId);
+ this.notifyStateChange(runId, state);
+ return { ok: true, state };
+ }
+
+ private async handlePause(runId: RunId): Promise {
+ const runner = this.runners.get(runId);
+ if (!runner) {
+ return { ok: false, error: `Runner for "${runId}" not found` };
+ }
+
+ runner.pause();
+ const state = await this.getState(runId);
+ return { ok: true, state };
+ }
+
+ private async handleResume(runId: RunId): Promise {
+ const runner = this.runners.get(runId);
+ if (!runner) {
+ return { ok: false, error: `Runner for "${runId}" not found` };
+ }
+
+ runner.resume();
+ const state = await this.getState(runId);
+ return { ok: true, state };
+ }
+
+ private async handleStepOver(runId: RunId): Promise {
+ const runner = this.runners.get(runId);
+ if (!runner) {
+ return { ok: false, error: `Runner for "${runId}" not found` };
+ }
+
+ // Set step mode to stepOver (will pause at next node)
+ const bpManager = getBreakpointRegistry().getOrCreate(runId);
+ bpManager.setStepMode('stepOver');
+
+ // Resume execution - runner will pause at next node due to stepOver mode
+ runner.resume();
+
+ const state = await this.getState(runId);
+ return { ok: true, state };
+ }
+
+ private async handleSetBreakpoints(runId: RunId, nodeIds: NodeId[]): Promise {
+ const bpManager = getBreakpointRegistry().getOrCreate(runId);
+ bpManager.setAll(nodeIds);
+
+ // Persist breakpoints to run record
+ await this.persistBreakpoints(runId, bpManager);
+
+ const state = await this.getState(runId);
+ this.notifyStateChange(runId, state);
+ return { ok: true, state };
+ }
+
+ private async handleAddBreakpoint(runId: RunId, nodeId: NodeId): Promise {
+ const bpManager = getBreakpointRegistry().getOrCreate(runId);
+ bpManager.add(nodeId);
+
+ await this.persistBreakpoints(runId, bpManager);
+
+ const state = await this.getState(runId);
+ this.notifyStateChange(runId, state);
+ return { ok: true, state };
+ }
+
+ private async handleRemoveBreakpoint(runId: RunId, nodeId: NodeId): Promise {
+ const bpManager = getBreakpointRegistry().getOrCreate(runId);
+ bpManager.remove(nodeId);
+
+ await this.persistBreakpoints(runId, bpManager);
+
+ const state = await this.getState(runId);
+ this.notifyStateChange(runId, state);
+ return { ok: true, state };
+ }
+
+ private async handleGetState(runId: RunId): Promise {
+ const state = await this.getState(runId);
+ return { ok: true, state };
+ }
+
+ private async handleGetVar(runId: RunId, name: string): Promise {
+ // Try to get from active runner first
+ const runner = this.runners.get(runId);
+ if (runner) {
+ const value = runner.getVar(name);
+ return { ok: true, value: value ?? null };
+ }
+
+ // Fallback: reconstruct from events
+ const value = await this.reconstructVar(runId, name);
+ return { ok: true, value: value ?? null };
+ }
+
+ private async handleSetVar(
+ runId: RunId,
+ name: string,
+ value: JsonValue,
+ ): Promise {
+ const runner = this.runners.get(runId);
+ if (!runner) {
+ return {
+ ok: false,
+ error: `Runner for "${runId}" not found - cannot set variable on inactive run`,
+ };
+ }
+
+ runner.setVar(name, value);
+ return { ok: true };
+ }
+
+ // ==================== Event Handling ====================
+
+ private handleEvent(event: RunEvent): void {
+ const { runId } = event;
+ let session = this.sessions.get(runId);
+
+ // Track pause/resume state
+ if (event.type === 'run.paused') {
+ if (!session) {
+ session = {
+ runId,
+ attached: false,
+ lastKnownExecution: 'paused',
+ };
+ this.sessions.set(runId, session);
+ }
+ session.lastKnownExecution = 'paused';
+ session.lastPauseReason = event.reason;
+ session.lastKnownNodeId = event.nodeId;
+ } else if (event.type === 'run.resumed') {
+ if (session) {
+ session.lastKnownExecution = 'running';
+ session.lastPauseReason = undefined;
+ }
+ } else if (event.type === 'run.started') {
+ if (!session) {
+ session = {
+ runId,
+ attached: false,
+ lastKnownExecution: 'running',
+ };
+ this.sessions.set(runId, session);
+ }
+ } else if (
+ event.type === 'run.succeeded' ||
+ event.type === 'run.failed' ||
+ event.type === 'run.canceled'
+ ) {
+ // Run ended - keep session for querying but mark as not running
+ if (session) {
+ session.lastKnownExecution = 'running'; // Technically ended, but not paused
+ }
+ } else if (event.type === 'node.started') {
+ if (session) {
+ session.lastKnownNodeId = event.nodeId;
+ }
+ }
+
+ // Notify listeners if session is attached
+ if (session?.attached) {
+ void this.getState(runId).then((state) => {
+ this.notifyStateChange(runId, state);
+ });
+ }
+ }
+
+ // ==================== Helpers ====================
+
+ private async persistBreakpoints(runId: RunId, bpManager: BreakpointManager): Promise {
+ const breakpoints = bpManager.getEnabled().map((bp) => bp.nodeId);
+ try {
+ await this.storage.runs.patch(runId, {
+ debug: { breakpoints },
+ });
+ } catch {
+ // Run may not exist yet - ignore persistence error
+ }
+ }
+
+ private async reconstructVar(runId: RunId, name: string): Promise {
+ // Get flow and run to reconstruct initial vars
+ const run = await this.storage.runs.get(runId);
+ if (!run) return undefined;
+
+ const flow = await this.storage.flows.get(run.flowId);
+ if (!flow) return undefined;
+
+ // Build initial vars
+ const vars: Record = { ...(run.args ?? {}) };
+ for (const def of flow.variables ?? []) {
+ if (vars[def.name] === undefined && def.default !== undefined) {
+ vars[def.name] = def.default;
+ }
+ }
+
+ // Apply all vars.patch events
+ const events = await this.storage.events.list(runId);
+ for (const event of events) {
+ if (event.type === 'vars.patch') {
+ for (const op of event.patch) {
+ if (op.op === 'set') {
+ vars[op.name] = op.value ?? null;
+ } else {
+ delete vars[op.name];
+ }
+ }
+ }
+ }
+
+ return vars[name];
+ }
+
+ private notifyStateChange(runId: RunId, state: DebuggerState): void {
+ // Notify specific run listeners
+ const runListeners = this.listeners.get(runId);
+ if (runListeners) {
+ for (const listener of runListeners) {
+ try {
+ listener(state);
+ } catch (e) {
+ console.error('[DebugController] Listener error:', e);
+ }
+ }
+ }
+
+ // Notify global listeners
+ const globalListeners = this.listeners.get(null);
+ if (globalListeners) {
+ for (const listener of globalListeners) {
+ try {
+ listener(state);
+ } catch (e) {
+ console.error('[DebugController] Listener error:', e);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Create and start a debug controller
+ */
+export function createDebugController(config: DebugControllerConfig): DebugController {
+ const controller = new DebugController(config);
+ controller.start();
+ return controller;
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/index.ts
new file mode 100644
index 00000000..94fba51c
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/index.ts
@@ -0,0 +1,11 @@
+/**
+ * @fileoverview Kernel 模块导出入口
+ */
+
+export * from './kernel';
+export * from './runner';
+export * from './traversal';
+export * from './breakpoints';
+export * from './artifacts';
+export * from './debug-controller';
+export * from './recovery-kernel';
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/kernel.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/kernel.ts
new file mode 100644
index 00000000..497be257
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/kernel.ts
@@ -0,0 +1,149 @@
+/**
+ * @fileoverview ExecutionKernel 接口定义
+ * @description 定义 Record-Replay V3 的核心执行引擎接口
+ */
+
+import type { JsonObject } from '../../domain/json';
+import type { FlowId, NodeId, RunId } from '../../domain/ids';
+import type { RRError } from '../../domain/errors';
+import type { FlowV3 } from '../../domain/flow';
+import type { DebuggerCommand, DebuggerState } from '../../domain/debug';
+import type { RunEvent, RunStatus, Unsubscribe } from '../../domain/events';
+
+/**
+ * Run 启动请求
+ */
+export interface RunStartRequest {
+ /** Run ID(由调用方生成) */
+ runId: RunId;
+ /** Flow ID */
+ flowId: FlowId;
+ /** Flow 快照(执行时使用的完整 Flow 定义) */
+ flowSnapshot: FlowV3;
+ /** 运行参数 */
+ args?: JsonObject;
+ /** 起始节点 ID(默认为 Flow 的 entryNodeId) */
+ startNodeId?: NodeId;
+ /** Tab ID(必须由调用方分配,每 Run 独占) */
+ tabId: number;
+ /** 调试配置 */
+ debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean };
+}
+
+/**
+ * Run 执行结果
+ */
+export interface RunResult {
+ /** Run ID */
+ runId: RunId;
+ /** 最终状态 */
+ status: Extract;
+ /** 总耗时(毫秒) */
+ tookMs: number;
+ /** 错误信息(如果失败) */
+ error?: RRError;
+ /** 输出结果 */
+ outputs?: JsonObject;
+}
+
+/**
+ * Run 状态查询结果
+ */
+export interface RunStatusInfo {
+ /** 当前状态 */
+ status: RunStatus;
+ /** 当前节点 ID */
+ currentNodeId?: NodeId;
+ /** 开始时间 */
+ startedAt?: number;
+ /** 最后更新时间 */
+ updatedAt: number;
+ /** Tab ID */
+ tabId?: number;
+}
+
+/**
+ * ExecutionKernel 接口
+ * @description Record-Replay V3 的核心执行引擎
+ */
+export interface ExecutionKernel {
+ /**
+ * 订阅事件流
+ * @param listener 事件监听器
+ * @returns 取消订阅函数
+ */
+ onEvent(listener: (event: RunEvent) => void): Unsubscribe;
+
+ /**
+ * 启动 Run
+ * @description 将 Run 加入队列并开始执行
+ */
+ startRun(req: RunStartRequest): Promise;
+
+ /**
+ * 暂停 Run
+ * @param runId Run ID
+ * @param reason 暂停原因
+ */
+ pauseRun(runId: RunId, reason?: { kind: 'command' }): Promise;
+
+ /**
+ * 恢复 Run
+ * @param runId Run ID
+ */
+ resumeRun(runId: RunId): Promise;
+
+ /**
+ * 取消 Run
+ * @param runId Run ID
+ * @param reason 取消原因
+ */
+ cancelRun(runId: RunId, reason?: string): Promise;
+
+ /**
+ * 执行调试命令
+ * @param runId Run ID
+ * @param cmd 调试命令
+ */
+ debug(
+ runId: RunId,
+ cmd: DebuggerCommand,
+ ): Promise<{ ok: true; state?: DebuggerState } | { ok: false; error: string }>;
+
+ /**
+ * 获取 Run 状态
+ * @param runId Run ID
+ * @returns Run 状态信息或 null(如果不存在)
+ */
+ getRunStatus(runId: RunId): Promise;
+
+ /**
+ * 恢复执行
+ * @description 在 Service Worker 重启后调用,恢复中断的 Run
+ */
+ recover(): Promise;
+}
+
+/**
+ * 创建 NotImplemented 的 ExecutionKernel
+ * @description Phase 0 占位实现
+ */
+export function createNotImplementedKernel(): ExecutionKernel {
+ const notImplemented = () => {
+ throw new Error('ExecutionKernel not implemented');
+ };
+
+ return {
+ onEvent: () => {
+ notImplemented();
+ return () => {};
+ },
+ startRun: async () => notImplemented(),
+ pauseRun: async () => notImplemented(),
+ resumeRun: async () => notImplemented(),
+ cancelRun: async () => notImplemented(),
+ debug: async () => notImplemented(),
+ getRunStatus: async () => notImplemented(),
+ recover: async () => notImplemented(),
+ };
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/recovery-kernel.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/recovery-kernel.ts
new file mode 100644
index 00000000..ccbcb9be
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/recovery-kernel.ts
@@ -0,0 +1,97 @@
+/**
+ * @fileoverview 支持崩溃恢复的 ExecutionKernel 实现 (P3-06)
+ * @description
+ * 提供 ExecutionKernel 的恢复增强实现,支持 `recover()` 方法。
+ * 通过委托给 RecoveryCoordinator 实现崩溃恢复。
+ *
+ * 其他执行方法(startRun, pauseRun 等)暂未实现,将在后续阶段完成。
+ */
+
+import type { UnixMillis } from '../../domain/json';
+import type { RunId } from '../../domain/ids';
+import type { DebuggerCommand, DebuggerState } from '../../domain/debug';
+
+import type { StoragePort } from '../storage/storage-port';
+import type { EventsBus } from '../transport/events-bus';
+import { recoverFromCrash } from '../recovery/recovery-coordinator';
+
+import type { ExecutionKernel, RunStartRequest, RunStatusInfo } from './kernel';
+
+// ==================== Types ====================
+
+/**
+ * 支持恢复的 Kernel 依赖
+ */
+export interface RecoveryEnabledKernelDeps {
+ /** 存储层 */
+ storage: StoragePort;
+ /** 事件总线 */
+ events: EventsBus;
+ /** 当前 Service Worker 的 ownerId */
+ ownerId: string;
+ /** 时间源 */
+ now?: () => UnixMillis;
+ /** 日志器 */
+ logger?: Pick;
+}
+
+// ==================== Factory ====================
+
+/**
+ * 创建支持恢复的 ExecutionKernel
+ * @description
+ * 此实现仅支持 `recover()` 和 `getRunStatus()` 方法。
+ * 其他执行方法暂未实现,将在后续阶段完成。
+ */
+export function createRecoveryEnabledKernel(deps: RecoveryEnabledKernelDeps): ExecutionKernel {
+ const logger = deps.logger ?? console;
+ const now = deps.now ?? (() => Date.now());
+
+ if (!deps.ownerId) {
+ throw new Error('ownerId is required');
+ }
+
+ const notImplemented = (name: string): never => {
+ throw new Error(`ExecutionKernel.${name} not implemented`);
+ };
+
+ return {
+ onEvent: (listener) => deps.events.subscribe(listener),
+
+ startRun: async (_req: RunStartRequest) => notImplemented('startRun'),
+ pauseRun: async (_runId: RunId) => notImplemented('pauseRun'),
+ resumeRun: async (_runId: RunId) => notImplemented('resumeRun'),
+ cancelRun: async (_runId: RunId) => notImplemented('cancelRun'),
+
+ debug: async (
+ _runId: RunId,
+ _cmd: DebuggerCommand,
+ ): Promise<{ ok: true; state?: DebuggerState } | { ok: false; error: string }> => {
+ return { ok: false, error: 'ExecutionKernel.debug not configured' };
+ },
+
+ getRunStatus: async (runId: RunId): Promise => {
+ const run = await deps.storage.runs.get(runId);
+ if (!run) return null;
+ return {
+ status: run.status,
+ currentNodeId: run.currentNodeId,
+ startedAt: run.startedAt,
+ updatedAt: run.updatedAt,
+ tabId: run.tabId,
+ };
+ },
+
+ recover: async (): Promise => {
+ logger.info('[RecoveryKernel] Starting crash recovery...');
+ const result = await recoverFromCrash({
+ storage: deps.storage,
+ events: deps.events,
+ ownerId: deps.ownerId,
+ now,
+ logger,
+ });
+ logger.info('[RecoveryKernel] Recovery complete:', result);
+ },
+ };
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/runner.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/runner.ts
new file mode 100644
index 00000000..9f0042e6
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/runner.ts
@@ -0,0 +1,893 @@
+/**
+ * @fileoverview RunRunner 接口和实现
+ * @description 定义和实现单个 Run 的顺序执行器
+ */
+
+import type { NodeId, RunId } from '../../domain/ids';
+import { EDGE_LABELS } from '../../domain/ids';
+import type { FlowV3, NodeV3 } from '../../domain/flow';
+import { findNodeById } from '../../domain/flow';
+import type {
+ PauseReason,
+ RunEvent,
+ RunEventInput,
+ RunRecordV3,
+ Unsubscribe,
+} from '../../domain/events';
+import { RUN_SCHEMA_VERSION } from '../../domain/events';
+import type { JsonObject, JsonValue } from '../../domain/json';
+import { RR_ERROR_CODES, createRRError, type RRError } from '../../domain/errors';
+import type { NodePolicy, RetryPolicy } from '../../domain/policy';
+import { mergeNodePolicy } from '../../domain/policy';
+
+import type { EventsBus } from '../transport/events-bus';
+import type { StoragePort } from '../storage/storage-port';
+import type { PluginRegistry } from '../plugins/registry';
+import { getPluginRegistry } from '../plugins/registry';
+import type { NodeExecutionContext, NodeExecutionResult, VarsPatchOp } from '../plugins/types';
+
+import type { ArtifactService } from './artifacts';
+import { createNotImplementedArtifactService } from './artifacts';
+import { getBreakpointRegistry, type BreakpointManager } from './breakpoints';
+import { findEdgeByLabel, findNextNode, validateFlowDAG } from './traversal';
+import type { RunResult } from './kernel';
+
+// ==================== Types ====================
+
+/**
+ * RunRunner 运行时状态
+ */
+export interface RunnerRuntimeState {
+ /** Run ID */
+ runId: RunId;
+ /** 当前节点 ID */
+ currentNodeId: NodeId | null;
+ /** 当前尝试次数 */
+ attempt: number;
+ /** 变量表 */
+ vars: Record;
+ /** 是否暂停 */
+ paused: boolean;
+ /** 是否取消 */
+ canceled: boolean;
+}
+
+/**
+ * RunRunner 配置
+ */
+export interface RunnerConfig {
+ /** Flow 快照 */
+ flow: FlowV3;
+ /** Tab ID */
+ tabId: number;
+ /** 初始参数 */
+ args?: JsonObject;
+ /** 起始节点 ID */
+ startNodeId?: NodeId;
+ /** 调试配置 */
+ debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean };
+}
+
+/**
+ * RunRunner 接口
+ */
+export interface RunRunner {
+ /** Run ID */
+ readonly runId: RunId;
+ /** 当前状态 */
+ readonly state: RunnerRuntimeState;
+ /** 订阅事件 */
+ onEvent(listener: (event: RunEvent) => void): Unsubscribe;
+ /** 开始执行 */
+ start(): Promise;
+ /** 暂停执行 */
+ pause(): void;
+ /** 恢复执行 */
+ resume(): void;
+ /** 取消执行 */
+ cancel(reason?: string): void;
+ /** 获取变量值 */
+ getVar(name: string): JsonValue | undefined;
+ /** 设置变量值 */
+ setVar(name: string, value: JsonValue): void;
+}
+
+/**
+ * RunRunner 工厂接口
+ */
+export interface RunRunnerFactory {
+ create(runId: RunId, config: RunnerConfig): RunRunner;
+}
+
+/**
+ * RunRunner 工厂依赖
+ */
+export interface RunRunnerFactoryDeps {
+ storage: StoragePort;
+ events: EventsBus;
+ plugins?: PluginRegistry;
+ artifactService?: ArtifactService;
+ now?: () => number;
+}
+
+// ==================== Helpers ====================
+
+interface Deferred {
+ promise: Promise;
+ resolve: (value: T) => void;
+ reject: (reason?: unknown) => void;
+}
+
+function createDeferred(): Deferred {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function errorMessage(err: unknown): string {
+ if (err instanceof Error) return err.message;
+ if (err && typeof err === 'object' && 'message' in err)
+ return String((err as { message: unknown }).message);
+ return String(err);
+}
+
+async function withTimeout(
+ p: Promise,
+ ms: number | undefined,
+ onTimeout: () => RRError,
+): Promise {
+ if (ms === undefined || !Number.isFinite(ms) || ms <= 0) {
+ return p;
+ }
+
+ let timer: ReturnType | undefined;
+ try {
+ return await Promise.race([
+ p,
+ new Promise((_resolve, reject) => {
+ timer = setTimeout(() => reject(onTimeout()), ms);
+ }),
+ ]);
+ } finally {
+ if (timer !== undefined) {
+ clearTimeout(timer);
+ }
+ }
+}
+
+function computeRetryDelayMs(policy: RetryPolicy, attempt: number): number {
+ const base = Math.max(0, policy.intervalMs);
+ let delay = base;
+ const backoff = policy.backoff ?? 'none';
+
+ if (backoff === 'linear') {
+ delay = base * attempt;
+ } else if (backoff === 'exp') {
+ delay = base * Math.pow(2, Math.max(0, attempt - 1));
+ }
+
+ if (policy.maxIntervalMs !== undefined) {
+ delay = Math.min(delay, Math.max(0, policy.maxIntervalMs));
+ }
+
+ if (policy.jitter === 'full') {
+ delay = Math.floor(Math.random() * (delay + 1));
+ }
+
+ return Math.max(0, Math.floor(delay));
+}
+
+function applyVarsPatch(vars: Record, patch: VarsPatchOp[]): void {
+ for (const op of patch) {
+ if (op.op === 'set') {
+ vars[op.name] = op.value ?? null;
+ } else {
+ delete vars[op.name];
+ }
+ }
+}
+
+function toRRError(err: unknown, fallback: { code: string; message: string }): RRError {
+ if (err && typeof err === 'object' && 'code' in err && 'message' in err) {
+ return err as RRError;
+ }
+ return createRRError(
+ fallback.code as RRError['code'],
+ `${fallback.message}: ${errorMessage(err)}`,
+ );
+}
+
+/**
+ * Serial queue for write operations
+ * Ensures event ordering and reduces write races
+ */
+class SerialQueue {
+ private tail: Promise = Promise.resolve();
+
+ run(fn: () => Promise): Promise {
+ const next = this.tail.then(fn, fn);
+ this.tail = next.then(
+ () => undefined,
+ () => undefined,
+ );
+ return next;
+ }
+}
+
+// ==================== Factory ====================
+
+/**
+ * 创建 NotImplemented 的 RunRunnerFactory
+ */
+export function createNotImplementedRunnerFactory(): RunRunnerFactory {
+ return {
+ create: () => {
+ throw new Error('RunRunnerFactory not implemented');
+ },
+ };
+}
+
+/**
+ * 创建 RunRunner 工厂
+ */
+export function createRunRunnerFactory(deps: RunRunnerFactoryDeps): RunRunnerFactory {
+ const plugins = deps.plugins ?? getPluginRegistry();
+ const artifactService = deps.artifactService ?? createNotImplementedArtifactService();
+ const now = deps.now ?? Date.now;
+
+ return {
+ create: (runId, config) =>
+ new StorageBackedRunRunner(runId, config, {
+ storage: deps.storage,
+ events: deps.events,
+ plugins,
+ artifactService,
+ now,
+ }),
+ };
+}
+
+// ==================== Implementation ====================
+
+interface RunnerEnv {
+ storage: StoragePort;
+ events: EventsBus;
+ plugins: PluginRegistry;
+ artifactService: ArtifactService;
+ now: () => number;
+}
+
+type OnErrorDecision =
+ | { kind: 'stop' }
+ | { kind: 'continue' }
+ | {
+ kind: 'goto';
+ target: { kind: 'edgeLabel'; label: string } | { kind: 'node'; nodeId: NodeId };
+ }
+ | { kind: 'retry'; retryPolicy: RetryPolicy | null };
+
+type NodeRunResult =
+ | { nextNodeId: NodeId | null }
+ | { terminal: 'failed'; error: RRError }
+ | { terminal: 'canceled' };
+
+/**
+ * Storage-backed RunRunner implementation
+ */
+class StorageBackedRunRunner implements RunRunner {
+ readonly runId: RunId;
+ readonly state: RunnerRuntimeState;
+
+ private readonly config: RunnerConfig;
+ private readonly env: RunnerEnv;
+ private readonly queue = new SerialQueue();
+ private readonly breakpoints: BreakpointManager;
+
+ private startPromise: Promise | null = null;
+ private outputs: JsonObject = {};
+ private cancelReason: string | undefined;
+ private pauseWaiter: Deferred | null = null;
+
+ constructor(runId: RunId, config: RunnerConfig, env: RunnerEnv) {
+ this.runId = runId;
+ this.config = config;
+ this.env = env;
+
+ this.state = {
+ runId,
+ currentNodeId: null,
+ attempt: 0,
+ vars: this.buildInitialVars(),
+ paused: false,
+ canceled: false,
+ };
+
+ this.breakpoints = getBreakpointRegistry().getOrCreate(runId, config.debug?.breakpoints);
+ }
+
+ onEvent(listener: (event: RunEvent) => void): Unsubscribe {
+ return this.env.events.subscribe(listener, { runId: this.runId });
+ }
+
+ start(): Promise {
+ if (!this.startPromise) {
+ this.startPromise = this.run();
+ }
+ return this.startPromise;
+ }
+
+ pause(): void {
+ this.requestPause({ kind: 'command' });
+ }
+
+ resume(): void {
+ if (!this.state.paused) return;
+ this.state.paused = false;
+ this.pauseWaiter?.resolve(undefined);
+ this.pauseWaiter = null;
+
+ void this.queue
+ .run(async () => {
+ await this.env.storage.runs.patch(this.runId, { status: 'running' });
+ await this.env.events.append({ runId: this.runId, type: 'run.resumed' } as RunEventInput);
+ })
+ .catch((e) => {
+ console.error('[RunRunner] resume persistence failed:', e);
+ });
+ }
+
+ cancel(reason?: string): void {
+ if (this.state.canceled) return;
+ this.state.canceled = true;
+ this.cancelReason = reason;
+
+ if (this.state.paused) {
+ this.state.paused = false;
+ this.pauseWaiter?.resolve(undefined);
+ this.pauseWaiter = null;
+ }
+ }
+
+ getVar(name: string): JsonValue | undefined {
+ return this.state.vars[name];
+ }
+
+ setVar(name: string, value: JsonValue): void {
+ this.state.vars[name] = value;
+
+ // Best-effort: emit vars.patch event
+ void this.queue
+ .run(() =>
+ this.env.events.append({
+ runId: this.runId,
+ type: 'vars.patch',
+ patch: [{ op: 'set', name, value }],
+ } as RunEventInput),
+ )
+ .catch(() => {});
+ }
+
+ // ==================== Private Methods ====================
+
+ private buildInitialVars(): Record {
+ const vars: Record = { ...(this.config.args ?? {}) };
+ for (const def of this.config.flow.variables ?? []) {
+ if (vars[def.name] === undefined && def.default !== undefined) {
+ vars[def.name] = def.default;
+ }
+ }
+ return vars;
+ }
+
+ private requestPause(reason: PauseReason): void {
+ if (this.state.canceled) return;
+ if (this.state.paused) return;
+
+ this.state.paused = true;
+ if (!this.pauseWaiter) {
+ this.pauseWaiter = createDeferred();
+ }
+
+ const nodeId = this.state.currentNodeId ?? undefined;
+ void this.queue
+ .run(async () => {
+ await this.env.storage.runs.patch(this.runId, {
+ status: 'paused',
+ ...(nodeId ? { currentNodeId: nodeId } : {}),
+ });
+ await this.env.events.append({
+ runId: this.runId,
+ type: 'run.paused',
+ reason,
+ ...(nodeId ? { nodeId } : {}),
+ } as RunEventInput);
+ })
+ .catch((e) => {
+ console.error('[RunRunner] pause persistence failed:', e);
+ });
+ }
+
+ private async waitIfPaused(): Promise {
+ while (this.state.paused && !this.state.canceled) {
+ if (!this.pauseWaiter) {
+ this.pauseWaiter = createDeferred();
+ }
+ await this.pauseWaiter.promise;
+ }
+ }
+
+ private async ensureRunRecord(startNodeId: NodeId, startedAt: number): Promise {
+ await this.queue.run(async () => {
+ const existing = await this.env.storage.runs.get(this.runId);
+ if (!existing) {
+ const record: RunRecordV3 = {
+ schemaVersion: RUN_SCHEMA_VERSION,
+ id: this.runId,
+ flowId: this.config.flow.id,
+ status: 'running',
+ createdAt: startedAt,
+ updatedAt: startedAt,
+ startedAt,
+ tabId: this.config.tabId,
+ startNodeId: this.config.startNodeId,
+ currentNodeId: startNodeId,
+ attempt: 0,
+ maxAttempts: 1,
+ args: this.config.args,
+ debug: this.config.debug,
+ nextSeq: 1,
+ };
+ await this.env.storage.runs.save(record);
+ return;
+ }
+
+ if (!Number.isSafeInteger(existing.nextSeq) || existing.nextSeq < 0) {
+ throw createRRError(
+ RR_ERROR_CODES.INVARIANT_VIOLATION,
+ `Invalid nextSeq for run "${this.runId}": ${String(existing.nextSeq)}`,
+ );
+ }
+
+ const patch: Partial = {
+ status: 'running',
+ tabId: this.config.tabId,
+ currentNodeId: startNodeId,
+ };
+ if (existing.startedAt === undefined) patch.startedAt = startedAt;
+ if (this.config.startNodeId !== undefined) patch.startNodeId = this.config.startNodeId;
+ if (this.config.args !== undefined) patch.args = this.config.args;
+ if (this.config.debug !== undefined) patch.debug = this.config.debug;
+ await this.env.storage.runs.patch(this.runId, patch);
+ });
+ }
+
+ private async run(): Promise {
+ const startedAt = this.env.now();
+ const { flow } = this.config;
+
+ const startNodeId = (this.config.startNodeId ?? flow.entryNodeId) as NodeId;
+
+ // Ensure Run record exists FIRST (before DAG validation)
+ // so that finishFailed can safely patch the record
+ await this.ensureRunRecord(startNodeId, startedAt);
+
+ // Validate DAG
+ const validation = validateFlowDAG(flow);
+ if (!validation.ok) {
+ const error =
+ validation.errors[0] ?? createRRError(RR_ERROR_CODES.DAG_INVALID, 'Invalid DAG');
+ return this.finishFailed(startedAt, error, undefined);
+ }
+
+ if (this.state.canceled) {
+ return this.finishCanceled(startedAt);
+ }
+
+ // Emit run.started
+ await this.queue.run(() =>
+ this.env.events.append({
+ runId: this.runId,
+ type: 'run.started',
+ flowId: flow.id,
+ tabId: this.config.tabId,
+ } as RunEventInput),
+ );
+
+ // Handle pauseOnStart
+ if (this.config.debug?.pauseOnStart) {
+ this.requestPause({ kind: 'policy', nodeId: startNodeId, reason: 'pauseOnStart' });
+ }
+
+ // Main execution loop
+ let currentNodeId: NodeId | null = startNodeId;
+ while (currentNodeId) {
+ this.state.currentNodeId = currentNodeId;
+
+ // Only update currentNodeId, not status (to preserve paused state)
+ const nodeIdToUpdate = currentNodeId; // Capture for closure
+ await this.queue.run(() =>
+ this.env.storage.runs.patch(this.runId, { currentNodeId: nodeIdToUpdate }),
+ );
+
+ if (this.state.canceled) break;
+ await this.waitIfPaused();
+ if (this.state.canceled) break;
+
+ const node = findNodeById(flow, currentNodeId);
+ if (!node) {
+ const error = createRRError(
+ RR_ERROR_CODES.DAG_INVALID,
+ `Node "${currentNodeId}" not found in flow`,
+ );
+ return this.finishFailed(startedAt, error, currentNodeId);
+ }
+
+ // Skip disabled nodes
+ if (node.disabled) {
+ await this.queue.run(() =>
+ this.env.events.append({
+ runId: this.runId,
+ type: 'node.skipped',
+ nodeId: node.id,
+ reason: 'disabled',
+ } as RunEventInput),
+ );
+ currentNodeId = findNextNode(flow, node.id);
+ continue;
+ }
+
+ // Check breakpoints
+ if (this.breakpoints.shouldPauseAt(node.id)) {
+ const reason: PauseReason =
+ this.breakpoints.getStepMode() === 'stepOver'
+ ? { kind: 'step', nodeId: node.id }
+ : { kind: 'breakpoint', nodeId: node.id };
+
+ // Clear step mode after hitting (to avoid infinite pause loop)
+ if (this.breakpoints.getStepMode() === 'stepOver') {
+ this.breakpoints.setStepMode('none');
+ }
+
+ this.requestPause(reason);
+ await this.waitIfPaused();
+ // After resume, proceed to execute the node (don't continue loop)
+ }
+
+ // Emit node.queued
+ await this.queue.run(() =>
+ this.env.events.append({
+ runId: this.runId,
+ type: 'node.queued',
+ nodeId: node.id,
+ } as RunEventInput),
+ );
+
+ // Execute node
+ const nodeStartAt = this.env.now();
+ const next = await this.runNode(flow, node, nodeStartAt);
+ if ('terminal' in next) {
+ if (next.terminal === 'canceled') break;
+ if (next.terminal === 'failed') {
+ return this.finishFailed(startedAt, next.error, node.id);
+ }
+ break;
+ }
+
+ currentNodeId = next.nextNodeId;
+ }
+
+ if (this.state.canceled) {
+ return this.finishCanceled(startedAt);
+ }
+
+ return this.finishSucceeded(startedAt);
+ }
+
+ private async runNode(flow: FlowV3, node: NodeV3, nodeStartAt: number): Promise {
+ let attempt = 1;
+
+ for (;;) {
+ if (this.state.canceled) return { terminal: 'canceled' };
+ await this.waitIfPaused();
+ if (this.state.canceled) return { terminal: 'canceled' };
+
+ this.state.attempt = attempt;
+
+ // Emit node.started
+ await this.queue.run(() =>
+ this.env.events.append({
+ runId: this.runId,
+ type: 'node.started',
+ nodeId: node.id,
+ attempt,
+ } as RunEventInput),
+ );
+
+ const exec = await this.executeNodeAttempt(flow, node);
+ if (exec.status === 'succeeded') {
+ const tookMs = this.env.now() - nodeStartAt;
+
+ // Apply vars patch
+ if (exec.varsPatch && exec.varsPatch.length > 0) {
+ applyVarsPatch(this.state.vars, exec.varsPatch);
+ await this.queue.run(() =>
+ this.env.events.append({
+ runId: this.runId,
+ type: 'vars.patch',
+ patch: exec.varsPatch,
+ } as RunEventInput),
+ );
+ }
+
+ // Merge outputs
+ if (exec.outputs) {
+ this.outputs = { ...this.outputs, ...exec.outputs };
+ }
+
+ // Emit node.succeeded
+ await this.queue.run(() =>
+ this.env.events.append({
+ runId: this.runId,
+ type: 'node.succeeded',
+ nodeId: node.id,
+ tookMs,
+ ...(exec.next ? { next: exec.next } : {}),
+ } as RunEventInput),
+ );
+
+ if (exec.next?.kind === 'end') {
+ return { nextNodeId: null };
+ }
+
+ const label = exec.next?.kind === 'edgeLabel' ? exec.next.label : undefined;
+ return { nextNodeId: findNextNode(flow, node.id, label) };
+ }
+
+ // Handle failure
+ const error = exec.error;
+ const policy = this.resolveNodePolicy(flow, node);
+ const decision = this.decideOnError(flow, node, policy, error);
+
+ // Emit node.failed
+ await this.queue.run(() =>
+ this.env.events.append({
+ runId: this.runId,
+ type: 'node.failed',
+ nodeId: node.id,
+ attempt,
+ error,
+ decision: decision.kind,
+ } as RunEventInput),
+ );
+
+ if (decision.kind === 'retry' && decision.retryPolicy) {
+ const maxAttempts = 1 + Math.max(0, decision.retryPolicy.retries);
+ const canRetry =
+ attempt < maxAttempts &&
+ (decision.retryPolicy.retryOn
+ ? decision.retryPolicy.retryOn.includes(
+ error.code as (typeof decision.retryPolicy.retryOn)[number],
+ )
+ : true);
+
+ if (!canRetry) {
+ return { terminal: 'failed', error };
+ }
+
+ const delay = computeRetryDelayMs(decision.retryPolicy, attempt);
+ if (delay > 0) {
+ await sleep(delay);
+ }
+ attempt++;
+ continue;
+ }
+
+ if (decision.kind === 'continue') {
+ return { nextNodeId: findNextNode(flow, node.id) };
+ }
+
+ if (decision.kind === 'goto') {
+ if (decision.target.kind === 'node') {
+ return { nextNodeId: decision.target.nodeId };
+ }
+ return { nextNodeId: findNextNode(flow, node.id, decision.target.label) };
+ }
+
+ return { terminal: 'failed', error };
+ }
+ }
+
+ private resolveNodePolicy(flow: FlowV3, node: NodeV3): NodePolicy {
+ const def = this.env.plugins.getNode(node.kind);
+ const flowDefault = flow.policy?.defaultNodePolicy;
+ const pluginDefault = def?.defaultPolicy;
+ const merged1 = mergeNodePolicy(flowDefault, pluginDefault);
+ return mergeNodePolicy(merged1, node.policy);
+ }
+
+ private decideOnError(
+ flow: FlowV3,
+ node: NodeV3,
+ policy: NodePolicy,
+ _error: RRError,
+ ): OnErrorDecision {
+ const configured = policy.onError;
+
+ // Default: if there's an ON_ERROR edge, use it
+ if (!configured) {
+ const onErrorEdge = findEdgeByLabel(flow, node.id, EDGE_LABELS.ON_ERROR);
+ if (onErrorEdge) {
+ return { kind: 'goto', target: { kind: 'edgeLabel', label: EDGE_LABELS.ON_ERROR } };
+ }
+ return { kind: 'stop' };
+ }
+
+ if (configured.kind === 'stop') return { kind: 'stop' };
+ if (configured.kind === 'continue') return { kind: 'continue' };
+ if (configured.kind === 'goto') {
+ return {
+ kind: 'goto',
+ target: configured.target as
+ | { kind: 'edgeLabel'; label: string }
+ | { kind: 'node'; nodeId: NodeId },
+ };
+ }
+
+ // retry
+ const base: RetryPolicy = policy.retry ?? { retries: 1, intervalMs: 0 };
+ const retryPolicy: RetryPolicy = configured.override
+ ? { ...base, ...configured.override }
+ : base;
+ return { kind: 'retry', retryPolicy };
+ }
+
+ private async executeNodeAttempt(flow: FlowV3, node: NodeV3): Promise {
+ const def = this.env.plugins.getNode(node.kind);
+ if (!def) {
+ return {
+ status: 'failed',
+ error: createRRError(
+ RR_ERROR_CODES.UNSUPPORTED_NODE,
+ `Node kind "${node.kind}" is not registered`,
+ ),
+ };
+ }
+
+ let parsedConfig: unknown = node.config;
+ try {
+ parsedConfig = def.schema.parse(node.config);
+ } catch (e) {
+ return {
+ status: 'failed',
+ error: createRRError(
+ RR_ERROR_CODES.VALIDATION_ERROR,
+ `Invalid node config: ${errorMessage(e)}`,
+ ),
+ };
+ }
+
+ const ctx: NodeExecutionContext = {
+ runId: this.runId,
+ flow,
+ nodeId: node.id,
+ tabId: this.config.tabId,
+ vars: this.state.vars,
+ log: (level, message, data) => {
+ void this.queue
+ .run(() =>
+ this.env.events.append({
+ runId: this.runId,
+ type: 'log',
+ level,
+ message,
+ ...(data !== undefined ? { data } : {}),
+ } as RunEventInput),
+ )
+ .catch(() => {});
+ },
+ chooseNext: (label) => ({ kind: 'edgeLabel', label }),
+ artifacts: {
+ screenshot: () => this.env.artifactService.screenshot(this.config.tabId),
+ },
+ persistent: {
+ get: async (name) => (await this.env.storage.persistentVars.get(name))?.value,
+ set: async (name, value) => {
+ await this.env.storage.persistentVars.set(name, value);
+ },
+ delete: async (name) => {
+ await this.env.storage.persistentVars.delete(name);
+ },
+ },
+ };
+
+ const policy = this.resolveNodePolicy(flow, node);
+ const timeoutMs = policy.timeout?.ms;
+ const scope = policy.timeout?.scope ?? 'attempt';
+ const attemptTimeoutMs = scope === 'attempt' && timeoutMs !== undefined ? timeoutMs : undefined;
+
+ try {
+ const nodeWithConfig = { ...node, config: parsedConfig } as Parameters[1];
+ const execPromise = def.execute(ctx, nodeWithConfig);
+ const result = await withTimeout(execPromise, attemptTimeoutMs, () =>
+ createRRError(RR_ERROR_CODES.TIMEOUT, `Node "${node.id}" timed out`),
+ );
+ return result;
+ } catch (e) {
+ return {
+ status: 'failed',
+ error: toRRError(e, { code: RR_ERROR_CODES.INTERNAL, message: 'Node execution threw' }),
+ };
+ }
+ }
+
+ private async finishSucceeded(startedAt: number): Promise {
+ const tookMs = this.env.now() - startedAt;
+ await this.queue.run(async () => {
+ await this.env.storage.runs.patch(this.runId, {
+ status: 'succeeded',
+ finishedAt: this.env.now(),
+ tookMs,
+ outputs: this.outputs,
+ });
+ await this.env.events.append({
+ runId: this.runId,
+ type: 'run.succeeded',
+ tookMs,
+ outputs: this.outputs,
+ } as RunEventInput);
+ });
+
+ return { runId: this.runId, status: 'succeeded', tookMs, outputs: this.outputs };
+ }
+
+ private async finishFailed(
+ startedAt: number,
+ error: RRError,
+ nodeId?: NodeId,
+ ): Promise {
+ const tookMs = this.env.now() - startedAt;
+ await this.queue.run(async () => {
+ await this.env.storage.runs.patch(this.runId, {
+ status: 'failed',
+ finishedAt: this.env.now(),
+ tookMs,
+ error,
+ ...(nodeId ? { currentNodeId: nodeId } : {}),
+ });
+ await this.env.events.append({
+ runId: this.runId,
+ type: 'run.failed',
+ error,
+ ...(nodeId ? { nodeId } : {}),
+ } as RunEventInput);
+ });
+
+ return { runId: this.runId, status: 'failed', tookMs, error };
+ }
+
+ private async finishCanceled(startedAt: number): Promise {
+ const tookMs = this.env.now() - startedAt;
+ await this.queue.run(async () => {
+ await this.env.storage.runs.patch(this.runId, {
+ status: 'canceled',
+ finishedAt: this.env.now(),
+ tookMs,
+ });
+ await this.env.events.append({
+ runId: this.runId,
+ type: 'run.canceled',
+ ...(this.cancelReason ? { reason: this.cancelReason } : {}),
+ } as RunEventInput);
+ });
+
+ return { runId: this.runId, status: 'canceled', tookMs };
+ }
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/traversal.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/traversal.ts
new file mode 100644
index 00000000..b93c1bd7
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/traversal.ts
@@ -0,0 +1,226 @@
+/**
+ * @fileoverview DAG 遍历和校验
+ * @description 提供 Flow DAG 的校验、遍历和下一节点查找功能
+ */
+
+import type { NodeId, EdgeLabel } from '../../domain/ids';
+import type { FlowV3, EdgeV3 } from '../../domain/flow';
+import { EDGE_LABELS } from '../../domain/ids';
+import { RR_ERROR_CODES, createRRError, type RRError } from '../../domain/errors';
+
+/**
+ * DAG 校验结果
+ */
+export type ValidateFlowDAGResult = { ok: true } | { ok: false; errors: RRError[] };
+
+/**
+ * 校验 Flow DAG 结构
+ * @param flow Flow 定义
+ * @returns 校验结果
+ */
+export function validateFlowDAG(flow: FlowV3): ValidateFlowDAGResult {
+ const errors: RRError[] = [];
+ const nodeIds = new Set(flow.nodes.map((n) => n.id));
+
+ // 检查 entryNodeId 是否存在
+ if (!nodeIds.has(flow.entryNodeId)) {
+ errors.push(
+ createRRError(
+ RR_ERROR_CODES.DAG_INVALID,
+ `Entry node "${flow.entryNodeId}" does not exist in flow`,
+ ),
+ );
+ }
+
+ // 检查边引用的节点是否存在
+ for (const edge of flow.edges) {
+ if (!nodeIds.has(edge.from)) {
+ errors.push(
+ createRRError(
+ RR_ERROR_CODES.DAG_INVALID,
+ `Edge "${edge.id}" references non-existent source node "${edge.from}"`,
+ ),
+ );
+ }
+ if (!nodeIds.has(edge.to)) {
+ errors.push(
+ createRRError(
+ RR_ERROR_CODES.DAG_INVALID,
+ `Edge "${edge.id}" references non-existent target node "${edge.to}"`,
+ ),
+ );
+ }
+ }
+
+ // 检查循环
+ const cycle = detectCycle(flow);
+ if (cycle) {
+ errors.push(
+ createRRError(RR_ERROR_CODES.DAG_CYCLE, `Cycle detected in flow: ${cycle.join(' -> ')}`),
+ );
+ }
+
+ return errors.length > 0 ? { ok: false, errors } : { ok: true };
+}
+
+/**
+ * 检测 DAG 中的循环
+ * @param flow Flow 定义
+ * @returns 循环路径(如果存在)或 null
+ */
+export function detectCycle(flow: FlowV3): NodeId[] | null {
+ const adjacency = buildAdjacencyMap(flow);
+ const visited = new Set();
+ const recursionStack = new Set();
+ const path: NodeId[] = [];
+
+ function dfs(nodeId: NodeId): boolean {
+ visited.add(nodeId);
+ recursionStack.add(nodeId);
+ path.push(nodeId);
+
+ const neighbors = adjacency.get(nodeId) || [];
+ for (const neighbor of neighbors) {
+ if (!visited.has(neighbor)) {
+ if (dfs(neighbor)) {
+ return true;
+ }
+ } else if (recursionStack.has(neighbor)) {
+ // 找到循环
+ const cycleStart = path.indexOf(neighbor);
+ path.push(neighbor); // 闭合循环
+ path.splice(0, cycleStart); // 移除循环前的节点
+ return true;
+ }
+ }
+
+ path.pop();
+ recursionStack.delete(nodeId);
+ return false;
+ }
+
+ for (const node of flow.nodes) {
+ if (!visited.has(node.id)) {
+ if (dfs(node.id)) {
+ return path;
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * 查找下一个节点
+ * @param flow Flow 定义
+ * @param currentNodeId 当前节点 ID
+ * @param label 边标签(可选,默认使用 default)
+ * @returns 下一个节点 ID 或 null(如果没有后续节点)
+ */
+export function findNextNode(
+ flow: FlowV3,
+ currentNodeId: NodeId,
+ label?: EdgeLabel,
+): NodeId | null {
+ const outEdges = flow.edges.filter((e) => e.from === currentNodeId);
+
+ if (outEdges.length === 0) {
+ return null;
+ }
+
+ // 如果指定了 label,优先匹配
+ if (label) {
+ const matchedEdge = outEdges.find((e) => e.label === label);
+ if (matchedEdge) {
+ return matchedEdge.to;
+ }
+ }
+
+ // 否则使用 default 边
+ const defaultEdge = outEdges.find(
+ (e) => e.label === EDGE_LABELS.DEFAULT || e.label === undefined,
+ );
+ if (defaultEdge) {
+ return defaultEdge.to;
+ }
+
+ // 如果只有一条边,使用它
+ if (outEdges.length === 1) {
+ return outEdges[0].to;
+ }
+
+ return null;
+}
+
+/**
+ * 查找指定标签的边
+ */
+export function findEdgeByLabel(
+ flow: FlowV3,
+ fromNodeId: NodeId,
+ label: EdgeLabel,
+): EdgeV3 | undefined {
+ return flow.edges.find((e) => e.from === fromNodeId && e.label === label);
+}
+
+/**
+ * 获取节点的所有出边
+ */
+export function getOutEdges(flow: FlowV3, nodeId: NodeId): EdgeV3[] {
+ return flow.edges.filter((e) => e.from === nodeId);
+}
+
+/**
+ * 获取节点的所有入边
+ */
+export function getInEdges(flow: FlowV3, nodeId: NodeId): EdgeV3[] {
+ return flow.edges.filter((e) => e.to === nodeId);
+}
+
+/**
+ * 构建邻接表
+ */
+function buildAdjacencyMap(flow: FlowV3): Map {
+ const map = new Map();
+
+ for (const node of flow.nodes) {
+ map.set(node.id, []);
+ }
+
+ for (const edge of flow.edges) {
+ const neighbors = map.get(edge.from);
+ if (neighbors) {
+ neighbors.push(edge.to);
+ }
+ }
+
+ return map;
+}
+
+/**
+ * 获取从入口节点可达的所有节点
+ */
+export function getReachableNodes(flow: FlowV3): Set {
+ const reachable = new Set();
+ const adjacency = buildAdjacencyMap(flow);
+
+ function dfs(nodeId: NodeId): void {
+ if (reachable.has(nodeId)) return;
+ reachable.add(nodeId);
+
+ const neighbors = adjacency.get(nodeId) || [];
+ for (const neighbor of neighbors) {
+ dfs(neighbor);
+ }
+ }
+
+ dfs(flow.entryNodeId);
+ return reachable;
+}
+
+/**
+ * 检查节点是否可达
+ */
+export function isNodeReachable(flow: FlowV3, nodeId: NodeId): boolean {
+ return getReachableNodes(flow).has(nodeId);
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/index.ts
new file mode 100644
index 00000000..0a493f46
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/index.ts
@@ -0,0 +1,8 @@
+/**
+ * @fileoverview 插件系统导出入口
+ */
+
+export * from './types';
+export * from './registry';
+export * from './v2-action-adapter';
+export * from './register-v2-replay-nodes';
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts
new file mode 100644
index 00000000..011eb46f
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts
@@ -0,0 +1,90 @@
+/**
+ * @fileoverview Register RR-V2 replay action handlers as RR-V3 nodes
+ * @description
+ * Batch registration of V2 action handlers into the V3 PluginRegistry.
+ * This enables V3 to execute flows that use V2 action types.
+ */
+
+import { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions/handlers';
+import type {
+ ActionHandler,
+ ExecutableActionType,
+} from '@/entrypoints/background/record-replay/actions/types';
+
+import type { PluginRegistry } from './registry';
+import {
+ adaptV2ActionHandlerToV3NodeDefinition,
+ type V2ActionNodeAdapterOptions,
+} from './v2-action-adapter';
+
+export interface RegisterV2ReplayNodesOptions extends V2ActionNodeAdapterOptions {
+ /**
+ * Only include these action types. If not specified, all V2 handlers are included.
+ */
+ include?: ReadonlyArray;
+
+ /**
+ * Exclude these action types. Applied after include filter.
+ */
+ exclude?: ReadonlyArray;
+}
+
+/**
+ * Register V2 replay action handlers as V3 node definitions.
+ *
+ * @param registry The V3 PluginRegistry to register nodes into
+ * @param options Configuration options
+ * @returns Array of registered node kinds
+ *
+ * @example
+ * ```ts
+ * const plugins = new PluginRegistry();
+ * const registered = registerV2ReplayNodesAsV3Nodes(plugins, {
+ * // Exclude control flow handlers that V3 runner doesn't support
+ * exclude: ['foreach', 'while'],
+ * });
+ * console.log('Registered:', registered);
+ * ```
+ */
+export function registerV2ReplayNodesAsV3Nodes(
+ registry: PluginRegistry,
+ options: RegisterV2ReplayNodesOptions = {},
+): string[] {
+ const actionRegistry = createReplayActionRegistry();
+ const handlers = actionRegistry.list();
+
+ const include = options.include ? new Set(options.include) : null;
+ const exclude = options.exclude ? new Set(options.exclude) : null;
+
+ const registered: string[] = [];
+
+ for (const handler of handlers) {
+ if (include && !include.has(handler.type)) continue;
+ if (exclude && exclude.has(handler.type)) continue;
+
+ // Cast needed because V2 handler types don't perfectly align with V3 NodeKind
+ const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(
+ handler as ActionHandler,
+ options,
+ );
+ registry.registerNode(nodeDef as unknown as Parameters[0]);
+ registered.push(handler.type);
+ }
+
+ return registered;
+}
+
+/**
+ * Get list of V2 action types that can be registered.
+ * Useful for debugging and documentation.
+ */
+export function listV2ActionTypes(): string[] {
+ const actionRegistry = createReplayActionRegistry();
+ return actionRegistry.list().map((h) => h.type);
+}
+
+/**
+ * Default exclude list for V3 registration.
+ * These handlers rely on V2 control directives that V3 runner doesn't support.
+ */
+export const DEFAULT_V2_EXCLUDE_LIST = ['foreach', 'while'] as const;
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/registry.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/registry.ts
new file mode 100644
index 00000000..dbf307a0
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/registry.ts
@@ -0,0 +1,157 @@
+/**
+ * @fileoverview 插件注册表
+ * @description 管理节点和触发器插件的注册和查询
+ */
+
+import type { NodeKind } from '../../domain/flow';
+import type { TriggerKind } from '../../domain/triggers';
+import { RR_ERROR_CODES, createRRError } from '../../domain/errors';
+import type {
+ NodeDefinition,
+ TriggerDefinition,
+ PluginRegistrationContext,
+ RRPlugin,
+} from './types';
+
+/**
+ * 插件注册表
+ * @description 单例模式,管理所有已注册的节点和触发器
+ */
+export class PluginRegistry implements PluginRegistrationContext {
+ private nodes = new Map();
+ private triggers = new Map();
+
+ /**
+ * 注册节点定义
+ * @description 如果已存在同名节点,会覆盖
+ */
+ registerNode(def: NodeDefinition): void {
+ this.nodes.set(def.kind, def);
+ }
+
+ /**
+ * 注册触发器定义
+ * @description 如果已存在同名触发器,会覆盖
+ */
+ registerTrigger(def: TriggerDefinition): void {
+ this.triggers.set(def.kind, def);
+ }
+
+ /**
+ * 获取节点定义
+ * @returns 节点定义或 undefined
+ */
+ getNode(kind: NodeKind): NodeDefinition | undefined {
+ return this.nodes.get(kind);
+ }
+
+ /**
+ * 获取节点定义(必须存在)
+ * @throws RRError 如果节点未注册
+ */
+ getNodeOrThrow(kind: NodeKind): NodeDefinition {
+ const def = this.nodes.get(kind);
+ if (!def) {
+ throw createRRError(RR_ERROR_CODES.UNSUPPORTED_NODE, `Node kind "${kind}" is not registered`);
+ }
+ return def;
+ }
+
+ /**
+ * 获取触发器定义
+ * @returns 触发器定义或 undefined
+ */
+ getTrigger(kind: TriggerKind): TriggerDefinition | undefined {
+ return this.triggers.get(kind);
+ }
+
+ /**
+ * 获取触发器定义(必须存在)
+ * @throws RRError 如果触发器未注册
+ */
+ getTriggerOrThrow(kind: TriggerKind): TriggerDefinition {
+ const def = this.triggers.get(kind);
+ if (!def) {
+ throw createRRError(
+ RR_ERROR_CODES.UNSUPPORTED_NODE,
+ `Trigger kind "${kind}" is not registered`,
+ );
+ }
+ return def;
+ }
+
+ /**
+ * 检查节点是否已注册
+ */
+ hasNode(kind: NodeKind): boolean {
+ return this.nodes.has(kind);
+ }
+
+ /**
+ * 检查触发器是否已注册
+ */
+ hasTrigger(kind: TriggerKind): boolean {
+ return this.triggers.has(kind);
+ }
+
+ /**
+ * 获取所有已注册的节点类型
+ */
+ listNodeKinds(): NodeKind[] {
+ return Array.from(this.nodes.keys());
+ }
+
+ /**
+ * 获取所有已注册的触发器类型
+ */
+ listTriggerKinds(): TriggerKind[] {
+ return Array.from(this.triggers.keys());
+ }
+
+ /**
+ * 注册插件
+ * @description 调用插件的 register 方法
+ */
+ registerPlugin(plugin: RRPlugin): void {
+ plugin.register(this);
+ }
+
+ /**
+ * 批量注册插件
+ */
+ registerPlugins(plugins: RRPlugin[]): void {
+ for (const plugin of plugins) {
+ this.registerPlugin(plugin);
+ }
+ }
+
+ /**
+ * 清空所有注册
+ * @description 主要用于测试
+ */
+ clear(): void {
+ this.nodes.clear();
+ this.triggers.clear();
+ }
+}
+
+/** 全局插件注册表实例 */
+let globalRegistry: PluginRegistry | null = null;
+
+/**
+ * 获取全局插件注册表
+ */
+export function getPluginRegistry(): PluginRegistry {
+ if (!globalRegistry) {
+ globalRegistry = new PluginRegistry();
+ }
+ return globalRegistry;
+}
+
+/**
+ * 重置全局插件注册表
+ * @description 主要用于测试
+ */
+export function resetPluginRegistry(): void {
+ globalRegistry = null;
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/types.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/types.ts
new file mode 100644
index 00000000..8851f7bb
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/types.ts
@@ -0,0 +1,181 @@
+/**
+ * @fileoverview 插件类型定义
+ * @description 定义 Record-Replay V3 中的节点和触发器插件接口
+ */
+
+import { z } from 'zod';
+
+import type { JsonObject, JsonValue } from '../../domain/json';
+import type { FlowId, NodeId, RunId, TriggerId } from '../../domain/ids';
+import type { NodeKind } from '../../domain/flow';
+import type { RRError } from '../../domain/errors';
+import type { NodePolicy } from '../../domain/policy';
+import type { FlowV3, NodeV3 } from '../../domain/flow';
+import type { TriggerKind } from '../../domain/triggers';
+
+/**
+ * Schema 类型
+ * @description 使用 Zod 进行配置校验
+ */
+export type Schema = z.ZodType;
+
+/**
+ * 节点执行上下文
+ * @description 提供给节点执行器的运行时上下文
+ */
+export interface NodeExecutionContext {
+ /** Run ID */
+ runId: RunId;
+ /** Flow 定义(快照) */
+ flow: FlowV3;
+ /** 当前节点 ID */
+ nodeId: NodeId;
+
+ /** 绑定的 Tab ID(每 Run 独占) */
+ tabId: number;
+ /** Frame ID(默认 0 为主框架) */
+ frameId?: number;
+
+ /** 当前变量表 */
+ vars: Record;
+
+ /**
+ * 日志记录
+ */
+ log: (level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: JsonValue) => void;
+
+ /**
+ * 选择下一个边
+ * @description 用于条件分支节点
+ */
+ chooseNext: (label: string) => { kind: 'edgeLabel'; label: string };
+
+ /**
+ * 工件操作
+ */
+ artifacts: {
+ /** 截取当前页面截图 */
+ screenshot: () => Promise<{ ok: true; base64: string } | { ok: false; error: RRError }>;
+ };
+
+ /**
+ * 持久化变量操作
+ */
+ persistent: {
+ /** 获取持久化变量 */
+ get: (name: `$${string}`) => Promise;
+ /** 设置持久化变量 */
+ set: (name: `$${string}`, value: JsonValue) => Promise;
+ /** 删除持久化变量 */
+ delete: (name: `$${string}`) => Promise;
+ };
+}
+
+/**
+ * 变量补丁操作
+ */
+export interface VarsPatchOp {
+ op: 'set' | 'delete';
+ name: string;
+ value?: JsonValue;
+}
+
+/**
+ * 节点执行结果
+ */
+export type NodeExecutionResult =
+ | {
+ status: 'succeeded';
+ /** 下一步执行方向 */
+ next?: { kind: 'edgeLabel'; label: string } | { kind: 'end' };
+ /** 输出结果 */
+ outputs?: JsonObject;
+ /** 变量修改 */
+ varsPatch?: VarsPatchOp[];
+ }
+ | { status: 'failed'; error: RRError };
+
+/**
+ * 节点定义
+ * @description 定义一种节点类型的执行逻辑
+ */
+export interface NodeDefinition<
+ TKind extends NodeKind = NodeKind,
+ TConfig extends JsonObject = JsonObject,
+> {
+ /** 节点类型标识 */
+ kind: TKind;
+ /** 配置校验 Schema */
+ schema: Schema;
+ /** 默认策略 */
+ defaultPolicy?: NodePolicy;
+ /**
+ * 执行节点
+ * @param ctx 执行上下文
+ * @param node 节点定义(含配置)
+ */
+ execute(
+ ctx: NodeExecutionContext,
+ node: NodeV3 & { kind: TKind; config: TConfig },
+ ): Promise;
+}
+
+/**
+ * 触发器安装上下文
+ */
+export interface TriggerInstallContext<
+ TKind extends TriggerKind = TriggerKind,
+ TConfig extends JsonObject = JsonObject,
+> {
+ /** 触发器 ID */
+ triggerId: TriggerId;
+ /** 触发器类型 */
+ kind: TKind;
+ /** 是否启用 */
+ enabled: boolean;
+ /** 关联的 Flow ID */
+ flowId: FlowId;
+ /** 触发器配置 */
+ config: TConfig;
+ /** 传递给 Flow 的参数 */
+ args?: JsonObject;
+}
+
+/**
+ * 触发器定义
+ * @description 定义一种触发器类型的安装和卸载逻辑
+ */
+export interface TriggerDefinition<
+ TKind extends TriggerKind = TriggerKind,
+ TConfig extends JsonObject = JsonObject,
+> {
+ /** 触发器类型标识 */
+ kind: TKind;
+ /** 配置校验 Schema */
+ schema: Schema;
+ /** 安装触发器 */
+ install(ctx: TriggerInstallContext): Promise | void;
+ /** 卸载触发器 */
+ uninstall(ctx: TriggerInstallContext): Promise | void;
+}
+
+/**
+ * 插件注册上下文
+ */
+export interface PluginRegistrationContext {
+ /** 注册节点定义 */
+ registerNode(def: NodeDefinition): void;
+ /** 注册触发器定义 */
+ registerTrigger(def: TriggerDefinition): void;
+}
+
+/**
+ * 插件接口
+ * @description Record-Replay 插件的标准接口
+ */
+export interface RRPlugin {
+ /** 插件名称 */
+ name: string;
+ /** 注册插件内容 */
+ register(ctx: PluginRegistrationContext): void;
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter.ts
new file mode 100644
index 00000000..cbc10684
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter.ts
@@ -0,0 +1,414 @@
+/**
+ * @fileoverview V2 ActionHandler -> V3 NodeDefinition adapter
+ * @description Bridges legacy RR-V2 action handlers into the RR-V3 PluginRegistry.
+ *
+ * Design notes:
+ * - V3 requires variable mutations to be represented as varsPatch so they are auditable in the event log.
+ * - V2 handlers mutate ctx.vars directly, so we run them against a cloned VariableStore and diff it.
+ * - Cross-node state (tabId/frameId changes from switchFrame/openTab/switchTab) is persisted in internal vars.
+ *
+ * WARNING: This adapter accesses V2 handler internals and may need updates if V2 types change.
+ */
+
+import { z } from 'zod';
+
+import type {
+ ActionExecutionContext,
+ ActionExecutionResult,
+ ActionHandler,
+ ActionError,
+ ActionErrorCode,
+ ActionPolicy,
+ ExecutableActionType,
+ ValidationResult,
+ Action,
+} from '@/entrypoints/background/record-replay/actions/types';
+
+import type { JsonValue, JsonObject } from '../../domain/json';
+import { RR_ERROR_CODES, createRRError, type RRError, type RRErrorCode } from '../../domain/errors';
+import type { NodePolicy } from '../../domain/policy';
+import { mergeNodePolicy } from '../../domain/policy';
+
+import type {
+ NodeDefinition,
+ NodeExecutionContext,
+ NodeExecutionResult,
+ VarsPatchOp,
+} from './types';
+
+// Internal run-scoped state keys used to emulate V2 "mutable context" across nodes.
+const DEFAULT_TAB_ID_VAR = '__rr_v2__tabId';
+const DEFAULT_FRAME_ID_VAR = '__rr_v2__frameId';
+
+export interface V2ActionNodeAdapterOptions {
+ /**
+ * Whether to emit v2 ActionExecutionResult.output into V3 NodeExecutionResult.outputs.
+ * Defaults to true.
+ */
+ includeOutput?: boolean;
+
+ /**
+ * Where to store cross-node "mutable context" state (tabId/frameId).
+ * Defaults are "__rr_v2__tabId" and "__rr_v2__frameId".
+ */
+ stateVars?: {
+ tabIdVar?: string;
+ frameIdVar?: string;
+ };
+
+ /**
+ * Execution flags forwarded into V2 ActionExecutionContext.execution.
+ * Keep default undefined to preserve V2 handler behavior.
+ */
+ executionFlags?: ActionExecutionContext['execution'];
+}
+
+// ==================== Utilities ====================
+
+function toErrorMessage(e: unknown): string {
+ if (e instanceof Error) return e.message;
+ if (e && typeof e === 'object' && 'message' in e)
+ return String((e as { message: unknown }).message);
+ return String(e);
+}
+
+function deepClone(value: T): T {
+ const sc = (globalThis as unknown as { structuredClone?: (v: U) => U }).structuredClone;
+ if (typeof sc === 'function') return sc(value);
+ return JSON.parse(JSON.stringify(value)) as T;
+}
+
+function safeJsonValue(value: unknown): JsonValue {
+ if (value === undefined) return null;
+ try {
+ const s = JSON.stringify(value);
+ if (s === undefined) return String(value);
+ return JSON.parse(s) as JsonValue;
+ } catch {
+ return String(value);
+ }
+}
+
+function mapLogLevel(level: 'info' | 'warn' | 'error' | undefined): 'info' | 'warn' | 'error' {
+ return level ?? 'info';
+}
+
+function mapV2ErrorCode(code: ActionErrorCode): RRErrorCode {
+ switch (code) {
+ case 'VALIDATION_ERROR':
+ return RR_ERROR_CODES.VALIDATION_ERROR;
+ case 'TIMEOUT':
+ return RR_ERROR_CODES.TIMEOUT;
+ case 'TAB_NOT_FOUND':
+ return RR_ERROR_CODES.TAB_NOT_FOUND;
+ case 'FRAME_NOT_FOUND':
+ return RR_ERROR_CODES.FRAME_NOT_FOUND;
+ case 'TARGET_NOT_FOUND':
+ return RR_ERROR_CODES.TARGET_NOT_FOUND;
+ case 'ELEMENT_NOT_VISIBLE':
+ return RR_ERROR_CODES.ELEMENT_NOT_VISIBLE;
+ case 'NAVIGATION_FAILED':
+ return RR_ERROR_CODES.NAVIGATION_FAILED;
+ case 'NETWORK_REQUEST_FAILED':
+ return RR_ERROR_CODES.NETWORK_REQUEST_FAILED;
+ case 'SCRIPT_FAILED':
+ return RR_ERROR_CODES.SCRIPT_FAILED;
+
+ // V3 doesn't currently have dedicated codes for these.
+ case 'DOWNLOAD_FAILED':
+ case 'ASSERTION_FAILED':
+ return RR_ERROR_CODES.TOOL_ERROR;
+
+ case 'UNKNOWN':
+ default:
+ return RR_ERROR_CODES.INTERNAL;
+ }
+}
+
+function toRRErrorFromV2(error: ActionError): RRError {
+ const data = error.data !== undefined ? safeJsonValue(error.data) : undefined;
+ return createRRError(
+ mapV2ErrorCode(error.code),
+ error.message,
+ data !== undefined ? { data } : undefined,
+ );
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
+function jsonEquals(a: JsonValue, b: JsonValue): boolean {
+ if (a === b) return true;
+
+ const aIsArray = Array.isArray(a);
+ const bIsArray = Array.isArray(b);
+ if (aIsArray || bIsArray) {
+ if (!aIsArray || !bIsArray) return false;
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (!jsonEquals(a[i] as JsonValue, b[i] as JsonValue)) return false;
+ }
+ return true;
+ }
+
+ const aIsObj = isRecord(a);
+ const bIsObj = isRecord(b);
+ if (aIsObj || bIsObj) {
+ if (!aIsObj || !bIsObj) return false;
+ const aKeys = Object.keys(a);
+ const bKeys = Object.keys(b);
+ if (aKeys.length !== bKeys.length) return false;
+ for (const k of aKeys) {
+ if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
+ if (!jsonEquals(a[k] as JsonValue, (b as Record)[k] as JsonValue))
+ return false;
+ }
+ return true;
+ }
+
+ return false;
+}
+
+function diffVars(
+ before: Record,
+ after: Record,
+): VarsPatchOp[] {
+ const patch: VarsPatchOp[] = [];
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
+
+ for (const key of keys) {
+ const beforeHas = Object.prototype.hasOwnProperty.call(before, key);
+ const afterHas = Object.prototype.hasOwnProperty.call(after, key);
+
+ if (!afterHas) {
+ if (beforeHas) patch.push({ op: 'delete', name: key });
+ continue;
+ }
+
+ const afterVal = after[key];
+ if (!beforeHas) {
+ patch.push({ op: 'set', name: key, value: afterVal });
+ continue;
+ }
+
+ const beforeVal = before[key];
+ if (!jsonEquals(beforeVal, afterVal)) {
+ patch.push({ op: 'set', name: key, value: afterVal });
+ }
+ }
+
+ return patch;
+}
+
+function readNumberVar(vars: Record, key: string): number | undefined {
+ const v = vars[key];
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
+}
+
+function toV2ActionPolicy(policy: NodePolicy | undefined): ActionPolicy | undefined {
+ if (!policy) return undefined;
+
+ const timeout = policy.timeout
+ ? {
+ ms: policy.timeout.ms,
+ scope: policy.timeout.scope === 'node' ? ('action' as const) : ('attempt' as const),
+ }
+ : undefined;
+
+ // NodePolicy/ActionPolicy are structurally similar; we only normalize timeout.scope.
+ return {
+ ...(timeout ? { timeout } : {}),
+ ...(policy.retry ? { retry: policy.retry as unknown as ActionPolicy['retry'] } : {}),
+ ...(policy.artifacts
+ ? { artifacts: policy.artifacts as unknown as ActionPolicy['artifacts'] }
+ : {}),
+ ...(policy.onError
+ ? (() => {
+ // V2 only supports goto by edge label. Node-target goto can't be represented.
+ if (policy.onError.kind === 'goto' && policy.onError.target.kind === 'node') {
+ return { onError: { kind: 'stop' } as ActionPolicy['onError'] };
+ }
+ if (policy.onError.kind === 'continue') {
+ return {
+ onError: {
+ kind: 'continue',
+ level: policy.onError.as,
+ } as ActionPolicy['onError'],
+ };
+ }
+ if (policy.onError.kind === 'goto') {
+ const target = policy.onError.target;
+ if (target.kind === 'edgeLabel') {
+ return {
+ onError: {
+ kind: 'goto',
+ label: target.label,
+ } as ActionPolicy['onError'],
+ };
+ }
+ // Node target can't be represented in V2, fall through to stop
+ return { onError: { kind: 'stop' } as ActionPolicy['onError'] };
+ }
+ if (policy.onError.kind === 'retry') {
+ // V2 has retry policy on action.policy.retry; keep onError as stop to avoid double semantics.
+ return { onError: { kind: 'stop' } as ActionPolicy['onError'] };
+ }
+ return { onError: policy.onError as unknown as ActionPolicy['onError'] };
+ })()
+ : {}),
+ };
+}
+
+function toJsonRecord(value: unknown): Record {
+ const out: Record = {};
+ if (!isRecord(value)) return out;
+
+ for (const [k, v] of Object.entries(value)) {
+ // Treat undefined as deletion (omit).
+ if (v === undefined) continue;
+ out[k] = safeJsonValue(v);
+ }
+
+ return out;
+}
+
+// ==================== Main Adapter ====================
+
+/**
+ * Adapt a single V2 ActionHandler into a V3 NodeDefinition.
+ */
+export function adaptV2ActionHandlerToV3NodeDefinition(
+ handler: ActionHandler,
+ options: V2ActionNodeAdapterOptions = {},
+): NodeDefinition {
+ const tabIdVar = options.stateVars?.tabIdVar ?? DEFAULT_TAB_ID_VAR;
+ const frameIdVar = options.stateVars?.frameIdVar ?? DEFAULT_FRAME_ID_VAR;
+
+ return {
+ kind: handler.type,
+ schema: z.record(z.any()) as unknown as NodeDefinition['schema'],
+ execute: async (ctx: NodeExecutionContext, node): Promise => {
+ const beforeVars = ctx.vars;
+
+ const effectiveTabId = readNumberVar(beforeVars, tabIdVar) ?? ctx.tabId;
+ const effectiveFrameId = readNumberVar(beforeVars, frameIdVar);
+
+ // Run against a cloned variable store to prevent bypassing vars.patch event stream.
+ const v2Vars = deepClone(beforeVars) as unknown as Record;
+
+ const v2Ctx: ActionExecutionContext = {
+ vars: v2Vars as unknown as ActionExecutionContext['vars'],
+ tabId: effectiveTabId,
+ frameId: effectiveFrameId,
+ runId: ctx.runId,
+ log: (message, level) => ctx.log(mapLogLevel(level), message),
+ pushLog: (entry) => {
+ try {
+ ctx.log('debug', 'v2.pushLog', safeJsonValue(entry));
+ } catch {
+ // ignore
+ }
+ },
+ captureScreenshot: async () => {
+ const r = await ctx.artifacts.screenshot();
+ if (r.ok) return r.base64;
+ throw new Error(r.error.message);
+ },
+ ...(options.executionFlags ? { execution: options.executionFlags } : {}),
+ };
+
+ const effectivePolicy = mergeNodePolicy(ctx.flow.policy?.defaultNodePolicy, node.policy);
+ const v2Policy = toV2ActionPolicy(effectivePolicy);
+
+ const action: Action = {
+ id: node.id as Action['id'],
+ type: handler.type,
+ ...(node.name ? { name: node.name } : {}),
+ ...(node.disabled ? { disabled: true } : {}),
+ ...(v2Policy ? { policy: v2Policy } : {}),
+ params: node.config as unknown as Action['params'],
+ ...(node.ui ? { ui: node.ui as Action['ui'] } : {}),
+ };
+
+ // V2 handler-level validation
+ if (handler.validate) {
+ const v: ValidationResult = handler.validate(action);
+ if (!v.ok) {
+ return {
+ status: 'failed',
+ error: createRRError(RR_ERROR_CODES.VALIDATION_ERROR, v.errors.join(', ')),
+ };
+ }
+ }
+
+ let result: ActionExecutionResult;
+ try {
+ result = await handler.run(v2Ctx, action);
+ } catch (e) {
+ return {
+ status: 'failed',
+ error: createRRError(
+ RR_ERROR_CODES.INTERNAL,
+ `V2 handler "${handler.type}" threw: ${toErrorMessage(e)}`,
+ ),
+ };
+ }
+
+ if (result.status === 'failed') {
+ const err = result.error
+ ? toRRErrorFromV2(result.error)
+ : createRRError(RR_ERROR_CODES.INTERNAL, `V2 handler "${handler.type}" failed`);
+ return { status: 'failed', error: err };
+ }
+
+ if (result.status === 'paused') {
+ return {
+ status: 'failed',
+ error: createRRError(
+ RR_ERROR_CODES.RUN_PAUSED,
+ `V2 handler "${handler.type}" returned paused (not supported in V3 NodeExecutionResult)`,
+ ),
+ };
+ }
+
+ // V3 does not support V2 scheduler control directives (foreach/while).
+ if (result.control) {
+ return {
+ status: 'failed',
+ error: createRRError(
+ RR_ERROR_CODES.UNSUPPORTED_NODE,
+ `V2 control directive "${result.control.kind}" is not supported by the V3 runner`,
+ { data: safeJsonValue(result.control) },
+ ),
+ };
+ }
+
+ // Persist cross-node context changes via internal vars.
+ if (typeof v2Ctx.frameId === 'number' && Number.isFinite(v2Ctx.frameId)) {
+ v2Vars[frameIdVar] = v2Ctx.frameId;
+ } else {
+ delete v2Vars[frameIdVar];
+ }
+
+ if (typeof result.newTabId === 'number' && Number.isFinite(result.newTabId)) {
+ v2Vars[tabIdVar] = result.newTabId;
+ }
+
+ const afterVars = toJsonRecord(v2Vars);
+ const varsPatch = diffVars(beforeVars, afterVars);
+
+ const outputs: Record | undefined =
+ options.includeOutput === false || result.output === undefined
+ ? undefined
+ : { [node.id]: safeJsonValue(result.output) };
+
+ return {
+ status: 'succeeded',
+ ...(result.nextLabel ? { next: ctx.chooseNext(result.nextLabel) } : {}),
+ ...(outputs ? { outputs } : {}),
+ ...(varsPatch.length > 0 ? { varsPatch } : {}),
+ };
+ },
+ };
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/enqueue-run.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/enqueue-run.ts
new file mode 100644
index 00000000..52e25917
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/enqueue-run.ts
@@ -0,0 +1,219 @@
+/**
+ * @fileoverview 共享入队服务
+ * @description
+ * 提供统一的 Run 入队逻辑,供 RPC Server 和 TriggerManager 共用。
+ *
+ * 设计理由:
+ * - 将原本位于 RpcServer 的入队逻辑抽离为独立服务
+ * - 避免 RPC 和 TriggerManager 之间的行为漂移
+ * - 统一参数校验、Run 创建、队列入队、事件发布流程
+ */
+
+import type { JsonObject, UnixMillis } from '../../domain/json';
+import type { FlowId, NodeId, RunId } from '../../domain/ids';
+import type { TriggerFireContext } from '../../domain/triggers';
+import { RUN_SCHEMA_VERSION, type RunRecordV3 } from '../../domain/events';
+import type { StoragePort } from '../storage/storage-port';
+import type { EventsBus } from '../transport/events-bus';
+import type { RunScheduler } from './scheduler';
+
+// ==================== Types ====================
+
+/**
+ * 入队服务依赖
+ */
+export interface EnqueueRunDeps {
+ /** 存储层 (仅需 flows/runs/queue) */
+ storage: Pick;
+ /** 事件总线 */
+ events: Pick;
+ /** 调度器 (可选) */
+ scheduler?: Pick;
+ /** RunId 生成器 (用于测试注入) */
+ generateRunId?: () => RunId;
+ /** 时间源 (用于测试注入) */
+ now?: () => UnixMillis;
+}
+
+/**
+ * 入队请求参数
+ */
+export interface EnqueueRunInput {
+ /** Flow ID (必选) */
+ flowId: FlowId;
+ /** 起始节点 ID (可选,默认使用 Flow 的 entryNodeId) */
+ startNodeId?: NodeId;
+ /** 优先级 (默认 0) */
+ priority?: number;
+ /** 最大尝试次数 (默认 1) */
+ maxAttempts?: number;
+ /** 传递给 Flow 的参数 */
+ args?: JsonObject;
+ /** 触发上下文 (由 TriggerManager 设置) */
+ trigger?: TriggerFireContext;
+ /** 调试选项 */
+ debug?: {
+ breakpoints?: NodeId[];
+ pauseOnStart?: boolean;
+ };
+}
+
+/**
+ * 入队结果
+ */
+export interface EnqueueRunResult {
+ /** 新创建的 Run ID */
+ runId: RunId;
+ /** 在队列中的位置 (1-based) */
+ position: number;
+}
+
+// ==================== Utilities ====================
+
+/**
+ * 默认 RunId 生成器
+ */
+function defaultGenerateRunId(): RunId {
+ return `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+}
+
+/**
+ * 校验整数参数
+ */
+function validateInt(
+ value: unknown,
+ defaultValue: number,
+ fieldName: string,
+ opts?: { min?: number; max?: number },
+): number {
+ if (value === undefined || value === null) {
+ return defaultValue;
+ }
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ throw new Error(`${fieldName} must be a finite number`);
+ }
+ const intValue = Math.floor(value);
+ if (opts?.min !== undefined && intValue < opts.min) {
+ throw new Error(`${fieldName} must be >= ${opts.min}`);
+ }
+ if (opts?.max !== undefined && intValue > opts.max) {
+ throw new Error(`${fieldName} must be <= ${opts.max}`);
+ }
+ return intValue;
+}
+
+/**
+ * 计算 Run 在队列中的位置
+ * @description 按调度顺序: priority DESC + createdAt ASC
+ * @returns 1-based position, or -1 if run not found in queued items
+ *
+ * Note: Due to race conditions (scheduler may claim the run before this is called),
+ * position may be -1. Callers should handle this gracefully.
+ */
+async function computeQueuePosition(
+ storage: Pick,
+ runId: RunId,
+): Promise {
+ const queueItems = await storage.queue.list('queued');
+ queueItems.sort((a, b) => {
+ if (a.priority !== b.priority) return b.priority - a.priority;
+ return a.createdAt - b.createdAt;
+ });
+ const index = queueItems.findIndex((item) => item.id === runId);
+ // Return -1 if not found (run may have been claimed already)
+ return index === -1 ? -1 : index + 1;
+}
+
+// ==================== Main Function ====================
+
+/**
+ * 入队执行一个 Run
+ * @description
+ * 执行步骤:
+ * 1. 参数校验
+ * 2. 验证 Flow 存在
+ * 3. 创建 RunRecordV3 (status=queued)
+ * 4. 入队到 RunQueue
+ * 5. 发布 run.queued 事件
+ * 6. 触发调度 (best-effort)
+ * 7. 计算队列位置
+ */
+export async function enqueueRun(
+ deps: EnqueueRunDeps,
+ input: EnqueueRunInput,
+): Promise {
+ const { flowId } = input;
+ if (!flowId) {
+ throw new Error('flowId is required');
+ }
+
+ const now = deps.now ?? (() => Date.now());
+ const generateRunId = deps.generateRunId ?? defaultGenerateRunId;
+
+ // 参数校验
+ const priority = validateInt(input.priority, 0, 'priority');
+ const maxAttempts = validateInt(input.maxAttempts, 1, 'maxAttempts', { min: 1 });
+
+ // 验证 Flow 存在
+ const flow = await deps.storage.flows.get(flowId);
+ if (!flow) {
+ throw new Error(`Flow "${flowId}" not found`);
+ }
+
+ // 验证 startNodeId 存在于 Flow 中
+ if (input.startNodeId) {
+ const nodeExists = flow.nodes.some((n) => n.id === input.startNodeId);
+ if (!nodeExists) {
+ throw new Error(`startNodeId "${input.startNodeId}" not found in flow "${flowId}"`);
+ }
+ }
+
+ const ts = now();
+ const runId = generateRunId();
+
+ // 1. 创建 RunRecordV3
+ const runRecord: RunRecordV3 = {
+ schemaVersion: RUN_SCHEMA_VERSION,
+ id: runId,
+ flowId,
+ status: 'queued',
+ createdAt: ts,
+ updatedAt: ts,
+ attempt: 0,
+ maxAttempts,
+ args: input.args,
+ trigger: input.trigger,
+ debug: input.debug,
+ startNodeId: input.startNodeId,
+ nextSeq: 0,
+ };
+ await deps.storage.runs.save(runRecord);
+
+ // 2. 入队
+ await deps.storage.queue.enqueue({
+ id: runId,
+ flowId,
+ priority,
+ maxAttempts,
+ args: input.args,
+ trigger: input.trigger,
+ debug: input.debug,
+ });
+
+ // 3. 发布 run.queued 事件
+ await deps.events.append({
+ runId,
+ type: 'run.queued',
+ flowId,
+ });
+
+ // 4. 计算队列位置 (在 kick 之前计算,减少竞态条件导致 position=-1 的概率)
+ const position = await computeQueuePosition(deps.storage, runId);
+
+ // 5. 触发调度 (best-effort, 不阻塞返回)
+ if (deps.scheduler) {
+ void deps.scheduler.kick();
+ }
+
+ return { runId, position };
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/index.ts
new file mode 100644
index 00000000..2d3b052f
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/index.ts
@@ -0,0 +1,8 @@
+/**
+ * @fileoverview Queue 模块导出入口
+ */
+
+export * from './queue';
+export * from './leasing';
+export * from './scheduler';
+export * from './enqueue-run';
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/leasing.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/leasing.ts
new file mode 100644
index 00000000..9925ee15
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/leasing.ts
@@ -0,0 +1,113 @@
+/**
+ * @fileoverview 租约管理
+ * @description 管理 Run 的租约续约和过期回收
+ */
+
+import type { UnixMillis } from '../../domain/json';
+import type { RunId } from '../../domain/ids';
+import type { RunQueue, RunQueueConfig, Lease } from './queue';
+
+/**
+ * 租约管理器
+ * @description 管理租约续约和过期检测
+ */
+export interface LeaseManager {
+ /**
+ * 开始心跳
+ * @param ownerId 持有者 ID
+ */
+ startHeartbeat(ownerId: string): void;
+
+ /**
+ * 停止心跳
+ * @param ownerId 持有者 ID
+ */
+ stopHeartbeat(ownerId: string): void;
+
+ /**
+ * 检查并回收过期租约
+ * @param now 当前时间
+ * @returns 被回收的 Run ID 列表
+ */
+ reclaimExpiredLeases(now: UnixMillis): Promise;
+
+ /**
+ * 判断租约是否过期
+ */
+ isLeaseExpired(lease: Lease, now: UnixMillis): boolean;
+
+ /**
+ * 创建新租约
+ */
+ createLease(ownerId: string, now: UnixMillis): Lease;
+
+ /**
+ * 停止所有心跳
+ */
+ dispose(): void;
+}
+
+/**
+ * 创建租约管理器
+ */
+export function createLeaseManager(queue: RunQueue, config: RunQueueConfig): LeaseManager {
+ const heartbeatTimers = new Map>();
+
+ return {
+ startHeartbeat(ownerId: string): void {
+ // 如果已有定时器,先停止
+ this.stopHeartbeat(ownerId);
+
+ // 创建新的心跳定时器
+ const timer = setInterval(async () => {
+ try {
+ await queue.heartbeat(ownerId, Date.now());
+ } catch (error) {
+ console.error(`[LeaseManager] Heartbeat failed for ${ownerId}:`, error);
+ }
+ }, config.heartbeatIntervalMs);
+
+ heartbeatTimers.set(ownerId, timer);
+ },
+
+ stopHeartbeat(ownerId: string): void {
+ const timer = heartbeatTimers.get(ownerId);
+ if (timer) {
+ clearInterval(timer);
+ heartbeatTimers.delete(ownerId);
+ }
+ },
+
+ async reclaimExpiredLeases(now: UnixMillis): Promise {
+ // Delegate to the queue implementation which uses the lease_expiresAt index
+ // for efficient scanning and updates storage atomically.
+ return queue.reclaimExpiredLeases(now);
+ },
+
+ isLeaseExpired(lease: Lease, now: UnixMillis): boolean {
+ return lease.expiresAt < now;
+ },
+
+ createLease(ownerId: string, now: UnixMillis): Lease {
+ return {
+ ownerId,
+ expiresAt: now + config.leaseTtlMs,
+ };
+ },
+
+ dispose(): void {
+ for (const timer of heartbeatTimers.values()) {
+ clearInterval(timer);
+ }
+ heartbeatTimers.clear();
+ },
+ };
+}
+
+/**
+ * 生成唯一的 owner ID
+ * @description 用于标识当前 Service Worker 实例
+ */
+export function generateOwnerId(): string {
+ return `sw_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/queue.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/queue.ts
new file mode 100644
index 00000000..ec8fd2c3
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/queue.ts
@@ -0,0 +1,199 @@
+/**
+ * @fileoverview RunQueue 接口定义
+ * @description 定义 Run 队列的管理接口
+ */
+
+import type { JsonObject, UnixMillis } from '../../domain/json';
+import type { FlowId, NodeId, RunId } from '../../domain/ids';
+import type { TriggerFireContext } from '../../domain/triggers';
+
+/**
+ * RunQueue 配置
+ */
+export interface RunQueueConfig {
+ /** 最大并行 Run 数量 */
+ maxParallelRuns: number;
+ /** 租约 TTL(毫秒) */
+ leaseTtlMs: number;
+ /** 心跳间隔(毫秒) */
+ heartbeatIntervalMs: number;
+}
+
+/**
+ * 默认队列配置
+ */
+export const DEFAULT_QUEUE_CONFIG: RunQueueConfig = {
+ maxParallelRuns: 3,
+ leaseTtlMs: 15_000,
+ heartbeatIntervalMs: 5_000,
+};
+
+/**
+ * 队列项状态
+ */
+export type QueueItemStatus = 'queued' | 'running' | 'paused';
+
+/**
+ * 租约信息
+ */
+export interface Lease {
+ /** 持有者 ID */
+ ownerId: string;
+ /** 过期时间 */
+ expiresAt: UnixMillis;
+}
+
+/**
+ * RunQueue 队列项
+ */
+export interface RunQueueItem {
+ /** Run ID */
+ id: RunId;
+ /** Flow ID */
+ flowId: FlowId;
+ /** 状态 */
+ status: QueueItemStatus;
+ /** 创建时间 */
+ createdAt: UnixMillis;
+ /** 更新时间 */
+ updatedAt: UnixMillis;
+ /** 优先级(数字越大优先级越高) */
+ priority: number;
+ /** 当前尝试次数 */
+ attempt: number;
+ /** 最大尝试次数 */
+ maxAttempts: number;
+ /** Tab ID */
+ tabId?: number;
+ /** 运行参数 */
+ args?: JsonObject;
+ /** 触发器上下文 */
+ trigger?: TriggerFireContext;
+ /** 租约信息 */
+ lease?: Lease;
+ /** 调试配置 */
+ debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean };
+}
+
+/**
+ * 入队请求(不含自动生成的字段)
+ * - priority 默认为 0
+ * - maxAttempts 默认为 1
+ */
+export type EnqueueInput = Omit<
+ RunQueueItem,
+ 'status' | 'createdAt' | 'updatedAt' | 'attempt' | 'lease' | 'priority' | 'maxAttempts'
+> & {
+ id: RunId;
+ /** 优先级(数字越大优先级越高,默认 0) */
+ priority?: number;
+ /** 最大尝试次数(默认 1) */
+ maxAttempts?: number;
+};
+
+/**
+ * RunQueue 接口
+ * @description 管理 Run 的队列和调度
+ */
+export interface RunQueue {
+ /**
+ * 入队
+ * @param input 入队请求
+ * @returns 队列项
+ */
+ enqueue(input: EnqueueInput): Promise;
+
+ /**
+ * 领取下一个可执行的 Run
+ * @param ownerId 领取者 ID
+ * @param now 当前时间
+ * @returns 队列项或 null
+ */
+ claimNext(ownerId: string, now: UnixMillis): Promise;
+
+ /**
+ * 续约心跳
+ * @param ownerId 领取者 ID
+ * @param now 当前时间
+ */
+ heartbeat(ownerId: string, now: UnixMillis): Promise;
+
+ /**
+ * 回收过期租约
+ * @description 将 lease.expiresAt < now 的 running/paused 项回收为 queued
+ * @param now 当前时间
+ * @returns 被回收的 Run ID 列表
+ */
+ reclaimExpiredLeases(now: UnixMillis): Promise;
+
+ /**
+ * 恢复孤儿租约(SW 重启后调用)
+ * @description
+ * - 将孤儿 running 项回收为 queued(status -> queued,租约清除)
+ * - 将孤儿 paused 项接管(保持 status=paused,租约 ownerId 更新为新 ownerId)
+ * @param ownerId 新的 ownerId(当前 Service Worker 实例)
+ * @param now 当前时间
+ * @returns 受影响的 runId 列表(含原 ownerId 用于审计)
+ */
+ recoverOrphanLeases(
+ ownerId: string,
+ now: UnixMillis,
+ ): Promise<{
+ requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }>;
+ adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }>;
+ }>;
+
+ /**
+ * 标记为 running
+ */
+ markRunning(runId: RunId, ownerId: string, now: UnixMillis): Promise;
+
+ /**
+ * 标记为 paused
+ */
+ markPaused(runId: RunId, ownerId: string, now: UnixMillis): Promise;
+
+ /**
+ * 标记为完成(从队列移除)
+ */
+ markDone(runId: RunId, now: UnixMillis): Promise;
+
+ /**
+ * 取消 Run
+ */
+ cancel(runId: RunId, now: UnixMillis, reason?: string): Promise;
+
+ /**
+ * 获取队列项
+ */
+ get(runId: RunId): Promise;
+
+ /**
+ * 列出队列项
+ */
+ list(status?: QueueItemStatus): Promise;
+}
+
+/**
+ * 创建 NotImplemented 的 RunQueue
+ * @description Phase 0 占位实现
+ */
+export function createNotImplementedQueue(): RunQueue {
+ const notImplemented = () => {
+ throw new Error('RunQueue not implemented');
+ };
+
+ return {
+ enqueue: async () => notImplemented(),
+ claimNext: async () => notImplemented(),
+ heartbeat: async () => notImplemented(),
+ reclaimExpiredLeases: async () => notImplemented(),
+ recoverOrphanLeases: async () => notImplemented(),
+ markRunning: async () => notImplemented(),
+ markPaused: async () => notImplemented(),
+ markDone: async () => notImplemented(),
+ cancel: async () => notImplemented(),
+ get: async () => notImplemented(),
+ list: async () => notImplemented(),
+ };
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/scheduler.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/scheduler.ts
new file mode 100644
index 00000000..75da4b68
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/scheduler.ts
@@ -0,0 +1,336 @@
+/**
+ * @fileoverview RunQueue scheduler (maxParallelRuns)
+ * @description
+ * Orchestrates atomic claims from RunQueue and launches execution with an injected executor.
+ *
+ * Responsibilities:
+ * - Enforce maxParallelRuns (per scheduler instance)
+ * - Backfill available slots when runs complete
+ * - Periodically reclaim expired leases (best-effort)
+ * - Start/stop lease heartbeats via LeaseManager
+ * - Acquire/release keepalive to prevent MV3 SW termination (P3-05)
+ *
+ * Non-responsibilities:
+ * - Run execution details (Flow loading, tab allocation, etc.) are injected via RunExecutor
+ */
+
+import type { UnixMillis } from '../../domain/json';
+import type { RunId } from '../../domain/ids';
+import type { LeaseManager } from './leasing';
+import type { RunQueue, RunQueueConfig, RunQueueItem } from './queue';
+import type { KeepaliveController } from '../keepalive/offscreen-keepalive';
+
+// ==================== Types ====================
+
+/**
+ * Run executor contract:
+ * - Resolve when the run reaches a terminal state (succeeded/failed/canceled).
+ * - Throw/reject only for unexpected infrastructure errors.
+ */
+export type RunExecutor = (item: RunQueueItem) => Promise;
+
+/**
+ * Scheduler tuning parameters
+ */
+export interface RunSchedulerTuning {
+ /**
+ * Poll interval for queue consumption fallback.
+ * Set to 0 to disable polling (kick-only).
+ */
+ pollIntervalMs?: number;
+
+ /**
+ * Minimum interval between lease reclaim scans.
+ * Set to 0 to disable periodic reclaim (not recommended in production).
+ */
+ reclaimIntervalMs?: number;
+}
+
+/**
+ * Scheduler dependencies (dependency injection)
+ */
+export interface RunSchedulerDeps {
+ queue: Pick;
+ leaseManager: Pick;
+ keepalive: Pick;
+ config: RunQueueConfig;
+ ownerId: string;
+ execute: RunExecutor;
+ now?: () => UnixMillis;
+ tuning?: RunSchedulerTuning;
+ logger?: Pick;
+}
+
+/**
+ * Scheduler state for inspection
+ */
+export interface RunSchedulerState {
+ started: boolean;
+ ownerId: string;
+ maxParallelRuns: number;
+ activeRunIds: RunId[];
+}
+
+/**
+ * Scheduler interface
+ */
+export interface RunScheduler {
+ /** Start the scheduler */
+ start(): void;
+ /** Stop the scheduler */
+ stop(): void;
+ /**
+ * Trigger a scheduling pass.
+ * Safe to call frequently; re-entrancy is coalesced.
+ */
+ kick(): Promise;
+ /** Get current state */
+ getState(): RunSchedulerState;
+ /** Dispose the scheduler */
+ dispose(): void;
+}
+
+// ==================== Constants ====================
+
+const DEFAULT_POLL_INTERVAL_MS = 500;
+
+// ==================== Helpers ====================
+
+function clampNonNegativeInt(value: unknown, fallback: number): number {
+ const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback;
+ return Math.max(0, n);
+}
+
+function defaultReclaimIntervalMs(leaseTtlMs: number): number {
+ const ttl = clampNonNegativeInt(leaseTtlMs, 0);
+ // Reclaim at most every ~TTL/2, but never less than 1s to avoid tight loops.
+ return Math.max(1_000, Math.floor(ttl / 2));
+}
+
+// ==================== Factory ====================
+
+/**
+ * Create a RunScheduler
+ *
+ * Scheduling model:
+ * - Concurrency is enforced by an in-memory set of active runIds.
+ * - Ordering is delegated to RunQueue.claimNext() (priority DESC, createdAt ASC).
+ *
+ * MV3 Service Worker may be suspended/restarted, so we use a "kick + polling" strategy:
+ * - kick: Immediate scheduling trigger on enqueue/completion (low latency)
+ * - polling: Fallback to ensure queue is consumed even if caller forgets to kick
+ */
+export function createRunScheduler(deps: RunSchedulerDeps): RunScheduler {
+ const logger = deps.logger ?? console;
+
+ if (!deps.ownerId) {
+ throw new Error('ownerId is required');
+ }
+
+ const now = deps.now ?? (() => Date.now());
+ const maxParallelRuns = clampNonNegativeInt(deps.config.maxParallelRuns, 0);
+ const pollIntervalMs = clampNonNegativeInt(
+ deps.tuning?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
+ DEFAULT_POLL_INTERVAL_MS,
+ );
+ const reclaimIntervalMs = clampNonNegativeInt(
+ deps.tuning?.reclaimIntervalMs ?? defaultReclaimIntervalMs(deps.config.leaseTtlMs),
+ defaultReclaimIntervalMs(deps.config.leaseTtlMs),
+ );
+
+ let started = false;
+ let pollTimer: ReturnType | null = null;
+ let releaseKeepalive: (() => void) | null = null;
+
+ const activeRunIds = new Set();
+
+ // Coalesced re-entrancy control for tick()
+ let pendingKick = false;
+ let pumpPromise: Promise | null = null;
+
+ let lastReclaimAt: UnixMillis | null = null;
+
+ /**
+ * Single scheduling tick:
+ * 1. Reclaim expired leases (if interval elapsed)
+ * 2. Fill available slots up to maxParallelRuns
+ */
+ async function tick(): Promise {
+ const t = now();
+
+ // Best-effort lease reclaim (disabled when reclaimIntervalMs === 0)
+ if (reclaimIntervalMs > 0) {
+ const shouldReclaim = lastReclaimAt === null || t - lastReclaimAt >= reclaimIntervalMs;
+ if (shouldReclaim) {
+ lastReclaimAt = t;
+ try {
+ await deps.leaseManager.reclaimExpiredLeases(t);
+ } catch (e) {
+ logger.warn('[RunScheduler] reclaimExpiredLeases failed:', e);
+ }
+ }
+ }
+
+ // Fill available slots up to maxParallelRuns
+ //
+ // Note: `stop()` can be called while an async claim is in-flight. Guard the loop
+ // with `started` to prevent claiming additional items after stop is requested.
+ while (started && activeRunIds.size < maxParallelRuns) {
+ let claimed: RunQueueItem | null = null;
+ try {
+ claimed = await deps.queue.claimNext(deps.ownerId, t);
+ } catch (e) {
+ logger.error('[RunScheduler] claimNext failed:', e);
+ return;
+ }
+
+ if (!claimed) return;
+
+ // Guard against double-launch within the same scheduler instance
+ if (activeRunIds.has(claimed.id)) {
+ logger.error(
+ `[RunScheduler] Invariant violation: run "${claimed.id}" was claimed twice in the same scheduler instance`,
+ );
+ // Best-effort cleanup: avoid a stuck running entry
+ void deps.queue
+ .markDone(claimed.id, now())
+ .catch((err) =>
+ logger.warn('[RunScheduler] markDone after duplicate claim failed:', err),
+ );
+ continue;
+ }
+
+ activeRunIds.add(claimed.id);
+
+ // Capture claimed item for the closure
+ const claimedItem = claimed;
+
+ const runPromise = Promise.resolve()
+ .then(() => deps.execute(claimedItem))
+ .catch((e) => {
+ // If execution failed unexpectedly, log but still cleanup
+ logger.error(`[RunScheduler] execute failed for run "${claimedItem.id}":`, e);
+ })
+ .finally(async () => {
+ activeRunIds.delete(claimedItem.id);
+ try {
+ await deps.queue.markDone(claimedItem.id, now());
+ } catch (e) {
+ logger.warn(`[RunScheduler] markDone failed for run "${claimedItem.id}":`, e);
+ }
+
+ // Backfill immediately when a slot frees up
+ if (started) {
+ void kick();
+ }
+ });
+
+ // Ensure no floating promise warnings
+ void runPromise;
+ }
+ }
+
+ /**
+ * Pump loop: keeps running while pendingKick is set
+ */
+ async function pump(): Promise {
+ try {
+ while (started && pendingKick) {
+ pendingKick = false;
+ try {
+ await tick();
+ } catch (e) {
+ logger.error('[RunScheduler] tick failed:', e);
+ }
+ }
+ } finally {
+ pumpPromise = null;
+ }
+ }
+
+ function start(): void {
+ if (started) return;
+ started = true;
+
+ // Acquire keepalive to prevent MV3 SW termination
+ try {
+ releaseKeepalive = deps.keepalive.acquire('scheduler');
+ } catch (e) {
+ logger.warn('[RunScheduler] keepalive.acquire failed:', e);
+ releaseKeepalive = null;
+ }
+
+ try {
+ deps.leaseManager.startHeartbeat(deps.ownerId);
+ } catch (e) {
+ logger.warn('[RunScheduler] startHeartbeat failed:', e);
+ }
+
+ if (pollIntervalMs > 0) {
+ pollTimer = setInterval(() => {
+ void kick();
+ }, pollIntervalMs);
+ }
+
+ void kick();
+ }
+
+ function stop(): void {
+ if (!started) return;
+
+ if (activeRunIds.size > 0) {
+ logger.warn(
+ `[RunScheduler] stop() called with ${activeRunIds.size} active runs; heartbeats will stop and leases may expire/reclaim concurrently`,
+ );
+ }
+
+ started = false;
+
+ if (pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ }
+
+ try {
+ deps.leaseManager.stopHeartbeat(deps.ownerId);
+ } catch (e) {
+ logger.warn('[RunScheduler] stopHeartbeat failed:', e);
+ }
+
+ // Release keepalive
+ if (releaseKeepalive) {
+ try {
+ releaseKeepalive();
+ } catch (e) {
+ logger.warn('[RunScheduler] keepalive release failed:', e);
+ }
+ releaseKeepalive = null;
+ }
+ }
+
+ function kick(): Promise {
+ if (!started) return Promise.resolve();
+
+ pendingKick = true;
+ if (!pumpPromise) {
+ pumpPromise = pump();
+ }
+ return pumpPromise;
+ }
+
+ function getState(): RunSchedulerState {
+ return {
+ started,
+ ownerId: deps.ownerId,
+ maxParallelRuns,
+ activeRunIds: Array.from(activeRunIds),
+ };
+ }
+
+ function dispose(): void {
+ stop();
+ activeRunIds.clear();
+ }
+
+ return { start, stop, kick, getState, dispose };
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/index.ts
new file mode 100644
index 00000000..2e5bba36
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/index.ts
@@ -0,0 +1,6 @@
+/**
+ * @fileoverview Recovery module exports
+ * @description 崩溃恢复模块导出
+ */
+
+export * from './recovery-coordinator';
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/recovery-coordinator.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/recovery-coordinator.ts
new file mode 100644
index 00000000..45d9c7c2
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/recovery-coordinator.ts
@@ -0,0 +1,260 @@
+/**
+ * @fileoverview 崩溃恢复协调器 (P3-06)
+ * @description
+ * MV3 Service Worker 可能随时被终止。此协调器在 SW 启动时协调队列状态和 Run 记录,
+ * 使中断的 Run 能够被恢复执行。
+ *
+ * 恢复策略:
+ * - 孤儿 running 项:回收为 queued,等待重新调度(从头重跑)
+ * - 孤儿 paused 项:接管 lease,保持 paused 状态
+ * - 已终态 Run 的队列残留:清理
+ *
+ * 调用时机:
+ * - 必须在 scheduler.start() 之前调用
+ * - 通常在 SW 启动时调用一次
+ */
+
+import type { UnixMillis } from '../../domain/json';
+import type { RunId } from '../../domain/ids';
+import { isTerminalStatus, type RunStatus } from '../../domain/events';
+import type { StoragePort } from '../storage/storage-port';
+import type { EventsBus } from '../transport/events-bus';
+
+// ==================== Types ====================
+
+/**
+ * 恢复结果
+ */
+export interface RecoveryResult {
+ /** 被回收为 queued 的 running Run ID */
+ requeuedRunning: RunId[];
+ /** 被接管的 paused Run ID */
+ adoptedPaused: RunId[];
+ /** 被清理的已终态 Run ID */
+ cleanedTerminal: RunId[];
+}
+
+/**
+ * 恢复协调器依赖
+ */
+export interface RecoveryCoordinatorDeps {
+ /** 存储层 */
+ storage: StoragePort;
+ /** 事件总线 */
+ events: EventsBus;
+ /** 当前 Service Worker 的 ownerId */
+ ownerId: string;
+ /** 时间源 */
+ now: () => UnixMillis;
+ /** 日志器 */
+ logger?: Pick;
+}
+
+// ==================== Main Function ====================
+
+/**
+ * 执行崩溃恢复
+ * @description
+ * 在 SW 启动时调用,协调队列状态和 Run 记录。
+ *
+ * 执行顺序:
+ * 1. 预清理:检查队列中的所有项,清理已终态或无对应 RunRecord 的残留
+ * 2. 恢复孤儿租约:回收 running,接管 paused
+ * 3. 同步 RunRecord 状态:确保 RunRecord 与队列状态一致
+ * 4. 发送恢复事件:为 requeued running 项发送 run.recovered 事件
+ */
+export async function recoverFromCrash(deps: RecoveryCoordinatorDeps): Promise {
+ const logger = deps.logger ?? console;
+
+ if (!deps.ownerId) {
+ throw new Error('ownerId is required');
+ }
+
+ const now = deps.now();
+
+ // 设计理由:恢复过程必须"先清理后接管/回收",否则可能把已经终态的 Run 重新排队执行
+ const cleanedTerminalSet = new Set();
+
+ // ==================== Step 1: 预清理 ====================
+ // 检查队列中的所有项,清理已终态或无对应 RunRecord 的残留
+ try {
+ const items = await deps.storage.queue.list();
+ for (const item of items) {
+ const runId = item.id;
+ const run = await deps.storage.runs.get(runId);
+
+ // 防御性清理:无 RunRecord 的队列项无法执行
+ if (!run) {
+ try {
+ await deps.storage.queue.markDone(runId, now);
+ cleanedTerminalSet.add(runId);
+ logger.debug(`[Recovery] Cleaned orphan queue item without RunRecord: ${runId}`);
+ } catch (e) {
+ logger.warn('[Recovery] markDone for missing RunRecord failed:', runId, e);
+ }
+ continue;
+ }
+
+ // 清理已终态的 Run(SW 可能在 runner 完成后、scheduler markDone 前崩溃)
+ if (isTerminalStatus(run.status)) {
+ try {
+ await deps.storage.queue.markDone(runId, now);
+ cleanedTerminalSet.add(runId);
+ logger.debug(`[Recovery] Cleaned terminal queue item: ${runId} (status=${run.status})`);
+ } catch (e) {
+ logger.warn('[Recovery] markDone for terminal run failed:', runId, e);
+ }
+ }
+ }
+ } catch (e) {
+ logger.warn('[Recovery] Pre-clean failed:', e);
+ }
+
+ // ==================== Step 2: 恢复孤儿租约 ====================
+ // Best-effort:即使失败也不应该阻止启动
+ let requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }> = [];
+ let adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }> = [];
+ try {
+ const result = await deps.storage.queue.recoverOrphanLeases(deps.ownerId, now);
+ requeuedRunning = result.requeuedRunning;
+ adoptedPaused = result.adoptedPaused;
+ } catch (e) {
+ logger.error('[Recovery] recoverOrphanLeases failed:', e);
+ // 继续执行,不阻止启动
+ }
+
+ // ==================== Step 3: 同步 RunRecord 状态 ====================
+ const requeuedRunningIds: RunId[] = [];
+ for (const entry of requeuedRunning) {
+ const runId = entry.runId;
+ requeuedRunningIds.push(runId);
+
+ // 跳过在 Step 1 中已清理的项
+ if (cleanedTerminalSet.has(runId)) {
+ continue;
+ }
+
+ try {
+ const run = await deps.storage.runs.get(runId);
+ if (!run) {
+ // RunRecord 不存在,清理队列项(防御性)
+ try {
+ await deps.storage.queue.markDone(runId, now);
+ cleanedTerminalSet.add(runId);
+ } catch (markDoneErr) {
+ logger.warn(
+ '[Recovery] markDone for missing RunRecord in Step3 failed:',
+ runId,
+ markDoneErr,
+ );
+ }
+ continue;
+ }
+
+ // 跳过已终态的 Run(可能在恢复过程中被其他逻辑更新)
+ // 同时清理队列项,防止残留
+ if (isTerminalStatus(run.status)) {
+ try {
+ await deps.storage.queue.markDone(runId, now);
+ cleanedTerminalSet.add(runId);
+ logger.debug(
+ `[Recovery] Cleaned terminal queue item in Step3: ${runId} (status=${run.status})`,
+ );
+ } catch (markDoneErr) {
+ logger.warn('[Recovery] markDone for terminal run in Step3 failed:', runId, markDoneErr);
+ }
+ continue;
+ }
+
+ // 更新 RunRecord 状态为 queued
+ await deps.storage.runs.patch(runId, { status: 'queued', updatedAt: now });
+
+ // 发送恢复事件(best-effort,失败不影响恢复流程)
+ try {
+ const fromStatus: 'running' | 'paused' = run.status === 'paused' ? 'paused' : 'running';
+ await deps.events.append({
+ runId,
+ type: 'run.recovered',
+ reason: 'sw_restart',
+ fromStatus,
+ toStatus: 'queued',
+ prevOwnerId: entry.prevOwnerId,
+ ts: now,
+ });
+ logger.info(`[Recovery] Requeued orphan running run: ${runId} (from=${fromStatus})`);
+ } catch (eventErr) {
+ logger.warn('[Recovery] Failed to emit run.recovered event:', runId, eventErr);
+ // 继续执行,不影响恢复流程
+ }
+ } catch (e) {
+ logger.warn('[Recovery] Reconcile requeued running failed:', runId, e);
+ }
+ }
+
+ // ==================== Step 4: 同步 adopted paused 的 RunRecord ====================
+ const adoptedPausedIds: RunId[] = [];
+ for (const entry of adoptedPaused) {
+ const runId = entry.runId;
+ adoptedPausedIds.push(runId);
+
+ // 跳过在 Step 1 中已清理的项
+ if (cleanedTerminalSet.has(runId)) {
+ continue;
+ }
+
+ try {
+ const run = await deps.storage.runs.get(runId);
+ if (!run) {
+ // RunRecord 不存在,清理队列项(防御性)
+ try {
+ await deps.storage.queue.markDone(runId, now);
+ cleanedTerminalSet.add(runId);
+ } catch (markDoneErr) {
+ logger.warn(
+ '[Recovery] markDone for missing RunRecord in Step4 failed:',
+ runId,
+ markDoneErr,
+ );
+ }
+ continue;
+ }
+
+ // 跳过已终态的 Run,同时清理队列项
+ if (isTerminalStatus(run.status)) {
+ try {
+ await deps.storage.queue.markDone(runId, now);
+ cleanedTerminalSet.add(runId);
+ logger.debug(
+ `[Recovery] Cleaned terminal queue item in Step4: ${runId} (status=${run.status})`,
+ );
+ } catch (markDoneErr) {
+ logger.warn('[Recovery] markDone for terminal run in Step4 failed:', runId, markDoneErr);
+ }
+ continue;
+ }
+
+ // 如果 RunRecord 状态不是 paused,同步更新
+ if (run.status !== 'paused') {
+ await deps.storage.runs.patch(runId, { status: 'paused' as RunStatus, updatedAt: now });
+ }
+
+ logger.info(`[Recovery] Adopted orphan paused run: ${runId}`);
+ } catch (e) {
+ logger.warn('[Recovery] Reconcile adopted paused failed:', runId, e);
+ }
+ }
+
+ const result: RecoveryResult = {
+ requeuedRunning: requeuedRunningIds,
+ adoptedPaused: adoptedPausedIds,
+ cleanedTerminal: Array.from(cleanedTerminalSet),
+ };
+
+ logger.info('[Recovery] Complete:', {
+ requeuedRunning: result.requeuedRunning.length,
+ adoptedPaused: result.adoptedPaused.length,
+ cleanedTerminal: result.cleanedTerminal.length,
+ });
+
+ return result;
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/index.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/index.ts
new file mode 100644
index 00000000..df3f3a7c
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/index.ts
@@ -0,0 +1,5 @@
+/**
+ * @fileoverview Engine Storage 模块导出入口
+ */
+
+export * from './storage-port';
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/storage-port.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/storage-port.ts
new file mode 100644
index 00000000..0ab9df65
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/storage-port.ts
@@ -0,0 +1,144 @@
+/**
+ * @fileoverview StoragePort 接口定义
+ * @description 定义 Storage 层的抽象接口,用于依赖注入
+ */
+
+import type { FlowId, RunId, TriggerId } from '../../domain/ids';
+import type { FlowV3 } from '../../domain/flow';
+import type { RunEvent, RunEventInput, RunRecordV3 } from '../../domain/events';
+import type { PersistentVarRecord, PersistentVariableName } from '../../domain/variables';
+import type { TriggerSpec } from '../../domain/triggers';
+import type { RunQueue } from '../queue/queue';
+
+/**
+ * FlowsStore 接口
+ */
+export interface FlowsStore {
+ /** 列出所有 Flow */
+ list(): Promise;
+ /** 获取单个 Flow */
+ get(id: FlowId): Promise;
+ /** 保存 Flow */
+ save(flow: FlowV3): Promise;
+ /** 删除 Flow */
+ delete(id: FlowId): Promise;
+}
+
+/**
+ * RunsStore 接口
+ */
+export interface RunsStore {
+ /** 列出所有 Run 记录 */
+ list(): Promise;
+ /** 获取单个 Run 记录 */
+ get(id: RunId): Promise;
+ /** 保存 Run 记录 */
+ save(record: RunRecordV3): Promise;
+ /** 部分更新 Run 记录 */
+ patch(id: RunId, patch: Partial): Promise;
+}
+
+/**
+ * EventsStore 接口
+ * @description seq 分配必须由 append() 内部原子完成
+ */
+export interface EventsStore {
+ /**
+ * 追加事件并原子分配 seq
+ * @description 在单个事务中:读取 RunRecordV3.nextSeq -> 写入事件 -> 递增 nextSeq
+ * @param event 事件输入(不含 seq)
+ * @returns 完整事件(含分配的 seq 和 ts)
+ */
+ append(event: RunEventInput): Promise;
+
+ /**
+ * 列出事件
+ * @param runId Run ID
+ * @param opts 查询选项
+ */
+ list(runId: RunId, opts?: { fromSeq?: number; limit?: number }): Promise;
+}
+
+/**
+ * PersistentVarsStore 接口
+ */
+export interface PersistentVarsStore {
+ /** 获取持久化变量 */
+ get(key: PersistentVariableName): Promise;
+ /** 设置持久化变量 */
+ set(
+ key: PersistentVariableName,
+ value: PersistentVarRecord['value'],
+ ): Promise;
+ /** 删除持久化变量 */
+ delete(key: PersistentVariableName): Promise;
+ /** 列出持久化变量 */
+ list(prefix?: PersistentVariableName): Promise;
+}
+
+/**
+ * TriggersStore 接口
+ */
+export interface TriggersStore {
+ /** 列出所有触发器 */
+ list(): Promise;
+ /** 获取单个触发器 */
+ get(id: TriggerId): Promise;
+ /** 保存触发器 */
+ save(spec: TriggerSpec): Promise;
+ /** 删除触发器 */
+ delete(id: TriggerId): Promise;
+}
+
+/**
+ * StoragePort 接口
+ * @description 聚合所有存储接口,用于依赖注入
+ */
+export interface StoragePort {
+ /** Flows 存储 */
+ flows: FlowsStore;
+ /** Runs 存储 */
+ runs: RunsStore;
+ /** Events 存储 */
+ events: EventsStore;
+ /** Queue 存储 */
+ queue: RunQueue;
+ /** 持久化变量存储 */
+ persistentVars: PersistentVarsStore;
+ /** 触发器存储 */
+ triggers: TriggersStore;
+}
+
+/**
+ * 创建 NotImplemented 的 Store
+ * @description 避免 Proxy 生成 'then' 导致 thenable 行为
+ */
+function createNotImplementedStore(name: string): T {
+ const target = {} as T;
+ return new Proxy(target, {
+ get(_, prop) {
+ // Avoid thenable behavior by returning undefined for 'then'
+ if (prop === 'then') {
+ return undefined;
+ }
+ return async () => {
+ throw new Error(`${name}.${String(prop)} not implemented`);
+ };
+ },
+ });
+}
+
+/**
+ * 创建 NotImplemented 的 StoragePort
+ * @description Phase 0 占位实现
+ */
+export function createNotImplementedStoragePort(): StoragePort {
+ return {
+ flows: createNotImplementedStore('FlowsStore'),
+ runs: createNotImplementedStore('RunsStore'),
+ events: createNotImplementedStore('EventsStore'),
+ queue: createNotImplementedStore('RunQueue'),
+ persistentVars: createNotImplementedStore('PersistentVarsStore'),
+ triggers: createNotImplementedStore('TriggersStore'),
+ };
+}
diff --git a/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/events-bus.ts b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/events-bus.ts
new file mode 100644
index 00000000..2ecd93d0
--- /dev/null
+++ b/app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/events-bus.ts
@@ -0,0 +1,222 @@
+/**
+ * @fileoverview EventsBus Interface and Implementation
+ * @description Event subscription, publishing, and persistence
+ */
+
+import type { RunId } from '../../domain/ids';
+import type { RunEvent, RunEventInput, Unsubscribe } from '../../domain/events';
+import type { EventsStore } from '../storage/storage-port';
+
+/**
+ * Event query parameters
+ */
+export interface EventsQuery {
+ /** Run ID */
+ runId: RunId;
+ /** Starting sequence number (inclusive) */
+ fromSeq?: number;
+ /** Maximum number of results */
+ limit?: number;
+}
+
+/**
+ * Subscription filter
+ */
+export interface EventsFilter {
+ /** Only receive events for this Run */
+ runId?: RunId;
+}
+
+/**
+ * EventsBus Interface
+ * @description Responsible for event subscription, publishing, and persistence
+ */
+export interface EventsBus {
+ /**
+ * Subscribe to events
+ * @param listener Event listener
+ * @param filter Optional filter
+ * @returns Unsubscribe function
+ */
+ subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe;
+
+ /**
+ * Append event
+ * @description Delegates to EventsStore for atomic seq allocation, then broadcasts
+ * @param event Event input (without seq)
+ * @returns Complete event (with seq and ts)
+ */
+ append(event: RunEventInput): Promise;
+
+ /**
+ * Query historical events
+ * @param query Query parameters
+ * @returns Events sorted by seq ascending
+ */
+ list(query: EventsQuery): Promise;
+}
+
+/**
+ * Create NotImplemented EventsBus
+ * @description Phase 0 placeholder
+ */
+export function createNotImplementedEventsBus(): EventsBus {
+ const notImplemented = () => {
+ throw new Error('EventsBus not implemented');
+ };
+
+ return {
+ subscribe: () => {
+ notImplemented();
+ return () => {};
+ },
+ append: async () => notImplemented(),
+ list: async () => notImplemented(),
+ };
+}
+
+/**
+ * Listener entry for subscription management
+ */
+interface ListenerEntry {
+ listener: (event: RunEvent) => void;
+ filter?: EventsFilter;
+}
+
+/**
+ * Storage-backed EventsBus Implementation
+ * @description
+ * - seq allocation is done by EventsStore.append() (atomic with RunRecordV3.nextSeq)
+ * - broadcast happens only after append resolves (i.e. after commit)
+ */
+export class StorageBackedEventsBus implements EventsBus {
+ private listeners = new Set();
+
+ constructor(private readonly store: EventsStore) {}
+
+ subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe {
+ const entry: ListenerEntry = { listener, filter };
+ this.listeners.add(entry);
+ return () => {
+ this.listeners.delete(entry);
+ };
+ }
+
+ async append(input: RunEventInput): Promise