baa-conductor

git clone 

commit
73a70f9
parent
667fc6a
author
im_wower
date
2026-03-24 08:33:20 +0800 CST
merge: land BRW002 claude page automation http
2 files changed,  +1120, -120
M docs/firefox/README.md
+225, -96
  1@@ -1,62 +1,219 @@
  2 # Firefox Control Protocol
  3 
  4-当前 Firefox 插件实现代码已经直接收口到本仓库子目录:
  5+当前 Firefox 插件代码在:
  6 
  7 - [`../../plugins/baa-firefox/`](../../plugins/baa-firefox/)
  8 
  9-这里保留 Firefox 插件与 `baa-conductor` 的最小接入约定。
 10+这份文档只描述当前 `baa-conductor` 里实际支持的 Firefox 插件能力。
 11 
 12-## 当前固定入口
 13+## 当前范围
 14+
 15+- 当前对外支持的页面 HTTP 代理能力只支持 `Claude`
 16+- 主线是:
 17+  - 捕获真实 Claude 页面请求里的凭证
 18+  - 发现真实 Claude API endpoint
 19+  - 在页面上下文里用真实 cookie / csrf / endpoint 直接代发 HTTP
 20+- 不支持 ChatGPT / Gemini 的页面自动化协议
 21+- 不把“找输入框、填 prompt、点击发送”作为主方案
 22+
 23+当前仓库里仍保留多平台的被动观测代码,但对后续服务端承诺的 runtime message 只针对 `Claude`。
 24+
 25+## 固定入口
 26 
 27 - Local WS bridge: `ws://100.71.210.78:4317/ws/firefox`
 28 - Local HTTP host: `http://100.71.210.78:4317`
 29 
 30-当前插件默认同时使用这两条链路:
 31+当前插件默认同时使用两条链路:
 32+
 33+- 本地 WS:负责 Firefox bridge 握手、浏览器元数据同步、服务端 `api_request` / `api_response`
 34+- 本地 HTTP:负责控制面状态同步,以及 `pause` / `resume` / `drain`
 35+
 36+插件管理页不再允许手工编辑地址。
 37+
 38+## Claude HTTP-First 设计
 39+
 40+Claude 页面能力建立在三层信息上:
 41+
 42+1. 凭证抓取
 43+   - `controller.js` 通过 `webRequest.onBeforeSendHeaders` 抓真实请求头
 44+   - 当前会保存 Claude 的 `cookie`、`x-csrf-token`、`anthropic-client-*`
 45+   - Claude 的 `org-id` 优先从请求 URL 提取,也会保存在凭证快照里
 46+
 47+2. Endpoint 发现
 48+   - `page-interceptor.js` 和 `webRequest` 会把真实访问过的 Claude API 路径收集到 `endpoints`
 49+   - 当前主线依赖的核心路径是:
 50+     - `POST /api/organizations/{orgId}/chat_conversations`
 51+     - `GET /api/organizations/{orgId}/chat_conversations/{convId}`
 52+     - `POST /api/organizations/{orgId}/chat_conversations/{convId}/completion`
 53+
 54+3. 页面内 HTTP 代理
 55+   - `controller.js` 发 `baa_page_proxy_request`
 56+   - `content-script.js` 把请求转发到页面上下文
 57+   - `page-interceptor.js` 在 Claude 页面里执行 `fetch(..., { credentials: "include" })`
 58+   - Claude API 响应再通过 `baa_page_proxy_response` 回到插件
 59+
 60+这条链路的目标是让后续 `conductor` WS bridge 调的是“真实 Claude 页面里的 HTTP 能力”,不是 DOM 自动化。
 61+
 62+## 当前 runtime message
 63+
 64+### `claude_send`
 65+
 66+用途:
 67+
 68+- 基于已捕获的凭证和 endpoint 发起一轮 Claude HTTP 对话
 69+
 70+示例:
 71+
 72+```json
 73+{
 74+  "type": "claude_send",
 75+  "prompt": "帮我总结当前仓库的 Firefox 插件结构",
 76+  "conversationId": "optional",
 77+  "organizationId": "optional",
 78+  "createNew": false,
 79+  "title": "optional"
 80+}
 81+```
 82+
 83+行为:
 84+
 85+- 如果没有传 `conversationId`,优先使用当前页面/缓存里的对话 ID
 86+- 如果仍没有对话 ID,则先创建新对话
 87+- 然后对 `/completion` 发 HTTP 请求
 88+- 读取 SSE 文本,之后再用 `GET chat_conversations/{convId}` 拉一次权威会话内容
 89+
 90+返回重点字段:
 91+
 92+- `organizationId`
 93+- `conversationId`
 94+- `response.role`
 95+- `response.content`
 96+- `response.thinking`
 97+- `response.timestamp`
 98+- `response.messageUuid`
 99+- `state.title`
100+- `state.currentUrl`
101+- `state.busy`
102+- `state.recentMessages`
103+
104+### `claude_read_conversation`
105+
106+用途:
107+
108+- 读取当前 Claude 对话的最近消息
109+
110+示例:
111+
112+```json
113+{
114+  "type": "claude_read_conversation",
115+  "conversationId": "optional",
116+  "organizationId": "optional"
117+}
118+```
119+
120+行为:
121+
122+- 先确定 `org-id`
123+- 再确定 `conversationId`
124+- 调 `GET /api/organizations/{orgId}/chat_conversations/{convId}`
125+- 返回最近消息、标题、URL、busy 状态
126+
127+### `claude_read_state`
128+
129+用途:
130+
131+- 读取最小 Claude 页面状态,给后续服务端消费
132 
133-- 本地 WS:负责 Firefox bridge 握手、浏览器元数据同步、服务端快照展示
134-- 本地 HTTP:负责控制面状态同步,以及 `pause` / `resume` / `drain` 写入
135+示例:
136 
137-不再允许在插件管理页中手工编辑地址。
138+```json
139+{
140+  "type": "claude_read_state",
141+  "refresh": true
142+}
143+```
144+
145+返回重点字段:
146+
147+- `title`
148+- `currentUrl`
149+- `busy`
150+- `recentMessages`
151+- `sources`
152+
153+其中 `recentMessages` 里的消息元素当前统一为:
154+
155+```json
156+{
157+  "id": "optional",
158+  "role": "user|assistant",
159+  "content": "text",
160+  "thinking": "optional",
161+  "timestamp": 0,
162+  "seq": 0
163+}
164+```
165+
166+## 数据来源说明
167+
168+### 来自 API
169+
170+- 对话标题
171+- 最近消息列表
172+- `role / content / timestamp`
173+- model
174+- 当前对话 ID
175+
176+这些数据来自:
177+
178+- `GET /api/organizations/{orgId}/chat_conversations/{convId}`
179+
180+### 来自 SSE
181+
182+- 本轮 `/completion` 的增量文本
183+- Claude 返回的 message uuid
184+- stop reason
185+
186+这些数据来自:
187 
188-## 目标
189+- `POST /api/organizations/{orgId}/chat_conversations/{convId}/completion`
190 
191-- 让 Firefox 插件自动接入 `mini` 本地 `/ws/firefox`
192-- 保留 `mini` 本地 HTTP 状态同步
193-- 把管理页收口成 WS 状态、HTTP 状态、控制按钮
194-- 在本地服务重启或公网 HTTP 短暂失败后自动恢复
195+### 来自浏览器 tab 元数据
196 
197-## 非目标
198+- 当前 URL
199+- 当 API 还没拿到标题时的临时标题兜底
200 
201-- 不让浏览器成为调度真相源
202-- 不重新引入旧的手工地址配置
203-- 不把 `/ws/firefox` 作为公网入口
204+当前实现没有依赖页面 DOM 解析输入框、消息列表或发送按钮。
205+
206+### 来自插件内部状态
207+
208+- `busy`
209+
210+`busy` 由两部分合并得到:
211+
212+- 插件自己发起的 Claude proxy 请求
213+- 页面里观察到的 Claude completion / SSE 活跃状态
214 
215 ## 启动行为
216 
217-- Firefox 启动时,`background.js` 会确保 `controller.html` 存在。
218-- `controller.html` 启动后立刻连接 `ws://100.71.210.78:4317/ws/firefox`。
219-- WS 断开后按固定间隔自动重连;本地服务恢复后连接会自动恢复。
220-- `controller.html` 启动后也会立刻请求 `GET /v1/system/state`。
221-- HTTP 成功后按 `15` 秒周期继续同步。
222-- HTTP 失败后按 `1` 秒、`3` 秒、`5` 秒快速重试,再进入 `30` 秒慢速重试。
223+- Firefox 启动时,`background.js` 会确保 `controller.html` 存在
224+- `controller.html` 启动后立刻连接 `ws://100.71.210.78:4317/ws/firefox`
225+- WS 断开后按固定间隔自动重连
226+- `controller.html` 启动后也会立刻请求 `GET /v1/system/state`
227+- HTTP 成功后按 `15` 秒周期继续同步
228+- HTTP 失败后按 `1` 秒、`3` 秒、`5` 秒快速重试,再进入 `30` 秒慢速重试
229 
230 ## 管理页 UI
231 
232-当前管理页只保留:
233+管理页当前仍保留:
234 
235 - 本地 WS 状态卡片
236 - 本地 HTTP 状态卡片
237 - `暂停` / `恢复` / `排空` 按钮
238-- `WS 状态` 原始详情面板
239-- `HTTP 状态` 原始详情面板
240-
241-不再保留:
242+- 凭证 / endpoint / 平台状态面板
243 
244-- WS 地址输入框
245-- HTTP 地址输入框
246-- 手工重连按钮
247-- 手工刷新按钮
248-- 标签页/凭证/端点/日志面板
249+Claude 的 runtime message 能力不依赖管理页按钮触发。
250 
251 ## WS 协议
252 
253@@ -76,9 +233,11 @@
254 
255 - `credentials`
256 - `api_endpoints`
257+- `network_log`
258+- `sse_event`
259 - `client_log`
260 
261-插件会消费服务端返回的:
262+同时也会消费服务端下发的:
263 
264 - `hello_ack`
265 - `state_snapshot`
266@@ -89,80 +248,50 @@
267 - `action_result`
268 - `error`
269 
270-其中 `state_snapshot` 用来驱动管理页里的 WS 状态展示。
271-
272-`api_request` / `api_response` 现在已经被正式纳入本地 WS bridge:
273-
274-- server 可以按 `clientId` 或默认最近活跃 client 下发 `api_request`
275-- 插件完成代发后会回 `api_response`
276-- daemon 会跟踪请求生命周期,并处理超时、client disconnect、同 `clientId` replacement
277-
278-当前仍然没有对外产品化的 `/v1/browser/*` HTTP 接口,这一层只补 transport / registry / request-response。
279-
280-## HTTP 协议
281-
282-读取:
283-
284-- `GET /v1/system/state`
285-
286-写入:
287-
288-- `POST /v1/system/pause`
289-- `POST /v1/system/resume`
290-- `POST /v1/system/drain`
291-
292-写接口请求体固定包含:
293-
294-```json
295-{
296-  "requested_by": "browser_admin",
297-  "source": "firefox_extension",
298-  "reason": "human_clicked_pause",
299-  "request_id": "uuid"
300-}
301-```
302+其中 `api_request` 仍然走统一 proxy 通道;Claude 的 runtime message 只是先在插件本地把这条能力补齐,后续由 `conductor` 的 WS bridge 调用。
303 
304-插件会尽量从 HTTP 返回中归一化这些字段:
305+## 验收建议
306 
307-- `mode`
308-- `leader`
309-- `lease_holder`
310-- `queue_depth` / `queued_tasks`
311-- `active_runs`
312-- `controlConnection`
313-- `retryCount`
314-- `lastSuccessAt`
315-- `lastFailureAt`
316-- `nextRetryAt`
317+### 1. 凭证与 endpoint
318 
319-## 验收与验证
320+1. 安装插件并打开 `https://claude.ai/`
321+2. 手工登录 Claude
322+3. 手工发一条真实消息
323+4. 在管理页确认:
324+   - Claude 有有效凭证
325+   - 已发现 `/chat_conversations` 和 `/completion` endpoint
326 
327-### 1. 验证 WS 连接
328+### 2. runtime message
329 
330-1. 安装插件并启动 Firefox。
331-2. 确认 `controller.html` 自动打开。
332-3. 确认 `本地 WS` 最终显示 `已连接`。
333-4. 在 `WS 状态` 面板中确认:
334-   - `wsUrl` 是 `ws://100.71.210.78:4317/ws/firefox`
335-   - 有最近一次 `state_snapshot`
336+1. 发送 `claude_read_state`
337+2. 确认返回:
338+   - `currentUrl`
339+   - `busy`
340+   - `recentMessages`
341+3. 发送 `claude_read_conversation`
342+4. 确认标题和最近消息来自 Claude API
343+5. 发送 `claude_send`
344+6. 确认:
345+   - Claude completion 返回 SSE 文本
346+   - 之后能重新读到最新会话内容
347 
348-### 2. 验证断线重连
349+### 3. 控制面
350 
351-1. 在插件已连接的情况下停掉本地 `conductor-daemon`。
352-2. 确认 `本地 WS` 状态变成 `重连中` 或显示最近错误。
353-3. 重启本地服务。
354-4. 确认管理页无需刷新即可回到 `已连接`。
355+1. 点击 `暂停`,确认 HTTP 状态里的 `mode` 变成 `paused`
356+2. 点击 `恢复`,确认 `mode` 变成 `running`
357+3. 点击 `排空`,确认 `mode` 变成 `draining`
358 
359-### 3. 验证按钮控制
360+## 已知限制
361 
362-1. 点击 `暂停`,确认 HTTP 状态里的 `mode` 变成 `paused`。
363-2. 点击 `恢复`,确认 `mode` 变成 `running`。
364-3. 点击 `排空`,确认 `mode` 变成 `draining`。
365-4. 如果 HTTP 临时失败,确认管理页会展示明确错误并自动继续重试。
366+- 当前正式支持的页面 HTTP 代理只有 Claude
367+- 必须先在真实 Claude 页面里产生过请求,插件才能学到可用凭证和 `org-id`
368+- 如果当前页面 URL 里没有对话 ID,且最近没有观察到 Claude 会话请求,`claude_read_conversation` 需要显式传 `conversationId`
369+- `claude_send` 走 HTTP 代理,不会驱动 Claude 页面 DOM,也不会让页面自动导航到新建对话 URL
370+- `recentMessages` 当前只保留最近一段窗口,不返回完整长历史
371 
372 ## 相关文件
373 
374 - [`../../plugins/baa-firefox/controller.js`](../../plugins/baa-firefox/controller.js)
375-- [`../../plugins/baa-firefox/controller.html`](../../plugins/baa-firefox/controller.html)
376 - [`../../plugins/baa-firefox/background.js`](../../plugins/baa-firefox/background.js)
377-- [`../../plugins/baa-firefox/docs/conductor-control.md`](../../plugins/baa-firefox/docs/conductor-control.md)
378+- [`../../plugins/baa-firefox/content-script.js`](../../plugins/baa-firefox/content-script.js)
379+- [`../../plugins/baa-firefox/page-interceptor.js`](../../plugins/baa-firefox/page-interceptor.js)
M plugins/baa-firefox/controller.js
+895, -24
   1@@ -17,7 +17,8 @@ const CONTROLLER_STORAGE_KEYS = {
   2   lastCredentialAtByPlatform: "baaFirefox.lastCredentialAtByPlatform",
   3   lastCredentialUrlByPlatform: "baaFirefox.lastCredentialUrlByPlatform",
   4   lastCredentialTabIdByPlatform: "baaFirefox.lastCredentialTabIdByPlatform",
   5-  geminiSendTemplate: "baaFirefox.geminiSendTemplate"
   6+  geminiSendTemplate: "baaFirefox.geminiSendTemplate",
   7+  claudeState: "baaFirefox.claudeState"
   8 };
   9 
  10 const DEFAULT_LOCAL_API_BASE = "http://100.71.210.78:4317";
  11@@ -37,6 +38,10 @@ const CONTROL_RETRY_LOG_INTERVAL = 60_000;
  12 const TRACKED_TAB_REFRESH_DELAY = 150;
  13 const CONTROL_STATUS_BODY_LIMIT = 12_000;
  14 const WS_RECONNECT_DELAY = 3_000;
  15+const PROXY_REQUEST_TIMEOUT = 180_000;
  16+const CLAUDE_MESSAGE_LIMIT = 20;
  17+const CLAUDE_TOOL_PLACEHOLDER_RE = /```\n?This block is not supported on your current device yet\.?\n?```/g;
  18+const CLAUDE_THINKING_START_RE = /^(The user|Let me|I need to|I should|I'll|George|User |Looking at|This is a|OK[,.]|Alright|Hmm|Now |Here|So |Wait|Actually|My |Their |His |Her |We |用户|让我|我需要|我来|我想|好的|那|先|接下来)/;
  19 const CHATGPT_SESSION_COOKIE_PATTERNS = [
  20   /__secure-next-auth\.session-token=/i,
  21   /__secure-authjs\.session-token=/i,
  22@@ -174,6 +179,7 @@ const state = {
  23   lastCredentialHash: createPlatformMap(() => ""),
  24   lastCredentialSentAt: createPlatformMap(() => 0),
  25   geminiSendTemplate: null,
  26+  claudeState: createDefaultClaudeState(),
  27   logs: []
  28 };
  29 
  30@@ -272,6 +278,358 @@ function cloneWsState(value) {
  31   };
  32 }
  33 
  34+function normalizeClaudeMessageRole(value) {
  35+  if (value === "human" || value === "user") return "user";
  36+  if (value === "assistant") return "assistant";
  37+  return typeof value === "string" && value.trim() ? value.trim() : "unknown";
  38+}
  39+
  40+function parseClaudeTimestamp(value) {
  41+  if (Number.isFinite(value)) return Number(value);
  42+  if (typeof value === "string" && value.trim()) {
  43+    const timestamp = Date.parse(value);
  44+    return Number.isFinite(timestamp) ? timestamp : null;
  45+  }
  46+  return null;
  47+}
  48+
  49+function normalizeClaudeMessageContent(message) {
  50+  if (Array.isArray(message?.content) && message.content.length > 0) {
  51+    const textBlocks = message.content
  52+      .filter((block) => block?.type === "text" && typeof block.text === "string")
  53+      .map((block) => block.text);
  54+    const thinkingBlocks = message.content
  55+      .filter((block) => block?.type === "thinking" && typeof block.thinking === "string")
  56+      .map((block) => block.thinking);
  57+    return {
  58+      content: textBlocks.join("").trim() || null,
  59+      thinking: thinkingBlocks.join("").trim() || null
  60+    };
  61+  }
  62+
  63+  let raw = typeof message?.text === "string" ? message.text : "";
  64+  raw = raw.replace(CLAUDE_TOOL_PLACEHOLDER_RE, "").trim();
  65+  raw = raw.replace(/\n{3,}/g, "\n\n");
  66+
  67+  if (raw && normalizeClaudeMessageRole(message?.sender) === "assistant" && CLAUDE_THINKING_START_RE.test(raw)) {
  68+    const splitIndex = raw.search(/\n\n(?:[#*\-|>]|\*\*|\d+\.|[\u4e00-\u9fff])/);
  69+    if (splitIndex > 20) {
  70+      return {
  71+        content: raw.slice(splitIndex + 2).trim() || null,
  72+        thinking: raw.slice(0, splitIndex).trim() || null
  73+      };
  74+    }
  75+
  76+    return {
  77+      content: null,
  78+      thinking: raw
  79+    };
  80+  }
  81+
  82+  return {
  83+    content: raw || null,
  84+    thinking: null
  85+  };
  86+}
  87+
  88+function normalizeClaudeMessage(message, index = 0) {
  89+  const parts = normalizeClaudeMessageContent(message);
  90+  return {
  91+    id: typeof message?.uuid === "string" && message.uuid ? message.uuid : null,
  92+    role: normalizeClaudeMessageRole(message?.sender),
  93+    content: parts.content,
  94+    thinking: parts.thinking,
  95+    timestamp: parseClaudeTimestamp(message?.created_at),
  96+    seq: Number.isFinite(index) ? Number(index) : null
  97+  };
  98+}
  99+
 100+function normalizeClaudeMessages(messages, limit = CLAUDE_MESSAGE_LIMIT) {
 101+  const source = Array.isArray(messages) ? messages : [];
 102+  const normalized = source.map((message, index) => normalizeClaudeMessage(message, index));
 103+  if (normalized.length <= limit) return normalized;
 104+  return normalized.slice(-limit);
 105+}
 106+
 107+function createDefaultClaudeState(overrides = {}) {
 108+  return {
 109+    organizationId: null,
 110+    conversationId: null,
 111+    title: null,
 112+    titleSource: null,
 113+    currentUrl: PLATFORMS?.claude?.rootUrl || "https://claude.ai/",
 114+    tabId: null,
 115+    tabTitle: null,
 116+    busy: false,
 117+    busyReason: null,
 118+    lastError: null,
 119+    lastActivityAt: 0,
 120+    lastReadAt: 0,
 121+    lastSendAt: 0,
 122+    lastConversationSource: null,
 123+    lastAssistantMessageUuid: null,
 124+    model: null,
 125+    messages: [],
 126+    ...overrides
 127+  };
 128+}
 129+
 130+function cloneClaudeState(value) {
 131+  if (!isRecord(value)) return createDefaultClaudeState();
 132+  return {
 133+    ...createDefaultClaudeState(),
 134+    ...value,
 135+    messages: Array.isArray(value.messages)
 136+      ? value.messages.map((message) => ({
 137+        id: typeof message?.id === "string" ? message.id : null,
 138+        role: typeof message?.role === "string" ? message.role : "unknown",
 139+        content: typeof message?.content === "string" ? message.content : null,
 140+        thinking: typeof message?.thinking === "string" ? message.thinking : null,
 141+        timestamp: Number.isFinite(message?.timestamp) ? Number(message.timestamp) : null,
 142+        seq: Number.isFinite(message?.seq) ? Number(message.seq) : null
 143+      }))
 144+      : []
 145+  };
 146+}
 147+
 148+function loadClaudeState(raw) {
 149+  const next = cloneClaudeState(raw);
 150+  next.busy = false;
 151+  next.busyReason = null;
 152+  next.lastError = null;
 153+  return next;
 154+}
 155+
 156+function trimToNull(value) {
 157+  return typeof value === "string" && value.trim() ? value.trim() : null;
 158+}
 159+
 160+function buildRuntimeRequestId(prefix = "runtime") {
 161+  if (typeof crypto?.randomUUID === "function") {
 162+    return `${prefix}-${crypto.randomUUID()}`;
 163+  }
 164+  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
 165+}
 166+
 167+function parseClaudeApiContext(url) {
 168+  try {
 169+    const parsed = new URL(url, PLATFORMS.claude.rootUrl);
 170+    const pathname = parsed.pathname || "/";
 171+    const organizationMatch = pathname.match(/\/api\/organizations\/([a-f0-9-]{36})(?:\/|$)/i);
 172+    const conversationMatch = pathname.match(/\/chat_conversations\/([a-f0-9-]{36})(?:\/|$)/i);
 173+    return {
 174+      pathname,
 175+      organizationId: organizationMatch ? organizationMatch[1] : null,
 176+      conversationId: conversationMatch ? conversationMatch[1] : null,
 177+      isConversationList: /\/api\/organizations\/[a-f0-9-]{36}\/chat_conversations$/i.test(pathname),
 178+      isConversationItem: /\/api\/organizations\/[a-f0-9-]{36}\/chat_conversations\/[a-f0-9-]{36}$/i.test(pathname),
 179+      isCompletion: /\/api\/organizations\/[a-f0-9-]{36}\/chat_conversations\/[a-f0-9-]{36}\/completion$/i.test(pathname)
 180+    };
 181+  } catch (_) {
 182+    return {
 183+      pathname: "",
 184+      organizationId: null,
 185+      conversationId: null,
 186+      isConversationList: false,
 187+      isConversationItem: false,
 188+      isCompletion: false
 189+    };
 190+  }
 191+}
 192+
 193+function extractClaudeConversationIdFromPageUrl(url) {
 194+  try {
 195+    const parsed = new URL(url, PLATFORMS.claude.rootUrl);
 196+    const match = (parsed.pathname || "").match(/([a-f0-9-]{36})(?:\/)?$/i);
 197+    return match ? match[1] : null;
 198+  } catch (_) {
 199+    return null;
 200+  }
 201+}
 202+
 203+function extractClaudeOrgId(headers = {}, requestUrl = "") {
 204+  const headerOrgId = trimToNull(headers["x-org-id"]);
 205+  if (headerOrgId) return headerOrgId;
 206+
 207+  const context = parseClaudeApiContext(requestUrl);
 208+  if (context.organizationId) return context.organizationId;
 209+
 210+  const cookie = getHeaderValue(headers, "cookie");
 211+  const cookieMatch = cookie.match(/(?:^|;\s*)lastActiveOrg=([a-f0-9-]{36})/i);
 212+  if (cookieMatch) return cookieMatch[1];
 213+
 214+  return trimToNull(state.claudeState.organizationId) || null;
 215+}
 216+
 217+function getClaudeOrgId() {
 218+  return extractClaudeOrgId(state.lastHeaders.claude, state.lastCredentialUrl.claude || "");
 219+}
 220+
 221+function getClaudeConversationIdFromState() {
 222+  return trimToNull(state.claudeState.conversationId)
 223+    || extractClaudeConversationIdFromPageUrl(state.claudeState.currentUrl || "")
 224+    || parseClaudeApiContext(state.lastCredentialUrl.claude || "").conversationId
 225+    || null;
 226+}
 227+
 228+function getLastClaudeMessageUuid(messages) {
 229+  const source = Array.isArray(messages) ? messages : [];
 230+  for (let index = source.length - 1; index >= 0; index -= 1) {
 231+    if (source[index]?.id) return source[index].id;
 232+  }
 233+  return null;
 234+}
 235+
 236+function buildClaudeStateSnapshot() {
 237+  const snapshot = cloneClaudeState(state.claudeState);
 238+  return {
 239+    platform: "claude",
 240+    organizationId: snapshot.organizationId,
 241+    conversationId: snapshot.conversationId,
 242+    title: snapshot.title,
 243+    currentUrl: snapshot.currentUrl,
 244+    tabId: snapshot.tabId,
 245+    busy: snapshot.busy,
 246+    lastError: snapshot.lastError,
 247+    lastActivityAt: snapshot.lastActivityAt || null,
 248+    lastReadAt: snapshot.lastReadAt || null,
 249+    lastSendAt: snapshot.lastSendAt || null,
 250+    recentMessages: Array.isArray(snapshot.messages)
 251+      ? snapshot.messages.slice(-CLAUDE_MESSAGE_LIMIT)
 252+      : [],
 253+    sources: {
 254+      title: snapshot.titleSource || null,
 255+      messages: snapshot.lastConversationSource || null,
 256+      busy: snapshot.busyReason || null,
 257+      currentUrl: "tab"
 258+    }
 259+  };
 260+}
 261+
 262+function updateClaudeState(patch = {}, options = {}) {
 263+  state.claudeState = cloneClaudeState({
 264+    ...state.claudeState,
 265+    ...patch
 266+  });
 267+  if (options.persist) {
 268+    persistState().catch(() => {});
 269+  }
 270+  if (options.render) {
 271+    render();
 272+  }
 273+  return state.claudeState;
 274+}
 275+
 276+async function refreshClaudeTabState(createIfMissing = false) {
 277+  const tab = createIfMissing
 278+    ? await ensurePlatformTab("claude", { focus: false })
 279+    : (await resolveTrackedTab("claude")) || await findPlatformTab("claude");
 280+
 281+  if (!tab) {
 282+    updateClaudeState({
 283+      tabId: null,
 284+      currentUrl: PLATFORMS.claude.rootUrl,
 285+      tabTitle: null
 286+    }, {
 287+      persist: true,
 288+      render: true
 289+    });
 290+    return null;
 291+  }
 292+
 293+  updateClaudeState({
 294+    tabId: Number.isInteger(tab.id) ? tab.id : null,
 295+    currentUrl: tab.url || state.claudeState.currentUrl || PLATFORMS.claude.rootUrl,
 296+    tabTitle: trimToNull(tab.title),
 297+    conversationId: extractClaudeConversationIdFromPageUrl(tab.url || "") || state.claudeState.conversationId,
 298+    title: state.claudeState.title || trimToNull(tab.title),
 299+    titleSource: state.claudeState.title ? state.claudeState.titleSource : (trimToNull(tab.title) ? "tab" : null)
 300+  }, {
 301+    persist: true,
 302+    render: true
 303+  });
 304+
 305+  return tab;
 306+}
 307+
 308+function normalizeClaudeConversation(payload, meta = {}) {
 309+  const source = isRecord(payload) ? payload : {};
 310+  const messages = normalizeClaudeMessages(source.chat_messages || []);
 311+  return {
 312+    organizationId: trimToNull(meta.organizationId) || extractClaudeOrgId({}, meta.requestUrl || ""),
 313+    conversationId: trimToNull(source.uuid) || trimToNull(meta.conversationId) || null,
 314+    title: trimToNull(source.name),
 315+    model: trimToNull(source.model),
 316+    updatedAt: parseClaudeTimestamp(source.updated_at) || Date.now(),
 317+    messages,
 318+    lastAssistantMessageUuid: getLastClaudeMessageUuid(messages)
 319+  };
 320+}
 321+
 322+function applyClaudeConversation(payload, meta = {}) {
 323+  const normalized = normalizeClaudeConversation(payload, meta);
 324+  const fallbackTitle = trimToNull(state.claudeState.tabTitle);
 325+  updateClaudeState({
 326+    organizationId: normalized.organizationId || state.claudeState.organizationId,
 327+    conversationId: normalized.conversationId || state.claudeState.conversationId,
 328+    title: normalized.title || fallbackTitle || state.claudeState.title,
 329+    titleSource: normalized.title ? "api" : (fallbackTitle ? "tab" : state.claudeState.titleSource),
 330+    model: normalized.model || state.claudeState.model,
 331+    messages: normalized.messages,
 332+    lastAssistantMessageUuid: normalized.lastAssistantMessageUuid || state.claudeState.lastAssistantMessageUuid,
 333+    lastConversationSource: meta.source || "api",
 334+    lastReadAt: meta.readAt || Date.now(),
 335+    lastActivityAt: normalized.updatedAt || Date.now(),
 336+    lastError: null
 337+  }, {
 338+    persist: true,
 339+    render: true
 340+  });
 341+  return buildClaudeStateSnapshot();
 342+}
 343+
 344+function parseClaudeSseText(text) {
 345+  let reply = "";
 346+  let thinking = "";
 347+  let messageUuid = null;
 348+  let stopReason = null;
 349+
 350+  for (const line of String(text || "").split("\n")) {
 351+    if (!line.startsWith("data: ")) continue;
 352+    try {
 353+      const data = JSON.parse(line.slice(6));
 354+      if (data.type === "completion" && data.completion) {
 355+        reply += data.completion;
 356+        if (data.uuid) messageUuid = data.uuid;
 357+      } else if (data.type === "message_start") {
 358+        reply = "";
 359+        thinking = "";
 360+        messageUuid = data.message?.uuid || data.message?.id || messageUuid;
 361+      } else if (data.type === "content_block_delta" && data.delta?.type === "text_delta") {
 362+        reply += data.delta.text || "";
 363+      } else if (data.type === "content_block_delta" && data.delta?.type === "thinking_delta") {
 364+        thinking += data.delta.thinking || "";
 365+      } else if (data.type === "thinking" && data.thinking) {
 366+        thinking += data.thinking;
 367+      }
 368+
 369+      if (!stopReason && typeof data.stop_reason === "string" && data.stop_reason) {
 370+        stopReason = data.stop_reason;
 371+      }
 372+      if (!stopReason && data.type === "message_stop") {
 373+        stopReason = "message_stop";
 374+      }
 375+    } catch (_) {}
 376+  }
 377+
 378+  return {
 379+    text: reply.trim(),
 380+    thinking: thinking.trim() || null,
 381+    messageUuid,
 382+    stopReason
 383+  };
 384+}
 385+
 386 function createPlatformMap(factory) {
 387   const out = {};
 388   for (const platform of PLATFORM_ORDER) {
 389@@ -1215,7 +1573,13 @@ async function persistState() {
 390     [CONTROLLER_STORAGE_KEYS.lastCredentialAtByPlatform]: state.lastCredentialAt,
 391     [CONTROLLER_STORAGE_KEYS.lastCredentialUrlByPlatform]: state.lastCredentialUrl,
 392     [CONTROLLER_STORAGE_KEYS.lastCredentialTabIdByPlatform]: state.lastCredentialTabId,
 393-    [CONTROLLER_STORAGE_KEYS.geminiSendTemplate]: state.geminiSendTemplate
 394+    [CONTROLLER_STORAGE_KEYS.geminiSendTemplate]: state.geminiSendTemplate,
 395+    [CONTROLLER_STORAGE_KEYS.claudeState]: {
 396+      ...cloneClaudeState(state.claudeState),
 397+      busy: false,
 398+      busyReason: null,
 399+      lastError: null
 400+    }
 401   });
 402 }
 403 
 404@@ -1742,6 +2106,16 @@ function buildProxyHeaders(platform, apiPath, sourceHeaders = null) {
 405   return out;
 406 }
 407 
 408+function buildClaudeHeaders(apiPath, overrides = {}) {
 409+  const headers = buildProxyHeaders("claude", apiPath);
 410+  for (const [name, value] of Object.entries(overrides || {})) {
 411+    const lower = String(name || "").toLowerCase();
 412+    if (!lower || value == null || value === "" || isForbiddenProxyHeader(lower)) continue;
 413+    headers[lower] = String(value);
 414+  }
 415+  return headers;
 416+}
 417+
 418 function buildGeminiAutoRequest(prompt) {
 419   const template = state.geminiSendTemplate;
 420   if (!template?.url || !template?.reqBody) {
 421@@ -1801,6 +2175,74 @@ function buildGeminiAutoRequest(prompt) {
 422   }
 423 }
 424 
 425+function createPendingProxyRequest(id, meta = {}) {
 426+  let settled = false;
 427+  let timer = null;
 428+  let resolveFn = null;
 429+  let rejectFn = null;
 430+  const response = new Promise((resolve, reject) => {
 431+    resolveFn = resolve;
 432+    rejectFn = reject;
 433+  });
 434+
 435+  const finish = (callback, value) => {
 436+    if (settled) return false;
 437+    settled = true;
 438+    clearTimeout(timer);
 439+    pendingProxyRequests.delete(id);
 440+    callback(value);
 441+    return true;
 442+  };
 443+
 444+  timer = setTimeout(() => {
 445+    finish(rejectFn, new Error(`${platformLabel(meta.platform || "claude")} 代理超时`));
 446+  }, Math.max(1_000, Number(meta.timeoutMs) || PROXY_REQUEST_TIMEOUT));
 447+
 448+  const entry = {
 449+    id,
 450+    ...meta,
 451+    response,
 452+    resolve(value) {
 453+      return finish(resolveFn, value);
 454+    },
 455+    reject(error) {
 456+      const nextError = error instanceof Error ? error : new Error(String(error || "proxy_failed"));
 457+      return finish(rejectFn, nextError);
 458+    }
 459+  };
 460+
 461+  pendingProxyRequests.set(id, entry);
 462+  return entry;
 463+}
 464+
 465+async function executeProxyRequest(payload, meta = {}) {
 466+  const platform = payload?.platform;
 467+  if (!platform || !PLATFORMS[platform]) {
 468+    throw new Error(`未知平台:${platform || "-"}`);
 469+  }
 470+
 471+  const tab = await ensurePlatformTab(platform, { focus: false });
 472+  const id = payload.id || buildRuntimeRequestId(platform);
 473+  const entry = createPendingProxyRequest(id, {
 474+    ...meta,
 475+    platform,
 476+    method: payload.method,
 477+    path: payload.path
 478+  });
 479+
 480+  try {
 481+    await postProxyRequestToTab(tab.id, {
 482+      ...payload,
 483+      id
 484+    });
 485+  } catch (error) {
 486+    entry.reject(error);
 487+    throw error;
 488+  }
 489+
 490+  return entry.response;
 491+}
 492+
 493 function sendEndpointSnapshot(platform = null) {
 494   for (const target of getTargetPlatforms(platform)) {
 495     const endpoints = Object.keys(state.endpoints[target]).sort();
 496@@ -2005,7 +2447,12 @@ function connectWs(options = {}) {
 497         break;
 498       }
 499       case "api_request":
 500-        proxyApiRequest(message).catch((error) => {
 501+        proxyApiRequest(message).then((result) => {
 502+          sendApiResponse(message.id, result.ok, result.status, result.body, result.error);
 503+          if (!result.ok) {
 504+            addLog("error", `代理 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${result.error || result.status || "proxy_failed"}`);
 505+          }
 506+        }).catch((error) => {
 507           sendApiResponse(message.id, false, null, null, error.message);
 508           addLog("error", `代理 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
 509         });
 510@@ -2098,6 +2545,16 @@ async function findPlatformTab(platform) {
 511 async function setTrackedTab(platform, tab) {
 512   state.trackedTabs[platform] = tab ? tab.id : null;
 513   pruneInvalidCredentialState();
 514+  if (platform === "claude") {
 515+    updateClaudeState({
 516+      tabId: tab?.id ?? null,
 517+      currentUrl: tab?.url || state.claudeState.currentUrl,
 518+      tabTitle: trimToNull(tab?.title),
 519+      conversationId: extractClaudeConversationIdFromPageUrl(tab?.url || "") || state.claudeState.conversationId
 520+    }, {
 521+      render: true
 522+    });
 523+  }
 524   await persistState();
 525   render();
 526 }
 527@@ -2257,10 +2714,86 @@ function resolvePlatformFromRequest(details) {
 528   return ensureTrackedTabId(platform, details.tabId, "request") ? platform : null;
 529 }
 530 
 531+function setClaudeBusy(isBusy, reason = null) {
 532+  updateClaudeState({
 533+    busy: !!isBusy,
 534+    busyReason: isBusy ? reason || "proxy" : null,
 535+    lastActivityAt: Date.now()
 536+  }, {
 537+    render: true
 538+  });
 539+}
 540+
 541+function applyObservedClaudeResponse(data, tabId) {
 542+  const context = parseClaudeApiContext(data.url || "");
 543+  const patch = {
 544+    tabId: Number.isInteger(tabId) ? tabId : state.claudeState.tabId,
 545+    organizationId: context.organizationId || state.claudeState.organizationId,
 546+    conversationId: context.conversationId || state.claudeState.conversationId,
 547+    lastActivityAt: Date.now()
 548+  };
 549+
 550+  if (context.isCompletion) {
 551+    patch.busy = true;
 552+    patch.busyReason = data.source === "proxy" ? "proxy" : "page_sse";
 553+  }
 554+
 555+  if (context.isConversationItem && typeof data.resBody === "string" && data.resBody) {
 556+    try {
 557+      const payload = JSON.parse(data.resBody);
 558+      applyClaudeConversation(payload, {
 559+        source: data.source === "proxy" ? "proxy_api" : "api_observed",
 560+        organizationId: context.organizationId,
 561+        conversationId: context.conversationId,
 562+        readAt: Date.now()
 563+      });
 564+      return;
 565+    } catch (_) {}
 566+  }
 567+
 568+  updateClaudeState(patch, {
 569+    persist: !!(patch.organizationId || patch.conversationId),
 570+    render: true
 571+  });
 572+}
 573+
 574+function applyObservedClaudeSse(data, tabId) {
 575+  const context = parseClaudeApiContext(data.url || "");
 576+  const parsed = typeof data.chunk === "string" && data.chunk ? parseClaudeSseText(data.chunk) : null;
 577+  const patch = {
 578+    tabId: Number.isInteger(tabId) ? tabId : state.claudeState.tabId,
 579+    organizationId: context.organizationId || state.claudeState.organizationId,
 580+    conversationId: context.conversationId || state.claudeState.conversationId,
 581+    lastActivityAt: Date.now()
 582+  };
 583+
 584+  if (parsed?.messageUuid) {
 585+    patch.lastAssistantMessageUuid = parsed.messageUuid;
 586+  }
 587+
 588+  if (data.done || data.error) {
 589+    patch.busy = false;
 590+    patch.busyReason = null;
 591+    patch.lastError = data.error || null;
 592+  } else {
 593+    patch.busy = true;
 594+    patch.busyReason = "page_sse";
 595+  }
 596+
 597+  updateClaudeState(patch, {
 598+    persist: !!(patch.organizationId || patch.conversationId || patch.lastAssistantMessageUuid),
 599+    render: true
 600+  });
 601+}
 602+
 603 function handlePageNetwork(data, sender) {
 604   const context = getSenderContext(sender, data?.platform || null);
 605   if (!context || !data || !data.url || !data.method) return;
 606 
 607+  if (context.platform === "claude") {
 608+    applyObservedClaudeResponse(data, context.tabId);
 609+  }
 610+
 611   if (context.platform === "gemini" && typeof data.reqBody === "string" && data.reqBody) {
 612     const templateHeaders = Object.keys(data.reqHeaders || {}).length > 0
 613       ? mergeKnownHeaders(context.platform, data.reqHeaders || {})
 614@@ -2286,6 +2819,10 @@ function handlePageSse(data, sender) {
 615   const context = getSenderContext(sender, data?.platform || null);
 616   if (!context || !data || !data.url) return;
 617 
 618+  if (context.platform === "claude") {
 619+    applyObservedClaudeSse(data, context.tabId);
 620+  }
 621+
 622   wsSend({
 623     type: "sse_event",
 624     clientId: state.clientId,
 625@@ -2304,10 +2841,26 @@ function handlePageProxyResponse(data, sender) {
 626   const context = getSenderContext(sender, data?.platform || null);
 627   if (!context || !data || !data.id) return;
 628   const pending = pendingProxyRequests.get(data.id);
 629+  if (!pending) return;
 630+
 631+  if (context.platform === "claude") {
 632+    const parsed = parseClaudeApiContext(data.url || pending.path || "");
 633+    updateClaudeState({
 634+      organizationId: parsed.organizationId || state.claudeState.organizationId,
 635+      conversationId: parsed.conversationId || state.claudeState.conversationId,
 636+      busy: false,
 637+      busyReason: null,
 638+      lastActivityAt: Date.now(),
 639+      lastError: data.error || null
 640+    }, {
 641+      persist: !!(parsed.organizationId || parsed.conversationId),
 642+      render: true
 643+    });
 644+  }
 645 
 646   if (
 647     context.platform === "gemini"
 648-    && pending?.prompt
 649+    && pending.prompt
 650     && pending.attempts < 1
 651     && Number(data.status) === 400
 652   ) {
 653@@ -2325,29 +2878,27 @@ function handlePageProxyResponse(data, sender) {
 654           body: retry.body,
 655           headers: retry.headers
 656         }).catch((error) => {
 657-          pendingProxyRequests.delete(data.id);
 658-          sendApiResponse(data.id, false, null, null, error.message);
 659+          pending.reject(error);
 660           addLog("error", `Gemini 重试 ${data.id} 失败:${error.message}`);
 661         });
 662         return;
 663       } catch (error) {
 664-        pendingProxyRequests.delete(data.id);
 665-        sendApiResponse(data.id, false, null, null, error.message);
 666+        pending.reject(error);
 667         addLog("error", `Gemini 重试 ${data.id} 失败:${error.message}`);
 668         return;
 669       }
 670     }
 671   }
 672 
 673-  pendingProxyRequests.delete(data.id);
 674-
 675-  sendApiResponse(
 676-    data.id,
 677-    !data.error,
 678-    Number.isFinite(data.status) ? data.status : null,
 679-    typeof data.body === "string" ? data.body : (data.body == null ? null : JSON.stringify(data.body)),
 680-    data.error || null
 681-  );
 682+  pending.resolve({
 683+    id: data.id,
 684+    ok: data.ok !== false && !data.error,
 685+    status: Number.isFinite(data.status) ? data.status : null,
 686+    body: typeof data.body === "string" ? data.body : (data.body == null ? null : JSON.stringify(data.body)),
 687+    error: data.error || null,
 688+    url: data.url || pending.path || null,
 689+    method: data.method || pending.method || null
 690+  });
 691 
 692   if (!data.ok || (Number.isFinite(data.status) && data.status >= 400)) {
 693     addLog(
 694@@ -2364,6 +2915,17 @@ function handlePageBridgeReady(data, sender) {
 695   if (!platform || !Number.isInteger(tabId)) return;
 696 
 697   ensureTrackedTabId(platform, tabId, "bridge");
 698+  if (platform === "claude") {
 699+    updateClaudeState({
 700+      tabId,
 701+      currentUrl: senderUrl || state.claudeState.currentUrl,
 702+      tabTitle: trimToNull(sender?.tab?.title),
 703+      conversationId: extractClaudeConversationIdFromPageUrl(senderUrl) || state.claudeState.conversationId
 704+    }, {
 705+      persist: true,
 706+      render: true
 707+    });
 708+  }
 709   addLog("info", `${platformLabel(platform)} 钩子已就绪,标签页 ${tabId},来源 ${data?.source || "未知"}`);
 710 }
 711 
 712@@ -2372,6 +2934,12 @@ function handleBeforeSendHeaders(details) {
 713   if (!platform) return;
 714 
 715   const headers = headerArrayToObject(details.requestHeaders);
 716+  if (platform === "claude") {
 717+    const orgId = extractClaudeOrgId(headers, details.url || "");
 718+    if (orgId) {
 719+      headers["x-org-id"] = orgId;
 720+    }
 721+  }
 722   if (Object.keys(headers).length === 0) return;
 723 
 724   collectEndpoint(platform, details.method || "GET", details.url);
 725@@ -2404,6 +2972,22 @@ function handleCompleted(details) {
 726 function handleErrorOccurred(details) {
 727   const platform = resolvePlatformFromRequest(details);
 728   if (!platform || !shouldTrackRequest(platform, details.url)) return;
 729+  if (platform === "claude") {
 730+    const context = parseClaudeApiContext(details.url || "");
 731+    if (context.isCompletion) {
 732+      updateClaudeState({
 733+        organizationId: context.organizationId || state.claudeState.organizationId,
 734+        conversationId: context.conversationId || state.claudeState.conversationId,
 735+        busy: false,
 736+        busyReason: null,
 737+        lastError: details.error || "request_failed",
 738+        lastActivityAt: Date.now()
 739+      }, {
 740+        persist: true,
 741+        render: true
 742+      });
 743+    }
 744+  }
 745   addLog("error", `${platformLabel(platform)} ${details.method} ${normalizePath(details.url)} 失败:${details.error}`);
 746 }
 747 
 748@@ -2432,33 +3016,294 @@ async function proxyApiRequest(message) {
 749   if (!platform || !PLATFORMS[platform]) throw new Error(`未知平台:${platform || "-"}`);
 750   if (!apiPath) throw new Error("缺少代理请求路径");
 751 
 752-  const tab = await ensurePlatformTab(platform, { focus: false });
 753   const prompt = platform === "gemini" ? extractPromptFromProxyBody(body) : null;
 754   const geminiAutoRequest = platform === "gemini" && prompt && isGeminiStreamGenerateUrl(apiPath)
 755     ? buildGeminiAutoRequest(prompt)
 756     : null;
 757-  const payload = {
 758+
 759+  return executeProxyRequest({
 760     id,
 761     platform,
 762     method: geminiAutoRequest ? "POST" : String(method || "GET").toUpperCase(),
 763     path: geminiAutoRequest ? geminiAutoRequest.path : apiPath,
 764     body: geminiAutoRequest ? geminiAutoRequest.body : body,
 765     headers: geminiAutoRequest ? geminiAutoRequest.headers : buildProxyHeaders(platform, apiPath)
 766-  };
 767-
 768-  pendingProxyRequests.set(id, {
 769+  }, {
 770     platform,
 771     prompt,
 772     attempts: 0
 773   });
 774+}
 775+
 776+async function proxyClaudeRequest(method, path, body = null, options = {}) {
 777+  const headers = buildClaudeHeaders(path, options.headers || {});
 778+  const response = await executeProxyRequest({
 779+    id: options.id || buildRuntimeRequestId("claude"),
 780+    platform: "claude",
 781+    method: String(method || "GET").toUpperCase(),
 782+    path,
 783+    body,
 784+    headers
 785+  }, {
 786+    platform: "claude",
 787+    timeoutMs: options.timeoutMs || PROXY_REQUEST_TIMEOUT
 788+  });
 789+
 790+  if (!response.ok || (Number.isFinite(response.status) && response.status >= 400)) {
 791+    const bodyPreview = typeof response.body === "string" && response.body
 792+      ? `: ${response.body.slice(0, 240)}`
 793+      : "";
 794+    throw new Error(`Claude 请求失败 (${response.status || "unknown"})${bodyPreview}`);
 795+  }
 796+
 797+  return response;
 798+}
 799+
 800+async function proxyClaudeJson(method, path, body = null, options = {}) {
 801+  const response = await proxyClaudeRequest(method, path, body, {
 802+    ...options,
 803+    headers: {
 804+      accept: "application/json",
 805+      ...(options.headers || {})
 806+    }
 807+  });
 808+
 809+  if (!response.body) return null;
 810   try {
 811-    await postProxyRequestToTab(tab.id, payload);
 812+    return JSON.parse(response.body);
 813   } catch (error) {
 814-    pendingProxyRequests.delete(id);
 815+    throw new Error(`Claude JSON 响应解析失败:${error.message}`);
 816+  }
 817+}
 818+
 819+async function readClaudeConversation(options = {}) {
 820+  await refreshClaudeTabState(options.createTab === true);
 821+  const conversationId = trimToNull(options.conversationId) || getClaudeConversationIdFromState();
 822+  const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
 823+
 824+  if (!organizationId) {
 825+    throw new Error("Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
 826+  }
 827+  if (!conversationId) {
 828+    throw new Error("Claude 当前没有可读取的对话 ID");
 829+  }
 830+
 831+  const payload = await proxyClaudeJson(
 832+    "GET",
 833+    `/api/organizations/${organizationId}/chat_conversations/${conversationId}`,
 834+    null
 835+  );
 836+
 837+  return applyClaudeConversation(payload, {
 838+    source: "api_read",
 839+    organizationId,
 840+    conversationId,
 841+    readAt: Date.now()
 842+  });
 843+}
 844+
 845+async function createClaudeConversation(options = {}) {
 846+  const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
 847+  if (!organizationId) {
 848+    throw new Error("Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
 849+  }
 850+
 851+  const payload = await proxyClaudeJson(
 852+    "POST",
 853+    `/api/organizations/${organizationId}/chat_conversations`,
 854+    {
 855+      name: trimToNull(options.title) || "",
 856+      uuid: typeof crypto?.randomUUID === "function" ? crypto.randomUUID() : undefined
 857+    }
 858+  );
 859+
 860+  const conversationId = trimToNull(payload?.uuid);
 861+  if (!conversationId) {
 862+    throw new Error("Claude 创建对话未返回 uuid");
 863+  }
 864+
 865+  updateClaudeState({
 866+    organizationId,
 867+    conversationId,
 868+    title: trimToNull(payload?.name) || trimToNull(options.title) || null,
 869+    titleSource: trimToNull(payload?.name) || trimToNull(options.title) ? "api" : null,
 870+    messages: [],
 871+    lastAssistantMessageUuid: null,
 872+    lastConversationSource: "api_create",
 873+    lastActivityAt: Date.now(),
 874+    lastError: null
 875+  }, {
 876+    persist: true,
 877+    render: true
 878+  });
 879+
 880+  return {
 881+    organizationId,
 882+    conversationId,
 883+    title: trimToNull(payload?.name) || trimToNull(options.title) || null
 884+  };
 885+}
 886+
 887+async function resolveClaudeSendTarget(options = {}) {
 888+  await refreshClaudeTabState(true);
 889+  const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
 890+  if (!organizationId) {
 891+    throw new Error("Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
 892+  }
 893+
 894+  let conversationId = trimToNull(options.conversationId);
 895+  let conversationSnapshot = null;
 896+
 897+  if (!conversationId && options.createNew !== true) {
 898+    conversationId = getClaudeConversationIdFromState();
 899+  }
 900+
 901+  if (conversationId && options.createNew !== true) {
 902+    conversationSnapshot = await readClaudeConversation({
 903+      organizationId,
 904+      conversationId
 905+    }).catch(() => null);
 906+  }
 907+
 908+  if (!conversationId || options.createNew === true) {
 909+    const created = await createClaudeConversation({
 910+      organizationId,
 911+      title: options.title
 912+    });
 913+    conversationId = created.conversationId;
 914+  }
 915+
 916+  const lastMessageUuid = conversationSnapshot?.recentMessages?.length
 917+    ? getLastClaudeMessageUuid(conversationSnapshot.recentMessages)
 918+    : (conversationId === state.claudeState.conversationId
 919+      ? state.claudeState.lastAssistantMessageUuid || null
 920+      : null);
 921+
 922+  return {
 923+    organizationId,
 924+    conversationId,
 925+    lastMessageUuid
 926+  };
 927+}
 928+
 929+async function sendClaudePrompt(message = {}) {
 930+  const prompt = trimToNull(message.prompt || message.text || message.message);
 931+  if (!prompt) {
 932+    throw new Error("Claude prompt 不能为空");
 933+  }
 934+
 935+  const target = await resolveClaudeSendTarget(message);
 936+  setClaudeBusy(true, "proxy");
 937+
 938+  try {
 939+    const completionBody = {
 940+      prompt,
 941+      timezone: "Asia/Shanghai",
 942+      attachments: [],
 943+      files: []
 944+    };
 945+
 946+    if (target.lastMessageUuid) {
 947+      completionBody.parent_message_uuid = target.lastMessageUuid;
 948+    }
 949+
 950+    const response = await proxyClaudeRequest(
 951+      "POST",
 952+      `/api/organizations/${target.organizationId}/chat_conversations/${target.conversationId}/completion`,
 953+      completionBody,
 954+      {
 955+        headers: {
 956+          accept: "text/event-stream",
 957+          "content-type": "application/json"
 958+        }
 959+      }
 960+    );
 961+
 962+    const parsed = parseClaudeSseText(response.body || "");
 963+    updateClaudeState({
 964+      organizationId: target.organizationId,
 965+      conversationId: target.conversationId,
 966+      lastAssistantMessageUuid: parsed.messageUuid || state.claudeState.lastAssistantMessageUuid,
 967+      lastSendAt: Date.now(),
 968+      lastActivityAt: Date.now(),
 969+      lastError: null
 970+    }, {
 971+      persist: true,
 972+      render: true
 973+    });
 974+
 975+    let conversation = null;
 976+    try {
 977+      conversation = await readClaudeConversation({
 978+        organizationId: target.organizationId,
 979+        conversationId: target.conversationId
 980+      });
 981+    } catch (error) {
 982+      updateClaudeState({
 983+        lastError: error.message
 984+      }, {
 985+        persist: true,
 986+        render: true
 987+      });
 988+      conversation = buildClaudeStateSnapshot();
 989+    }
 990+
 991+    setClaudeBusy(false, null);
 992+
 993+    return {
 994+      ok: true,
 995+      organizationId: target.organizationId,
 996+      conversationId: target.conversationId,
 997+      response: {
 998+        role: "assistant",
 999+        content: parsed.text,
1000+        thinking: parsed.thinking,
1001+        timestamp: Date.now(),
1002+        messageUuid: parsed.messageUuid || null,
1003+        stopReason: parsed.stopReason || null
1004+      },
1005+      state: conversation
1006+    };
1007+  } catch (error) {
1008+    updateClaudeState({
1009+      lastError: error.message
1010+    }, {
1011+      persist: true,
1012+      render: true
1013+    });
1014+    setClaudeBusy(false, null);
1015     throw error;
1016   }
1017 }
1018 
1019+async function readClaudeState(options = {}) {
1020+  const tab = await refreshClaudeTabState(options.createTab === true);
1021+  const conversationId = trimToNull(options.conversationId) || getClaudeConversationIdFromState();
1022+  const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
1023+
1024+  if (organizationId && conversationId && options.refresh !== false) {
1025+    try {
1026+      return await readClaudeConversation({
1027+        organizationId,
1028+        conversationId
1029+      });
1030+    } catch (error) {
1031+      updateClaudeState({
1032+        lastError: error.message
1033+      }, {
1034+        persist: true,
1035+        render: true
1036+      });
1037+    }
1038+  }
1039+
1040+  if (!tab) {
1041+    throw new Error("Claude 标签页不存在");
1042+  }
1043+
1044+  return buildClaudeStateSnapshot();
1045+}
1046+
1047 function registerWebRequestListeners() {
1048   browser.webRequest.onBeforeSendHeaders.addListener(
1049     handleBeforeSendHeaders,
1050@@ -2511,6 +3356,30 @@ function registerRuntimeListeners() {
1051           ok: true,
1052           snapshot: cloneControlState(state.controlState)
1053         });
1054+      case "claude_send":
1055+        return sendClaudePrompt(message).catch((error) => ({
1056+          ok: false,
1057+          error: error.message,
1058+          state: buildClaudeStateSnapshot()
1059+        }));
1060+      case "claude_read_conversation":
1061+        return readClaudeConversation(message).then((stateSnapshot) => ({
1062+          ok: true,
1063+          state: stateSnapshot
1064+        })).catch((error) => ({
1065+          ok: false,
1066+          error: error.message,
1067+          state: buildClaudeStateSnapshot()
1068+        }));
1069+      case "claude_read_state":
1070+        return readClaudeState(message).then((stateSnapshot) => ({
1071+          ok: true,
1072+          state: stateSnapshot
1073+        })).catch((error) => ({
1074+          ok: false,
1075+          error: error.message,
1076+          state: buildClaudeStateSnapshot()
1077+        }));
1078       default:
1079         break;
1080     }
1081@@ -2611,6 +3480,7 @@ async function init() {
1082     saved[CONTROLLER_STORAGE_KEYS.lastCredentialTabIdByPlatform]
1083   );
1084   state.geminiSendTemplate = saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplate] || null;
1085+  state.claudeState = loadClaudeState(saved[CONTROLLER_STORAGE_KEYS.claudeState]);
1086   if (needsStatusReset) {
1087     state.lastHeaders = createPlatformMap(() => ({}));
1088     state.lastCredentialAt = createPlatformMap(() => 0);
1089@@ -2632,6 +3502,7 @@ async function init() {
1090   const current = await browser.tabs.getCurrent();
1091   await browser.runtime.sendMessage({ type: "controller_ready", tabId: current.id });
1092   await refreshTrackedTabsFromBrowser("startup");
1093+  await refreshClaudeTabState(false);
1094   await persistState();
1095   render();
1096   addLog("info", `controller ready ${state.clientId}`, false);