baa-conductor


commit
43de232
parent
54f1c61
author
codex@macbookpro
date
2026-04-01 18:34:07 +0800 CST
feat: formalize Gemini raw relay support
14 files changed,  +648, -83
Raw patch view.
   1diff --git a/apps/conductor-daemon/src/browser-types.ts b/apps/conductor-daemon/src/browser-types.ts
   2index d7ab05d50e012bd94682779de1fc65b9cf2fd82c..b8f0714defcc2a7294b4105bb0e1915159bd03be 100644
   3--- a/apps/conductor-daemon/src/browser-types.ts
   4+++ b/apps/conductor-daemon/src/browser-types.ts
   5@@ -294,6 +294,7 @@ export interface BrowserBridgeController {
   6   apiRequest(input: {
   7     body?: unknown;
   8     clientId?: string | null;
   9+    conversationId?: string | null;
  10     headers?: Record<string, string> | null;
  11     id?: string | null;
  12     idleTimeoutMs?: number | null;
  13@@ -378,6 +379,7 @@ export interface BrowserBridgeController {
  14   streamRequest(input: {
  15     body?: unknown;
  16     clientId?: string | null;
  17+    conversationId?: string | null;
  18     headers?: Record<string, string> | null;
  19     id?: string | null;
  20     idleTimeoutMs?: number | null;
  21diff --git a/apps/conductor-daemon/src/firefox-bridge.ts b/apps/conductor-daemon/src/firefox-bridge.ts
  22index 409d2e5e4bf5fba1a334adff70f8e2239b992844..4d3749ce4a847ce75e026f04eac33771fe27bdb7 100644
  23--- a/apps/conductor-daemon/src/firefox-bridge.ts
  24+++ b/apps/conductor-daemon/src/firefox-bridge.ts
  25@@ -89,6 +89,7 @@ export interface FirefoxPluginActionCommandInput extends FirefoxBridgeCommandTar
  26 
  27 export interface FirefoxApiRequestCommandInput extends FirefoxBridgeCommandTarget {
  28   body?: unknown;
  29+  conversationId?: string | null;
  30   headers?: Record<string, string> | null;
  31   id?: string | null;
  32   idleTimeoutMs?: number | null;
  33@@ -1581,6 +1582,7 @@ export class FirefoxBridgeService {
  34     return await this.broker.sendApiRequest(
  35       compactRecord({
  36         body: input.body ?? null,
  37+        conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
  38         headers: normalizeHeaderRecord(input.headers),
  39         method: normalizeOptionalString(input.method)?.toUpperCase() ?? "GET",
  40         path,
  41@@ -1617,6 +1619,7 @@ export class FirefoxBridgeService {
  42     return this.broker.openApiStream(
  43       compactRecord({
  44         body: input.body ?? null,
  45+        conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
  46         headers: normalizeHeaderRecord(input.headers),
  47         method: normalizeOptionalString(input.method)?.toUpperCase() ?? "GET",
  48         path,
  49diff --git a/apps/conductor-daemon/src/index.test.js b/apps/conductor-daemon/src/index.test.js
  50index 1c7cdd0c7ab1951baffc3b683720eeb0807bea19..7e4bae0409347254c8254e06adc62d930d5e305e 100644
  51--- a/apps/conductor-daemon/src/index.test.js
  52+++ b/apps/conductor-daemon/src/index.test.js
  53@@ -6010,7 +6010,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
  54     );
  55     assert.deepEqual(
  56       describePayload.data.browser.request_contract.supported_platforms,
  57-      ["claude", "chatgpt"]
  58+      ["claude", "chatgpt", "gemini"]
  59     );
  60     assert.deepEqual(
  61       describePayload.data.browser.action_contract.supported_platforms,
  62@@ -6066,7 +6066,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
  63     assert.equal(businessDescribePayload.data.browser.request_contract.route.path, "/v1/browser/request");
  64     assert.deepEqual(
  65       businessDescribePayload.data.browser.request_contract.supported_platforms,
  66-      ["claude", "chatgpt"]
  67+      ["claude", "chatgpt", "gemini"]
  68     );
  69     assert.match(
  70       JSON.stringify(businessDescribePayload.data.browser.request_contract.supported_response_modes),
  71@@ -6298,6 +6298,36 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
  72     assert.equal(chatgptBufferedPayload.data.response.models[0].slug, "gpt-5.4");
  73     assert.equal(chatgptBufferedPayload.data.policy.platform, "chatgpt");
  74 
  75+    const geminiBufferedResponse = await handleConductorHttpRequest(
  76+      {
  77+        body: JSON.stringify({
  78+          conversationId: "conv-gemini-current",
  79+          platform: "gemini",
  80+          prompt: "hello generic gemini relay",
  81+          requestId: "browser-gemini-buffered-123",
  82+          path: "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
  83+        }),
  84+        method: "POST",
  85+        path: "/v1/browser/request"
  86+      },
  87+      localApiContext
  88+    );
  89+    assert.equal(geminiBufferedResponse.status, 200);
  90+    const geminiBufferedPayload = parseJsonBody(geminiBufferedResponse);
  91+    assert.equal(geminiBufferedPayload.data.request_mode, "api_request");
  92+    assert.equal(
  93+      geminiBufferedPayload.data.proxy.path,
  94+      "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
  95+    );
  96+    assert.equal(geminiBufferedPayload.data.proxy.request_id, "browser-gemini-buffered-123");
  97+    assert.equal(geminiBufferedPayload.data.response.conversation_id, "conv-gemini-current");
  98+    assert.equal(geminiBufferedPayload.data.policy.platform, "gemini");
  99+    const geminiBufferedCall = browser.calls.find(
 100+      (entry) => entry.kind === "apiRequest" && entry.id === "browser-gemini-buffered-123"
 101+    );
 102+    assert.ok(geminiBufferedCall);
 103+    assert.equal(geminiBufferedCall.conversationId, "conv-gemini-current");
 104+
 105     const chatgptLegacySendResponse = await handleConductorHttpRequest(
 106       {
 107         body: JSON.stringify({
 108@@ -6804,6 +6834,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 109       "apiRequest:GET:/api/stream-buffered-smoke",
 110       "apiRequest:GET:/backend-api/conversation-buffered-smoke",
 111       "apiRequest:GET:/backend-api/models",
 112+      "apiRequest:POST:/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate",
 113       "apiRequest:POST:/backend-api/conversation",
 114       "apiRequest:GET:/backend-api/conversation/conv-chatgpt-current",
 115       "apiRequest:POST:/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate",
 116diff --git a/apps/conductor-daemon/src/local-api.ts b/apps/conductor-daemon/src/local-api.ts
 117index d5b92a2ab66ba54df1c9b2b9bb454af914186930..fb8af94b1e6d72cc91dfb4b9ea6e3b35593da64e 100644
 118--- a/apps/conductor-daemon/src/local-api.ts
 119+++ b/apps/conductor-daemon/src/local-api.ts
 120@@ -198,7 +198,7 @@ const SUPPORTED_BROWSER_ACTIONS = [
 121 ] as const;
 122 const RESERVED_BROWSER_ACTIONS = [] as const;
 123 const FORMAL_BROWSER_SHELL_PLATFORMS = ["claude", "chatgpt"] as const;
 124-const FORMAL_BROWSER_REQUEST_PLATFORMS = ["claude", "chatgpt"] as const;
 125+const FORMAL_BROWSER_REQUEST_PLATFORMS = ["claude", "chatgpt", "gemini"] as const;
 126 const SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES = ["buffered", "sse"] as const;
 127 const RESERVED_BROWSER_REQUEST_RESPONSE_MODES = [] as const;
 128 const MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS = 60_000;
 129@@ -3405,6 +3405,7 @@ async function requestBrowserProxy(
 130     action: string;
 131     body?: JsonValue;
 132     clientId?: string | null;
 133+    conversationId?: string | null;
 134     headers?: Record<string, string>;
 135     id?: string | null;
 136     method: string;
 137@@ -3441,6 +3442,7 @@ async function requestBrowserProxy(
 138     const apiRequestPromise = bridge.apiRequest({
 139       body: input.body,
 140       clientId: input.clientId,
 141+      conversationId: input.conversationId,
 142       headers: input.headers,
 143       id: requestId,
 144       method: input.method,
 145@@ -4165,7 +4167,7 @@ function buildBrowserRequestContract(origin: string): JsonObject {
 146     route: describeRoute(requireRouteDefinition("browser.request")),
 147     request_body: {
 148       platform:
 149-        "必填字符串;当前正式支持 claude 和 chatgpt。claude 额外支持省略 path + prompt 的兼容模式;chatgpt 当前只支持显式 path 的 raw proxy 请求。",
 150+        "必填字符串;当前正式支持 claude、chatgpt 和 gemini。claude 额外支持省略 path + prompt 的兼容模式;chatgpt / gemini 当前只支持显式 path 的 raw proxy 请求。",
 151       clientId: "可选字符串;指定目标 Firefox bridge client。",
 152       requestId: "可选字符串;用于 trace 和未来 cancel 对齐。缺省时由 conductor 生成。",
 153       method: "可选字符串;默认 GET。若携带 requestBody 或 prompt 且未显式指定,则默认 POST。",
 154@@ -4174,7 +4176,8 @@ function buildBrowserRequestContract(origin: string): JsonObject {
 155       requestBody: "可选任意 JSON;作为代发请求体原样传入。",
 156       prompt: '可选 Claude 兼容字段;当 platform=claude 且省略 path 时,会自动补全 completion 路径。',
 157       organizationId: "可选 Claude 字段;覆盖自动选择的 organization。",
 158-      conversationId: "可选 Claude 字段;覆盖自动选择的 conversation。",
 159+      conversationId:
 160+        "可选字符串;Claude 可覆盖自动选择的 conversation;Gemini 会优先用于匹配会话级持久化发送模板。",
 161       responseMode:
 162         '可选字符串 buffered 或 sse;buffered 返回 JSON,sse 返回 text/event-stream,并按 stream_open / stream_event / stream_end / stream_error 编码。',
 163       timeoutMs: `可选整数 > 0;默认 ${DEFAULT_BROWSER_PROXY_TIMEOUT_MS}。`
 164@@ -4205,6 +4208,15 @@ function buildBrowserRequestContract(origin: string): JsonObject {
 165           method: "GET",
 166           path: "/backend-api/models"
 167         })
 168+      },
 169+      {
 170+        title: "Issue a Gemini raw relay request against a captured StreamGenerate template",
 171+        curl: buildCurlExample(origin, requireRouteDefinition("browser.request"), {
 172+          conversationId: "conv-gemini-current",
 173+          platform: "gemini",
 174+          path: BROWSER_GEMINI_STREAM_GENERATE_PATH,
 175+          prompt: "Summarize the latest conductor health snapshot."
 176+        })
 177       }
 178     ],
 179     error_semantics: [
 180@@ -4279,8 +4291,9 @@ function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: str
 181     notes: [
 182       "Business-facing browser work now lands on POST /v1/browser/request; browser/plugin management lands on POST /v1/browser/actions.",
 183       "GET /v1/browser remains the shared read model for login-state metadata, plugin connectivity, shell_runtime, and the latest structured action_result per client.",
 184-      "The generic browser HTTP request surface now formally supports Claude prompt/raw relay plus ChatGPT raw relay, and expects a local Firefox bridge client.",
 185-      "Claude keeps the prompt shortcut when path is omitted; ChatGPT currently requires an explicit path and a real browser login context captured on the selected client.",
 186+      "The generic browser HTTP request surface now formally supports Claude prompt/raw relay plus ChatGPT and Gemini raw relay, and expects a local Firefox bridge client.",
 187+      "Claude keeps the prompt shortcut when path is omitted; ChatGPT and Gemini require an explicit path and a real browser login context captured on the selected client.",
 188+      "Gemini raw relay can additionally use conversationId to prefer a conversation-matched persisted send template when the plugin has one.",
 189       "The legacy helper surface now also exposes /v1/browser/chatgpt/* and /v1/browser/gemini/* wrappers for BAA target compatibility.",
 190       "POST /v1/browser/actions now waits for the plugin to return a structured action_result instead of returning only a dispatch ack.",
 191       "POST /v1/browser/request now supports buffered JSON and formal SSE event envelopes; POST /v1/browser/request/cancel cancels an in-flight browser request by requestId.",
 192@@ -5047,7 +5060,7 @@ async function handleCapabilitiesRead(
 193     notes: [
 194       "Read routes are safe for discovery and inspection.",
 195       "The browser HTTP contract is now split into GET /v1/browser, POST /v1/browser/request, and POST /v1/browser/actions.",
 196-      "The generic browser request surface now formally supports Claude and ChatGPT; /v1/browser/{claude,chatgpt,gemini}/* remains available as legacy compatibility wrappers.",
 197+      "The generic browser request surface now formally supports Claude, ChatGPT, and Gemini; /v1/browser/{claude,chatgpt,gemini}/* remains available as legacy compatibility wrappers.",
 198       "All /v1/codex routes proxy the independent codexd daemon over local HTTP.",
 199       "POST /v1/system/* writes the local automation mode immediately.",
 200       "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN> and return 401 JSON on missing or wrong tokens.",
 201@@ -5598,6 +5611,7 @@ async function executeBrowserRequest(
 202         const stream = requireBrowserBridge(context).streamRequest({
 203           body: requestBody ?? { prompt: "" },
 204           clientId: selection.client.client_id,
 205+          conversationId: input.conversationId ?? conversation.id,
 206           headers: input.headers,
 207           id: requestId,
 208           idleTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.idleTimeoutMs,
 209@@ -5635,6 +5649,7 @@ async function executeBrowserRequest(
 210         action: "browser request",
 211         body: requestBody ?? { prompt: "" },
 212         clientId: selection.client.client_id,
 213+        conversationId: input.conversationId ?? conversation.id,
 214         headers: input.headers,
 215         id: requestId,
 216         method: requestMethod,
 217@@ -5697,6 +5712,7 @@ async function executeBrowserRequest(
 218       const stream = requireBrowserBridge(context).streamRequest({
 219         body: requestBody,
 220         clientId: targetClient.client_id,
 221+        conversationId: input.conversationId,
 222         headers: input.headers,
 223         id: requestId,
 224         idleTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.idleTimeoutMs,
 225@@ -5734,6 +5750,7 @@ async function executeBrowserRequest(
 226       action: "browser request",
 227       body: requestBody,
 228       clientId: targetClient.client_id,
 229+      conversationId: input.conversationId,
 230       headers: input.headers,
 231       id: requestId,
 232       method: requestMethod,
 233diff --git a/docs/api/README.md b/docs/api/README.md
 234index f3396284683286d562fb318fc9d4c11cecd07dea..c36e7831b6a005551bac9024bc63a3df572c9b76 100644
 235--- a/docs/api/README.md
 236+++ b/docs/api/README.md
 237@@ -153,15 +153,15 @@ Browser 面约定:
 238 - `conductor` 只保存并回显 `account`、`credential_fingerprint`、`endpoints`、`endpoint_metadata`、时间戳和 `fresh/stale/lost`
 239 - 原始 `cookie`、`token`、header 值不会入库,也不会出现在 `/v1/browser` 读接口里
 240 - 连接断开或流量老化后,持久化记录仍可读,但状态会从 `fresh` 变成 `stale` / `lost`
 241-- 当前浏览器本地代发面正式支持 `claude` 和 `chatgpt`;Gemini 目前仍只保留壳页和元数据上报,不在正式 HTTP relay 合同里
 242-- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` 当前必须显式提供 `path`
 243+- 当前浏览器本地代发面正式支持 `claude`、`chatgpt` 和 `gemini`;其中 Gemini 已进入正式 HTTP relay 合同
 244+- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` / `platform=gemini` 当前都必须显式提供 `path`
 245 - `POST /v1/browser/request` 支持 `responseMode=buffered` 和 `responseMode=sse`
 246 - SSE 响应固定用 `stream_open`、`stream_event`、`stream_end`、`stream_error` 作为 event name;`stream_event` 带递增 `seq`
 247 - `POST /v1/browser/actions` 当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore`,并返回结构化 `action_result`
 248 - `GET /v1/browser` 会回显当前风控默认值、最新 `shell_runtime` / `last_action_result` 和运行时 target/platform 状态,便于观察抖动、限流、退避和熔断
 249 - `/ws/firefox` 只在本地 listener 上可用,不是公网产品接口
 250 - `request` 的 Claude prompt 模式和 `current` 辅助读只有在 `mini` 上已有活跃 Firefox bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
 251-- ChatGPT raw relay 同样依赖真实浏览器里已捕获到的有效登录态 / header;建议先用 `GET /v1/browser?platform=chatgpt&status=fresh` 确认再发请求
 252+- ChatGPT / Gemini raw relay 同样依赖真实浏览器里已捕获到的有效登录态 / header;建议先用 `GET /v1/browser?platform=chatgpt&status=fresh`、`GET /v1/browser?platform=gemini&status=fresh` 确认再发请求
 253 - 如果当前没有活跃 Firefox client,会返回清晰的 `503` JSON 错误
 254 - 如果已连接 client 还没拿到 Claude 凭证,会返回 `409` JSON 错误并提示先在浏览器里完成一轮真实请求
 255 
 256diff --git a/docs/api/business-interfaces.md b/docs/api/business-interfaces.md
 257index a1632224cb9521f6f538153b1b71ce4a306f95b1..a141e4687f970ba1237fc51301e86704effb0cd6 100644
 258--- a/docs/api/business-interfaces.md
 259+++ b/docs/api/business-interfaces.md
 260@@ -79,8 +79,8 @@
 261 - 这个读面只返回 `account`、凭证指纹、端点元数据和时间戳状态;不会暴露原始 `cookie`、`token` 或 header 值
 262 - `GET /v1/browser` 现在会额外返回 `automation_conversations`,按 `platform + remote_conversation_id` 暴露当前对话的 `automation_status`、`pause_reason` 和 active link,供 Firefox 浮层和调试读面同步统一自动化状态
 263 - `records[].view` 会区分活跃连接与仅持久化记录,`status` 会暴露 `fresh`、`stale`、`lost`
 264-- 当前浏览器代发面正式支持 `claude` 和 `chatgpt`;Gemini 继续留在下一波
 265-- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` 当前必须显式带 `path`
 266+- 当前浏览器代发面正式支持 `claude`、`chatgpt` 和 `gemini`
 267+- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` / `platform=gemini` 当前都必须显式带 `path`
 268 - `POST /v1/browser/request` 支持 `responseMode=buffered` 和 `responseMode=sse`
 269 - `responseMode=sse` 会返回 `text/event-stream`,事件名固定为 `stream_open`、`stream_event`、`stream_end`、`stream_error`
 270 - `stream_event` 都带递增 `seq`;失败、超时或取消时会带着已收到的 partial 状态落到 `stream_error`
 271@@ -99,7 +99,7 @@
 272   - `repeated_message_count`
 273   - `repeated_renewal_count`
 274 - `conductor` 启动时会自动释放异常退出遗留的 `execution_state`,并把 `status=running` 的 renewal job 安全回排为 `pending`
 275-- ChatGPT raw relay 仍依赖浏览器里真实捕获到的登录态 / header;建议先看 `GET /v1/browser?platform=chatgpt&status=fresh`
 276+- ChatGPT / Gemini raw relay 仍依赖浏览器里真实捕获到的登录态 / header;建议先看 `GET /v1/browser?platform=chatgpt&status=fresh`、`GET /v1/browser?platform=gemini&status=fresh`
 277 - 如果没有活跃 Firefox bridge client,会返回 `503`
 278 - 如果 client 还没有 Claude 凭证快照,会返回 `409`
 279 - 打开、聚焦、重载标签页等 browser/plugin 管理动作已经移到 [`control-interfaces.md`](./control-interfaces.md) 和 `GET /describe/control`
 280diff --git a/docs/api/control-interfaces.md b/docs/api/control-interfaces.md
 281index c74587ed93bf5d74fecc5e2c8d19eebcf4c4b7fb..eb72fef2b821396567bc49718f21c5f8fa2d1a6e 100644
 282--- a/docs/api/control-interfaces.md
 283+++ b/docs/api/control-interfaces.md
 284@@ -97,7 +97,7 @@ browser/plugin 管理约定:
 285   - `disconnectMs` / `disconnect_ms` / `delayMs` / `delay_ms`
 286   - `repeatCount` / `repeat_count`
 287   - `repeatIntervalMs` / `repeat_interval_ms` / `intervalMs` / `interval_ms`
 288-- 当前正式 shell / credential 管理平台已覆盖 `claude` 和 `chatgpt`;Gemini 仍以空壳页和元数据上报为主
 289+- 当前正式 shell / credential 管理平台仍覆盖 `claude` 和 `chatgpt`;Gemini raw relay 已转入 business 面,但 control 面的 shell 管理合同没有在这轮一起扩面
 290 - 如果没有活跃 Firefox bridge client,会返回 `503`
 291 - 如果指定了不存在的 `clientId`,会返回 `409`
 292 - `POST /v1/browser/actions` 会等待插件回传结构化 `action_result`,返回 `accepted` / `completed` / `failed` / `reason` / `target` / `result` / `shell_runtime`
 293diff --git a/docs/firefox/README.md b/docs/firefox/README.md
 294index 6672ae4718907bac14dc772cfba3dbd591bcc9ec..20bb20bb51f9b067d2c95919397e9b3c291cef5b 100644
 295--- a/docs/firefox/README.md
 296+++ b/docs/firefox/README.md
 297@@ -194,14 +194,14 @@
 298 
 299 这条链路的关键边界:
 300 
 301-- 当前正式 relay 平台已支持 Claude 和 ChatGPT;其中 Claude 保留 prompt shortcut,ChatGPT 当前只支持显式 path 的 raw relay
 302+- 当前正式 relay 平台已支持 Claude、ChatGPT 和 Gemini;其中 Claude 保留 prompt shortcut,ChatGPT / Gemini 当前只支持显式 path 的 raw relay
 303 - `request` / `current` 通过插件已有的页面内 HTTP 代理完成,不是 DOM 自动化
 304 - `conductor` 不直接持有原始平台凭证
 305 - `responseMode=sse` 时,浏览器会通过 `stream_open` / `stream_event` / `stream_end` / `stream_error` 回传
 306 - `POST /v1/browser/request/cancel` 会向插件下发正式 `request_cancel`
 307 - 如果没有活跃 Firefox bridge client,会返回 `503`
 308 - 如果 client 还没有 Claude 凭证和 endpoint,Claude prompt helper 会返回 `409`
 309-- ChatGPT raw relay 同样依赖真实浏览器里已捕获到的有效 header / 登录态;Gemini 仍不在正式 HTTP relay 合同里
 310+- ChatGPT / Gemini raw relay 同样依赖真实浏览器里已捕获到的有效 header / 登录态;Gemini 现在会优先匹配持久化的会话级发送模板
 311 
 312 ## 启动和管理页
 313 
 314diff --git a/plans/STATUS_SUMMARY.md b/plans/STATUS_SUMMARY.md
 315index e66fa79d91e16b4a6f20af4137f23285bd57e487..1ca24524e04b1021004b380317e33244f8334a76 100644
 316--- a/plans/STATUS_SUMMARY.md
 317+++ b/plans/STATUS_SUMMARY.md
 318@@ -53,6 +53,7 @@
 319   - browser request 风控状态现在会持久化到 `artifact.db`,重启后会恢复限流/退避/熔断窗口,并回收遗留执行锁与 `running` renewal job
 320   - `proxy_delivery` 结果现在会异步补齐 Level 1 下游 HTTP 状态码;renewal dispatcher 仅在 `200` 时标记 `done`,`429/5xx` 改为 `retry`
 321   - ChatGPT send template 现在会持久化到插件本地缓存;controller 重载后会恢复最近模板,renewal dispatcher 对模板缺失改为 5s 起步短延迟 retry,并显式记录冷启动/预热完成日志
 322+  - Gemini send template 现在会按 shell / conversation 持久化到插件本地缓存;controller 重载后会恢复最近模板,`browser.proxy_delivery` 与 `/v1/browser/request` 都会优先匹配 Gemini 专用 raw relay builder
 323 
 324 ## 当前已纠正的文档/代码不一致
 325 
 326@@ -74,19 +75,16 @@
 327 **当前下一波任务:**
 328 
 329 1. `T-S065`:policy 配置化
 330-2. `T-S067`:Gemini 正式接入 raw relay 支持面
 331-3. `OPT-004`:Claude final-message 更稳 fallback
 332-4. `OPT-009`:renewal 模块重复工具函数抽取
 333+2. `OPT-004`:Claude final-message 更稳 fallback
 334+3. `OPT-009`:renewal 模块重复工具函数抽取
 335 
 336 **并行优化项:**
 337 
 338 1. `T-S065`
 339    让 policy 白名单配置化,为后续 automation control 指令扩面铺路
 340-2. `T-S067`
 341-   把 Gemini 提升到正式 raw relay 支持面,减少 helper/proxy mix 带来的脆弱性
 342-3. `OPT-004`
 343+2. `OPT-004`
 344    为 Claude final-message 增加更稳的 SSE fallback
 345-4. `OPT-009`
 346+3. `OPT-009`
 347    renewal 模块重复工具函数抽取,减少重复逻辑
 348 
 349 **已关闭的优化项:**
 350@@ -124,7 +122,7 @@ Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续
 351 - 已有本地对话/关联/续命任务、projector、dispatcher 和最小续命运维读接口
 352 - 当前主线已无 open bug blocker
 353 - `browser.chatgpt` / `browser.gemini` helper target 与 Gemini DOM delivery adapter 已在主线
 354-- 当前主要以 delivery 可靠性增强、policy 配置化和 Gemini raw relay 收口为主
 355+- 当前主要以 policy 配置化、Claude final-message fallback 和 renewal 代码卫生为主
 356 - 自动化仲裁、统一浮层控制、系统级暂停和重启后风控恢复都已完成
 357 
 358 之前的浏览器主链继续保持:
 359@@ -136,11 +134,11 @@ Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续
 360 
 361 ## 当前仍需关注
 362 
 363-- `Gemini` 当前仍不是 `/v1/browser/request` 的正式 raw relay 支持面;`@browser.gemini` 走 helper / proxy mix,仍需依赖最近观测到的真实请求上下文
 364+- `Gemini` 已正式纳入 `/v1/browser/request` raw relay 支持面;如果后续需要把 `tab_open` / `tab_restore` 等 control-shell 合同也正式扩到 Gemini,建议另开独立任务
 365 - ChatGPT proxy send 虽已持久化最近模板并在冷启动时走短延迟 retry,但如果 controller 重载前本地没有任何可恢复模板,首批 job 仍需等待下一次真实 ChatGPT 发送完成预热
 366 - Claude 的 `organizationId` 当前仍依赖最近观测到的 org 上下文,不是完整的多页多 org 精确映射
 367 - `proxy_delivery` 当前已补齐 Level 1 HTTP 接受确认,但仍不是“下游 AI 已完整回复”;Level 3 仍依赖后续 final-message 链路
 368 - ChatGPT root message / mapping 结构如果后续变化,final-message 提取启发式仍需跟进
 369 - recent relay cache 是有限窗口;极老 replay 超出窗口后,仍会落回 conductor dedupe
 370 - `status-api` 继续保留为显式 opt-in 兼容层,不是当前删除重点
 371-- 以上几项风险现主要拆成 `T-S067` 跟踪;`T-S068`、`T-S069` 已分别完成冷启动保护和首版 Level 1 收口
 372+- 以上几项风险现主要拆到 `T-S065`、`OPT-004`、`OPT-009` 跟踪;`T-S067`、`T-S068`、`T-S069` 已完成
 373diff --git a/plugins/baa-firefox/README.md b/plugins/baa-firefox/README.md
 374index 03e06c3becd008ce1368ed809844b2018ec6549b..bd8e495d47d8f28b052a95eb6d9db83103af1117 100644
 375--- a/plugins/baa-firefox/README.md
 376+++ b/plugins/baa-firefox/README.md
 377@@ -169,7 +169,7 @@ browser.runtime.sendMessage({
 378 
 379 - Claude 同源 API 代发
 380 - ChatGPT 同源 API 代发
 381-- Gemini 基于已发现模板的本地代发
 382+- Gemini 基于持久化 shell / conversation 发送模板的本地代发
 383 
 384 这些原始值只在浏览器内部使用,不进入 `conductor` 的 WS 元数据上报。
 385 
 386diff --git a/plugins/baa-firefox/controller.js b/plugins/baa-firefox/controller.js
 387index 8c596f187b6d3064ca6af05163779fcb760ce97d..4ba7953bfcc4f94229ca7fe53a6108c2ba16ed5d 100644
 388--- a/plugins/baa-firefox/controller.js
 389+++ b/plugins/baa-firefox/controller.js
 390@@ -24,6 +24,7 @@ const CONTROLLER_STORAGE_KEYS = {
 391   credentialFingerprintByPlatform: "baaFirefox.credentialFingerprintByPlatform",
 392   accountByPlatform: "baaFirefox.accountByPlatform",
 393   chatgptSendTemplates: "baaFirefox.chatgptSendTemplates",
 394+  geminiSendTemplates: "baaFirefox.geminiSendTemplates",
 395   geminiSendTemplate: "baaFirefox.geminiSendTemplate",
 396   claudeState: "baaFirefox.claudeState",
 397   finalMessageRelayCache: "baaFirefox.finalMessageRelayCache"
 398@@ -70,6 +71,10 @@ const REDACTED_CREDENTIAL_VALUE = "[redacted]";
 399 const ACCOUNT_EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
 400 const CHATGPT_SEND_TEMPLATE_LIMIT = 12;
 401 const CHATGPT_SEND_TEMPLATE_TTL = CREDENTIAL_TTL;
 402+const GEMINI_SEND_TEMPLATE_LIMIT = 12;
 403+const GEMINI_SEND_TEMPLATE_TTL = CREDENTIAL_TTL;
 404+const GEMINI_SHELL_TEMPLATE_KEY = "__shell__";
 405+const GEMINI_FALLBACK_TEMPLATE_KEY = "__fallback__";
 406 const CHATGPT_SESSION_COOKIE_PATTERNS = [
 407   /__secure-next-auth\.session-token=/i,
 408   /__secure-authjs\.session-token=/i,
 409@@ -223,7 +228,7 @@ const state = {
 410   lastCredentialHash: createPlatformMap(() => ""),
 411   lastCredentialSentAt: createPlatformMap(() => 0),
 412   chatgptSendTemplates: {},
 413-  geminiSendTemplate: null,
 414+  geminiSendTemplates: {},
 415   claudeState: createDefaultClaudeState(),
 416   controllerRuntime: createDefaultControllerRuntimeState(),
 417   finalMessageRelayObservers: createPlatformMap((platform) => createFinalMessageRelayObserver(platform)),
 418@@ -3122,7 +3127,29 @@ function hasGeminiTemplateHeaders(headers) {
 419     && (source["x-same-domain"] === "1" || Object.keys(source).some((name) => name.startsWith("x-goog-ext-")));
 420 }
 421 
 422-function parseGeminiSendTemplate(url, reqBody, reqHeaders = null) {
 423+function normalizeGeminiTemplateKey(conversationId = null, options = {}) {
 424+  const normalizedConversationId = trimToNull(conversationId);
 425+  if (normalizedConversationId) {
 426+    return normalizedConversationId;
 427+  }
 428+
 429+  return options.shellPage === true ? GEMINI_SHELL_TEMPLATE_KEY : GEMINI_FALLBACK_TEMPLATE_KEY;
 430+}
 431+
 432+function describeGeminiTemplateTarget(templateKey, template = null) {
 433+  const conversationId = trimToNull(template?.conversationId);
 434+  if (conversationId) {
 435+    return `conversation=${conversationId}`;
 436+  }
 437+
 438+  if (templateKey === GEMINI_SHELL_TEMPLATE_KEY || template?.shellPage === true) {
 439+    return "shell";
 440+  }
 441+
 442+  return "fallback";
 443+}
 444+
 445+function parseGeminiSendTemplate(context, url, reqBody, reqHeaders = null) {
 446   if (!isGeminiStreamGenerateUrl(url) || typeof reqBody !== "string" || !reqBody) return null;
 447 
 448   try {
 449@@ -3130,6 +3157,17 @@ function parseGeminiSendTemplate(url, reqBody, reqHeaders = null) {
 450     const parsedUrl = new URL(normalizedUrl, PLATFORMS.gemini.rootUrl);
 451     const headers = cloneHeaderMap(reqHeaders);
 452     if (Object.keys(headers).length > 0 && !hasGeminiTemplateHeaders(headers)) return null;
 453+    const pageUrl = trimToNull(context?.senderUrl || context?.pageUrl);
 454+    const conversationId =
 455+      trimToNull(context?.conversationId)
 456+      || extractGeminiConversationIdFromPageUrl(pageUrl || "")
 457+      || null;
 458+    const shellPage =
 459+      conversationId == null
 460+      && (
 461+        context?.isShellPage === true
 462+        || (pageUrl != null && isPlatformShellUrl("gemini", pageUrl, { allowFallback: true }))
 463+      );
 464     const params = new URLSearchParams(reqBody);
 465     const outerPayload = params.get("f.req");
 466     if (!outerPayload) return null;
 467@@ -3141,6 +3179,10 @@ function parseGeminiSendTemplate(url, reqBody, reqHeaders = null) {
 468     if (!Array.isArray(inner) || !Array.isArray(inner[0])) return null;
 469 
 470     return {
 471+      conversationId,
 472+      credentialFingerprint: trimToNull(state.credentialFingerprint.gemini),
 473+      pageUrl,
 474+      shellPage,
 475       url: normalizedUrl,
 476       reqBody,
 477       headers,
 478@@ -3153,11 +3195,150 @@ function parseGeminiSendTemplate(url, reqBody, reqHeaders = null) {
 479   }
 480 }
 481 
 482-function rememberGeminiSendTemplate(url, reqBody, reqHeaders = null) {
 483-  const next = parseGeminiSendTemplate(url, reqBody, reqHeaders);
 484+function normalizeGeminiSendTemplateEntry(value, templateKey = null, now = Date.now()) {
 485+  if (!isRecord(value)) {
 486+    return null;
 487+  }
 488+
 489+  const reqBody = typeof value.reqBody === "string" && value.reqBody.trim()
 490+    ? value.reqBody
 491+    : null;
 492+  const url = typeof value.url === "string" && value.url.trim()
 493+    ? value.url
 494+    : null;
 495+  const updatedAt = Number.isFinite(Number(value.updatedAt)) && Number(value.updatedAt) > 0
 496+    ? Math.round(Number(value.updatedAt))
 497+    : 0;
 498+
 499+  if (!reqBody || !url || updatedAt <= 0 || now - updatedAt > GEMINI_SEND_TEMPLATE_TTL) {
 500+    return null;
 501+  }
 502+
 503+  const normalizedPageUrl = trimToNull(value.pageUrl);
 504+  const normalizedConversationId =
 505+    trimToNull(value.conversationId)
 506+    || extractGeminiConversationIdFromPageUrl(normalizedPageUrl || "")
 507+    || null;
 508+  const shellPage = normalizedConversationId == null
 509+    && (
 510+      value.shellPage === true
 511+      || templateKey === GEMINI_SHELL_TEMPLATE_KEY
 512+    );
 513+  const parsed = parseGeminiSendTemplate({
 514+    conversationId: normalizedConversationId,
 515+    isShellPage: shellPage,
 516+    senderUrl: normalizedPageUrl
 517+  }, url, reqBody, value.headers);
 518+
 519+  if (!parsed) {
 520+    return null;
 521+  }
 522+
 523+  return {
 524+    ...parsed,
 525+    conversationId: normalizedConversationId,
 526+    credentialFingerprint: trimToNull(value.credentialFingerprint) || trimToNull(parsed.credentialFingerprint),
 527+    pageUrl: normalizedPageUrl || parsed.pageUrl || null,
 528+    shellPage,
 529+    updatedAt
 530+  };
 531+}
 532+
 533+function loadGeminiSendTemplates(raw, legacyRaw = null, now = Date.now()) {
 534+  const source = isRecord(raw) ? raw : {};
 535+  const normalized = [];
 536+
 537+  for (const [templateKey, entry] of Object.entries(source)) {
 538+    const template = normalizeGeminiSendTemplateEntry(entry, templateKey, now);
 539+    if (!template) continue;
 540+
 541+    normalized.push({
 542+      key: normalizeGeminiTemplateKey(template.conversationId, {
 543+        shellPage: template.shellPage === true
 544+      }),
 545+      ...template
 546+    });
 547+  }
 548+
 549+  if (normalized.length === 0) {
 550+    const legacyTemplate = normalizeGeminiSendTemplateEntry(
 551+      legacyRaw,
 552+      GEMINI_FALLBACK_TEMPLATE_KEY,
 553+      now
 554+    );
 555+    if (legacyTemplate) {
 556+      normalized.push({
 557+        key: normalizeGeminiTemplateKey(legacyTemplate.conversationId, {
 558+          shellPage: legacyTemplate.shellPage === true
 559+        }),
 560+        ...legacyTemplate
 561+      });
 562+    }
 563+  }
 564+
 565+  normalized.sort((left, right) => right.updatedAt - left.updatedAt);
 566+
 567+  const next = {};
 568+  for (const entry of normalized.slice(0, GEMINI_SEND_TEMPLATE_LIMIT)) {
 569+    if (next[entry.key]) {
 570+      continue;
 571+    }
 572+    next[entry.key] = {
 573+      conversationId: entry.conversationId,
 574+      credentialFingerprint: entry.credentialFingerprint,
 575+      headers: entry.headers,
 576+      pageUrl: entry.pageUrl,
 577+      prompt: entry.prompt,
 578+      reqBody: entry.reqBody,
 579+      reqId: entry.reqId,
 580+      shellPage: entry.shellPage,
 581+      updatedAt: entry.updatedAt,
 582+      url: entry.url
 583+    };
 584+  }
 585+
 586+  return next;
 587+}
 588+
 589+function serializeGeminiSendTemplates(now = Date.now()) {
 590+  return loadGeminiSendTemplates(state.geminiSendTemplates, null, now);
 591+}
 592+
 593+function serializeLegacyGeminiSendTemplate(now = Date.now()) {
 594+  const entries = Object.values(serializeGeminiSendTemplates(now))
 595+    .sort((left, right) => right.updatedAt - left.updatedAt);
 596+  return entries[0] || null;
 597+}
 598+
 599+function pruneGeminiSendTemplates(now = Date.now()) {
 600+  state.geminiSendTemplates = loadGeminiSendTemplates(state.geminiSendTemplates, null, now);
 601+}
 602+
 603+function invalidateGeminiSendTemplate(templateKey) {
 604+  const normalizedKey = trimToNull(templateKey);
 605+
 606+  if (!normalizedKey || !state.geminiSendTemplates[normalizedKey]) {
 607+    return false;
 608+  }
 609+
 610+  delete state.geminiSendTemplates[normalizedKey];
 611+  persistState().catch(() => {});
 612+  return true;
 613+}
 614+
 615+function rememberGeminiSendTemplate(context, url, reqBody, reqHeaders = null) {
 616+  const next = parseGeminiSendTemplate(context, url, reqBody, reqHeaders);
 617   if (!next) return false;
 618 
 619-  const previous = state.geminiSendTemplate;
 620+  const templateKey = normalizeGeminiTemplateKey(next.conversationId, {
 621+    shellPage: next.shellPage === true
 622+  });
 623+  const previous = normalizeGeminiSendTemplateEntry(
 624+    state.geminiSendTemplates[templateKey],
 625+    templateKey,
 626+    next.updatedAt
 627+  );
 628+
 629   if (Number.isFinite(previous?.reqId) && Number.isFinite(next.reqId)) {
 630     next.reqId = Math.max(previous.reqId, next.reqId);
 631   } else if (Number.isFinite(previous?.reqId) && !Number.isFinite(next.reqId)) {
 632@@ -3169,29 +3350,146 @@ function rememberGeminiSendTemplate(url, reqBody, reqHeaders = null) {
 633     || previous.reqBody !== next.reqBody
 634     || previous.prompt !== next.prompt
 635     || previous.reqId !== next.reqId
 636+    || previous.pageUrl !== next.pageUrl
 637+    || previous.conversationId !== next.conversationId
 638+    || previous.credentialFingerprint !== next.credentialFingerprint
 639     || JSON.stringify(previous.headers || {}) !== JSON.stringify(next.headers || {});
 640 
 641-  state.geminiSendTemplate = next;
 642+  state.geminiSendTemplates[templateKey] = next;
 643+  pruneGeminiSendTemplates(next.updatedAt);
 644   persistState().catch(() => {});
 645 
 646   if (changed) {
 647-    addLog("info", `已捕获 Gemini 发送模板 reqid=${next.reqId || "-"}`);
 648+    addLog(
 649+      "info",
 650+      previous
 651+        ? `已更新 Gemini 发送模板 ${describeGeminiTemplateTarget(templateKey, next)}`
 652+        : `Gemini 发送模板预热完成 ${describeGeminiTemplateTarget(templateKey, next)}`,
 653+      false
 654+    );
 655   }
 656   return true;
 657 }
 658 
 659+function getGeminiSendTemplateByKey(templateKey) {
 660+  const normalizedKey = trimToNull(templateKey);
 661+  if (!normalizedKey) {
 662+    return null;
 663+  }
 664+
 665+  const template = normalizeGeminiSendTemplateEntry(
 666+    state.geminiSendTemplates[normalizedKey],
 667+    normalizedKey
 668+  );
 669+
 670+  if (!template) {
 671+    invalidateGeminiSendTemplate(normalizedKey);
 672+    return null;
 673+  }
 674+
 675+  const currentFingerprint = trimToNull(state.credentialFingerprint.gemini);
 676+
 677+  if (
 678+    template.credentialFingerprint
 679+    && currentFingerprint
 680+    && template.credentialFingerprint !== currentFingerprint
 681+  ) {
 682+    invalidateGeminiSendTemplate(normalizedKey);
 683+    return null;
 684+  }
 685+
 686+  state.geminiSendTemplates[normalizedKey] = template;
 687+  return {
 688+    key: normalizedKey,
 689+    ...template
 690+  };
 691+}
 692+
 693+function selectMostRecentGeminiSendTemplate(excludedKeys = []) {
 694+  const excluded = new Set(excludedKeys.map((key) => trimToNull(key)).filter(Boolean));
 695+  const candidates = Object.keys(serializeGeminiSendTemplates())
 696+    .filter((key) => !excluded.has(key))
 697+    .map((key) => getGeminiSendTemplateByKey(key))
 698+    .filter(Boolean)
 699+    .sort((left, right) => right.updatedAt - left.updatedAt);
 700+
 701+  return candidates[0] || null;
 702+}
 703+
 704+function getGeminiSendTemplate(options = {}) {
 705+  const conversationId = trimToNull(options.conversationId);
 706+  const allowRecentFallback = options.allowRecentFallback === true;
 707+  const allowShellFallback = options.allowShellFallback === true;
 708+  const attemptedKeys = [];
 709+
 710+  if (conversationId) {
 711+    attemptedKeys.push(conversationId);
 712+    const exact = getGeminiSendTemplateByKey(conversationId);
 713+    if (exact) {
 714+      return {
 715+        match: "conversation",
 716+        ...exact
 717+      };
 718+    }
 719+  }
 720+
 721+  if (!conversationId || allowShellFallback) {
 722+    attemptedKeys.push(GEMINI_SHELL_TEMPLATE_KEY);
 723+    const shell = getGeminiSendTemplateByKey(GEMINI_SHELL_TEMPLATE_KEY);
 724+    if (shell) {
 725+      return {
 726+        match: "shell",
 727+        ...shell
 728+      };
 729+    }
 730+  }
 731+
 732+  if (!allowRecentFallback) {
 733+    return null;
 734+  }
 735+
 736+  const recent = selectMostRecentGeminiSendTemplate(attemptedKeys);
 737+  if (!recent) {
 738+    return null;
 739+  }
 740+
 741+  return {
 742+    match: recent.key === GEMINI_FALLBACK_TEMPLATE_KEY ? "fallback" : "recent",
 743+    ...recent
 744+  };
 745+}
 746+
 747 function extractGeminiXsrfToken(body) {
 748   const match = String(body || "").match(/"xsrf","([^"]+)"/);
 749   return match ? match[1] : null;
 750 }
 751 
 752-function updateGeminiTemplateXsrf(xsrfToken) {
 753-  if (!xsrfToken || !state.geminiSendTemplate?.reqBody) return false;
 754+function updateGeminiTemplateReqId(templateKey, reqId) {
 755+  const normalizedKey = trimToNull(templateKey);
 756+  if (!normalizedKey || !Number.isFinite(reqId)) return false;
 757+
 758+  const template = getGeminiSendTemplateByKey(normalizedKey);
 759+  if (!template) return false;
 760 
 761-  const params = new URLSearchParams(state.geminiSendTemplate.reqBody);
 762+  state.geminiSendTemplates[normalizedKey] = {
 763+    ...template,
 764+    reqId,
 765+    updatedAt: Date.now()
 766+  };
 767+  persistState().catch(() => {});
 768+  return true;
 769+}
 770+
 771+function updateGeminiTemplateXsrf(templateKey, xsrfToken) {
 772+  const normalizedKey = trimToNull(templateKey);
 773+  const template = normalizedKey ? getGeminiSendTemplateByKey(normalizedKey) : null;
 774+
 775+  if (!xsrfToken || !template?.reqBody) return false;
 776+
 777+  const params = new URLSearchParams(template.reqBody);
 778   params.set("at", xsrfToken);
 779-  state.geminiSendTemplate = {
 780-    ...state.geminiSendTemplate,
 781+  state.geminiSendTemplates[normalizedKey] = {
 782+    ...template,
 783     reqBody: params.toString(),
 784     updatedAt: Date.now()
 785   };
 786@@ -3667,7 +3965,8 @@ async function persistState() {
 787     [CONTROLLER_STORAGE_KEYS.credentialFingerprintByPlatform]: state.credentialFingerprint,
 788     [CONTROLLER_STORAGE_KEYS.accountByPlatform]: state.account,
 789     [CONTROLLER_STORAGE_KEYS.chatgptSendTemplates]: serializeChatgptSendTemplates(),
 790-    [CONTROLLER_STORAGE_KEYS.geminiSendTemplate]: state.geminiSendTemplate,
 791+    [CONTROLLER_STORAGE_KEYS.geminiSendTemplates]: serializeGeminiSendTemplates(),
 792+    [CONTROLLER_STORAGE_KEYS.geminiSendTemplate]: serializeLegacyGeminiSendTemplate(),
 793     [CONTROLLER_STORAGE_KEYS.finalMessageRelayCache]: serializeFinalMessageRelayCache(),
 794     [CONTROLLER_STORAGE_KEYS.claudeState]: {
 795       ...cloneClaudeState(state.claudeState),
 796@@ -4914,16 +5213,15 @@ function buildClaudeHeaders(apiPath, overrides = {}) {
 797   return headers;
 798 }
 799 
 800-function buildGeminiAutoRequest(prompt) {
 801-  const template = state.geminiSendTemplate;
 802-  if (!template?.url || !template?.reqBody) {
 803-    throw new Error("missing Gemini send template; send one real Gemini message first");
 804+function buildGeminiRequestFromTemplate(templateSelection, prompt) {
 805+  if (!templateSelection?.url || !templateSelection?.reqBody) {
 806+    throw new Error("missing Gemini send template");
 807   }
 808   const credential = requireCredentialState("gemini");
 809 
 810   try {
 811-    const url = new URL(template.url, PLATFORMS.gemini.rootUrl);
 812-    const params = new URLSearchParams(template.reqBody);
 813+    const url = new URL(templateSelection.url, PLATFORMS.gemini.rootUrl);
 814+    const params = new URLSearchParams(templateSelection.reqBody);
 815     const outerPayload = params.get("f.req");
 816     if (!outerPayload) throw new Error("template missing f.req");
 817 
 818@@ -4942,21 +5240,16 @@ function buildGeminiAutoRequest(prompt) {
 819     params.set("f.req", JSON.stringify(outer));
 820 
 821     const currentReqId = Number(url.searchParams.get("_reqid"));
 822-    const baseReqId = Number.isFinite(template.reqId) ? template.reqId : currentReqId;
 823+    const baseReqId = Number.isFinite(templateSelection.reqId) ? templateSelection.reqId : currentReqId;
 824     if (Number.isFinite(baseReqId)) {
 825       const nextReqId = baseReqId + 100000;
 826       url.searchParams.set("_reqid", String(nextReqId));
 827-      state.geminiSendTemplate = {
 828-        ...template,
 829-        reqId: nextReqId,
 830-        updatedAt: Date.now()
 831-      };
 832-      persistState().catch(() => {});
 833+      updateGeminiTemplateReqId(templateSelection.key, nextReqId);
 834     }
 835 
 836     const path = `${url.pathname || "/"}${url.search || ""}`;
 837-    const headerSource = hasGeminiTemplateHeaders(template.headers)
 838-      ? template.headers
 839+    const headerSource = hasGeminiTemplateHeaders(templateSelection.headers)
 840+      ? templateSelection.headers
 841       : credential.headers;
 842     const headers = buildProxyHeaders("gemini", path, headerSource);
 843     if (!hasGeminiTemplateHeaders(headers)) {
 844@@ -4964,15 +5257,69 @@ function buildGeminiAutoRequest(prompt) {
 845     }
 846 
 847     return {
 848-      path,
 849       body: params.toString(),
 850-      headers
 851+      headers,
 852+      path,
 853+      templateKey: templateSelection.key
 854     };
 855   } catch (error) {
 856     throw new Error(`构建 Gemini 请求失败:${error.message}`);
 857   }
 858 }
 859 
 860+function buildGeminiAutoRequest(prompt, options = {}) {
 861+  const conversationId = trimToNull(options.conversationId);
 862+  const templateSelection = getGeminiSendTemplate({
 863+    conversationId,
 864+    allowRecentFallback: options.allowRecentFallback !== false,
 865+    allowShellFallback: options.allowShellFallback === true
 866+  });
 867+
 868+  if (!templateSelection?.url || !templateSelection?.reqBody) {
 869+    throw new Error(
 870+      conversationId
 871+        ? `missing Gemini send template for conversation=${conversationId}; send one real Gemini message first`
 872+        : "missing Gemini send template; send one real Gemini message first"
 873+    );
 874+  }
 875+
 876+  return buildGeminiRequestFromTemplate(templateSelection, prompt);
 877+}
 878+
 879+function buildGeminiDeliveryRequest(options = {}) {
 880+  const conversationId = trimToNull(options.conversationId);
 881+  const messageText = trimToNull(options.messageText);
 882+  const shellPage = options.shellPage === true;
 883+
 884+  if (!messageText) {
 885+    throw new Error("delivery.invalid_payload: Gemini proxy_delivery requires message_text");
 886+  }
 887+
 888+  const templateSelection = getGeminiSendTemplate({
 889+    conversationId,
 890+    allowRecentFallback: conversationId == null,
 891+    allowShellFallback: shellPage
 892+  });
 893+
 894+  if (!templateSelection?.url || !templateSelection?.reqBody) {
 895+    throw new Error(
 896+      conversationId
 897+        ? `delivery.template_missing: missing Gemini send template for conversation=${conversationId}; send one real Gemini message in this conversation first`
 898+        : "delivery.template_missing: missing Gemini send template; send one real Gemini message first"
 899+    );
 900+  }
 901+
 902+  const request = buildGeminiRequestFromTemplate(templateSelection, messageText);
 903+
 904+  return {
 905+    body: request.body,
 906+    headers: request.headers,
 907+    method: "POST",
 908+    path: request.path,
 909+    templateKey: request.templateKey
 910+  };
 911+}
 912+
 913 function createPendingProxyRequest(id, meta = {}) {
 914   let ackSettled = false;
 915   let settled = false;
 916@@ -6687,7 +7034,7 @@ function handlePageNetwork(data, sender) {
 917   }
 918 
 919   if (context.platform === "gemini" && typeof data.reqBody === "string" && data.reqBody) {
 920-    rememberGeminiSendTemplate(data.url, data.reqBody, observedHeaders);
 921+    rememberGeminiSendTemplate(context, data.url, data.reqBody, observedHeaders);
 922   }
 923 
 924   collectEndpoint(context.platform, data.method, data.url);
 925@@ -6854,11 +7201,20 @@ function handlePageProxyResponse(data, sender) {
 926     && Number(data.status) === 400
 927   ) {
 928     const xsrfToken = extractGeminiXsrfToken(data.body);
 929-    if (updateGeminiTemplateXsrf(xsrfToken)) {
 930+    if (updateGeminiTemplateXsrf(pending.templateKey, xsrfToken)) {
 931       pending.attempts += 1;
 932       addLog("info", `Gemini xsrf 已刷新,正在重试代理 ${data.id}`);
 933       try {
 934-        const retry = buildGeminiAutoRequest(pending.prompt);
 935+        const retryTemplate = trimToNull(pending.templateKey)
 936+          ? getGeminiSendTemplateByKey(pending.templateKey)
 937+          : null;
 938+        const retry = retryTemplate
 939+          ? buildGeminiRequestFromTemplate(retryTemplate, pending.prompt)
 940+          : buildGeminiAutoRequest(pending.prompt, {
 941+              allowRecentFallback: true,
 942+              allowShellFallback: pending.shellPage === true,
 943+              conversationId: pending.conversationId
 944+            });
 945         postProxyRequestToTab(context.tabId, {
 946           id: data.id,
 947           platform: "gemini",
 948@@ -7337,20 +7693,40 @@ async function runProxyDeliveryAction(message) {
 949     throw createPausedPageDeliveryError("proxy_delivery", pausedPage, target.conversationId);
 950   }
 951 
 952-  const request = platform === "claude"
 953-    ? buildClaudeDeliveryProxyRequest(message, target)
 954-    : buildChatgptDeliveryRequest({
 955+  let request = null;
 956+
 957+  switch (platform) {
 958+    case "claude":
 959+      request = buildClaudeDeliveryProxyRequest(message, target);
 960+      break;
 961+    case "chatgpt":
 962+      request = buildChatgptDeliveryRequest({
 963         conversationId: target.conversationId,
 964         messageText: message?.message_text || message?.messageText,
 965         sourceAssistantMessageId: message?.assistant_message_id || message?.assistantMessageId
 966       });
 967+      break;
 968+    case "gemini":
 969+      request = buildGeminiDeliveryRequest({
 970+        conversationId: target.conversationId,
 971+        messageText: message?.message_text || message?.messageText,
 972+        shellPage: target.shellPage === true
 973+      });
 974+      break;
 975+    default:
 976+      throw new Error(`未知平台:${platform}`);
 977+  }
 978+
 979   const proxyRequestId = `${planId}:proxy_delivery`;
 980   const pending = createPendingProxyRequest(proxyRequestId, {
 981+    conversationId: target.conversationId,
 982     method: request.method,
 983     path: request.path,
 984     platform,
 985     responseMode: "sse",
 986+    shellPage: target.shellPage === true,
 987     tabId: target.tab.id,
 988+    templateKey: request.templateKey || null,
 989     timeoutMs
 990   });
 991 
 992@@ -7479,16 +7855,29 @@ async function runDeliveryAction(message, command) {
 993 }
 994 
 995 async function proxyApiRequest(message) {
 996-  const { id, platform, method = "GET", path: apiPath, body = null } = message || {};
 997+  const {
 998+    conversation_id: rawConversationId,
 999+    conversationId: rawConversationIdCamel,
1000+    id,
1001+    platform,
1002+    method = "GET",
1003+    path: apiPath,
1004+    body = null
1005+  } = message || {};
1006   if (!id) throw new Error("缺少代理请求 ID");
1007   if (!platform || !PLATFORMS[platform]) throw new Error(`未知平台:${platform || "-"}`);
1008   if (!apiPath) throw new Error("缺少代理请求路径");
1009 
1010   const responseMode = String(message?.response_mode || message?.responseMode || "buffered").toLowerCase();
1011   const streamId = trimToNull(message?.stream_id) || trimToNull(message?.streamId) || id;
1012+  const conversationId = trimToNull(rawConversationId) || trimToNull(rawConversationIdCamel);
1013   const prompt = platform === "gemini" ? extractPromptFromProxyBody(body) : null;
1014   const geminiAutoRequest = platform === "gemini" && prompt && isGeminiStreamGenerateUrl(apiPath)
1015-    ? buildGeminiAutoRequest(prompt)
1016+    ? buildGeminiAutoRequest(prompt, {
1017+        allowRecentFallback: true,
1018+        allowShellFallback: true,
1019+        conversationId
1020+      })
1021     : null;
1022 
1023   return executeProxyRequest({
1024@@ -7499,11 +7888,14 @@ async function proxyApiRequest(message) {
1025     body: geminiAutoRequest ? geminiAutoRequest.body : body,
1026     headers: geminiAutoRequest ? geminiAutoRequest.headers : buildProxyHeaders(platform, apiPath)
1027   }, {
1028+    conversationId,
1029     platform,
1030     prompt,
1031     responseMode,
1032     streamId,
1033-    attempts: 0
1034+    attempts: 0,
1035+    shellPage: false,
1036+    templateKey: geminiAutoRequest?.templateKey || null
1037   });
1038 }
1039 
1040@@ -8066,7 +8458,10 @@ async function init() {
1041   state.chatgptSendTemplates = loadChatgptSendTemplates(
1042     saved[CONTROLLER_STORAGE_KEYS.chatgptSendTemplates]
1043   );
1044-  state.geminiSendTemplate = saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplate] || null;
1045+  state.geminiSendTemplates = loadGeminiSendTemplates(
1046+    saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplates],
1047+    saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplate]
1048+  );
1049   restoreFinalMessageRelayCache(saved[CONTROLLER_STORAGE_KEYS.finalMessageRelayCache]);
1050   state.claudeState = loadClaudeState(saved[CONTROLLER_STORAGE_KEYS.claudeState]);
1051   state.controllerRuntime = loadControllerRuntimeState(saved[CONTROLLER_STORAGE_KEYS.controllerRuntime]);
1052@@ -8163,12 +8558,15 @@ function exposeControllerTestApi() {
1053   }
1054 
1055   Object.assign(target, {
1056+    buildGeminiAutoRequest,
1057+    buildGeminiDeliveryRequest,
1058     buildChatgptDeliveryRequest,
1059     buildPageControlSnapshotForSender,
1060     connectWs,
1061     createPluginDiagnosticPayload,
1062     flushBufferedPluginDiagnosticLogs,
1063     getChatgptSendTemplate,
1064+    getGeminiSendTemplate,
1065     getSenderContext,
1066     handlePageBridgeReady,
1067     handlePageDiagnosticLog,
1068@@ -8176,8 +8574,10 @@ function exposeControllerTestApi() {
1069     handlePageSse,
1070     handleWsStateSnapshot,
1071     loadChatgptSendTemplates,
1072+    loadGeminiSendTemplates,
1073     persistFinalMessageRelayCache,
1074     rememberChatgptSendTemplate,
1075+    rememberGeminiSendTemplate,
1076     reinjectAllOpenPlatformTabs,
1077     reinjectPlatformTabs,
1078     restoreFinalMessageRelayCache,
1079@@ -8187,6 +8587,7 @@ function exposeControllerTestApi() {
1080     runPluginManagementAction,
1081     sendPluginDiagnosticLog,
1082     serializeChatgptSendTemplates,
1083+    serializeGeminiSendTemplates,
1084     serializeFinalMessageRelayCache,
1085     setDesiredTabState,
1086     syncPageControlFromContext,
1087diff --git a/plugins/baa-firefox/controller.test.cjs b/plugins/baa-firefox/controller.test.cjs
1088index 7612bb660e7410a1fb2ff9e8045bbb2f5f023a96..01dabd952b971e99e4d7a51c49dd2848f11af527 100644
1089--- a/plugins/baa-firefox/controller.test.cjs
1090+++ b/plugins/baa-firefox/controller.test.cjs
1091@@ -156,6 +156,12 @@ function createControllerHarness(options = {}) {
1092   };
1093 }
1094 
1095+function parseGeminiRequestTuple(reqBody) {
1096+  const params = new URLSearchParams(reqBody);
1097+  const outer = JSON.parse(params.get("f.req"));
1098+  return JSON.parse(outer[1]);
1099+}
1100+
1101 test("controller flushes buffered plugin diagnostic logs after the WS opens", () => {
1102   const { api } = createControllerHarness();
1103 
1104@@ -361,7 +367,7 @@ test("controller applies websocket automation snapshots to control and page stat
1105 });
1106 
1107 test("controller restores persisted ChatGPT send templates and reuses them for proxy delivery", () => {
1108-  const nowMs = Date.UTC(2026, 3, 1, 10, 0, 0);
1109+  const nowMs = Date.now();
1110   const { api } = createControllerHarness();
1111 
1112   api.state.credentialFingerprint.chatgpt = "fp-chatgpt-1";
1113@@ -423,7 +429,7 @@ test("controller restores persisted ChatGPT send templates and reuses them for p
1114 });
1115 
1116 test("controller invalidates persisted ChatGPT templates when the credential fingerprint changes", () => {
1117-  const nowMs = Date.UTC(2026, 3, 1, 10, 5, 0);
1118+  const nowMs = Date.now();
1119   const { api } = createControllerHarness();
1120 
1121   api.state.chatgptSendTemplates = api.loadChatgptSendTemplates({
1122@@ -458,3 +464,110 @@ test("controller invalidates persisted ChatGPT templates when the credential fin
1123   assert.equal(template, null);
1124   assert.equal(Object.keys(api.serializeChatgptSendTemplates(nowMs)).length, 0);
1125 });
1126+
1127+test("controller restores persisted Gemini send templates and reuses the matching conversation template for proxy delivery", () => {
1128+  const nowMs = Date.now();
1129+  const templateUrl = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate?_reqid=420000&rt=c";
1130+  const requestHeaders = {
1131+    "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
1132+    cookie: "__Secure-1PSID=test-session",
1133+    "x-goog-authuser": "0",
1134+    "x-same-domain": "1"
1135+  };
1136+  const templateBody = new URLSearchParams({
1137+    at: "xsrf-token-1",
1138+    "f.req": JSON.stringify([
1139+      null,
1140+      JSON.stringify([
1141+        ["hello from gemini"],
1142+        null,
1143+        ["conv-gemini-persisted", "resp-1", "choice-1"]
1144+      ])
1145+    ])
1146+  }).toString();
1147+  const { api } = createControllerHarness();
1148+
1149+  api.state.credentialFingerprint.gemini = "fp-gemini-1";
1150+  api.rememberGeminiSendTemplate({
1151+    conversationId: "conv-gemini-persisted",
1152+    isShellPage: false,
1153+    senderUrl: "https://gemini.google.com/app/conv-gemini-persisted"
1154+  }, templateUrl, templateBody, requestHeaders);
1155+
1156+  const savedTemplates = api.serializeGeminiSendTemplates(nowMs);
1157+  const { api: restoredApi } = createControllerHarness();
1158+
1159+  restoredApi.state.geminiSendTemplates = restoredApi.loadGeminiSendTemplates(savedTemplates, null, nowMs);
1160+  restoredApi.state.credentialFingerprint.gemini = "fp-gemini-1";
1161+  restoredApi.state.trackedTabs.gemini = 29;
1162+  restoredApi.state.lastHeaders.gemini = requestHeaders;
1163+  restoredApi.state.credentialCapturedAt.gemini = nowMs;
1164+  restoredApi.state.lastCredentialAt.gemini = nowMs;
1165+  restoredApi.state.lastCredentialTabId.gemini = 29;
1166+  restoredApi.state.lastCredentialUrl.gemini = templateUrl;
1167+
1168+  const template = restoredApi.getGeminiSendTemplate({
1169+    conversationId: "conv-gemini-persisted"
1170+  });
1171+  const request = restoredApi.buildGeminiDeliveryRequest({
1172+    conversationId: "conv-gemini-persisted",
1173+    messageText: "follow-up from persisted Gemini template"
1174+  });
1175+  const requestTuple = parseGeminiRequestTuple(request.body);
1176+  const requestPath = new URL(request.path, "https://gemini.google.com/");
1177+
1178+  assert.ok(template);
1179+  assert.equal(template.match, "conversation");
1180+  assert.equal(template.credentialFingerprint, "fp-gemini-1");
1181+  assert.equal(request.method, "POST");
1182+  assert.equal(request.templateKey, "conv-gemini-persisted");
1183+  assert.equal(
1184+    requestPath.pathname,
1185+    "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
1186+  );
1187+  assert.equal(Number(requestPath.searchParams.get("_reqid")) > 420000, true);
1188+  assert.equal(requestTuple[0][0], "follow-up from persisted Gemini template");
1189+  assert.deepEqual(requestTuple[2], ["conv-gemini-persisted", "resp-1", "choice-1"]);
1190+  assert.equal(request.headers["content-type"], "application/x-www-form-urlencoded;charset=UTF-8");
1191+  assert.equal(request.headers["x-same-domain"], "1");
1192+});
1193+
1194+test("controller invalidates persisted Gemini templates when the credential fingerprint changes", () => {
1195+  const nowMs = Date.now();
1196+  const { api } = createControllerHarness();
1197+
1198+  api.state.geminiSendTemplates = api.loadGeminiSendTemplates({
1199+    "conv-gemini-fingerprint-mismatch": {
1200+      conversationId: "conv-gemini-fingerprint-mismatch",
1201+      credentialFingerprint: "fp-old",
1202+      headers: {
1203+        "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
1204+        "x-same-domain": "1"
1205+      },
1206+      pageUrl: "https://gemini.google.com/app/conv-gemini-fingerprint-mismatch",
1207+      reqBody: new URLSearchParams({
1208+        at: "xsrf-token-old",
1209+        "f.req": JSON.stringify([
1210+          null,
1211+          JSON.stringify([
1212+            ["hello"],
1213+            null,
1214+            ["conv-gemini-fingerprint-mismatch", "resp-old", "choice-old"]
1215+          ])
1216+        ])
1217+      }).toString(),
1218+      reqId: 520000,
1219+      shellPage: false,
1220+      updatedAt: nowMs,
1221+      url: "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate?_reqid=520000&rt=c"
1222+    }
1223+  }, null, nowMs);
1224+  api.state.credentialFingerprint.gemini = "fp-new";
1225+
1226+  const template = api.getGeminiSendTemplate({
1227+    conversationId: "conv-gemini-fingerprint-mismatch"
1228+  });
1229+
1230+  assert.equal(template, null);
1231+  assert.equal(Object.keys(api.serializeGeminiSendTemplates(nowMs)).length, 0);
1232+});
1233diff --git a/tasks/T-S067.md b/tasks/T-S067.md
1234index 4991bdb137ba9285872e4b4a7899a7548b1efd6b..54833dc0f2fc1a9279f970a4b17aad091868d4c8 100644
1235--- a/tasks/T-S067.md
1236+++ b/tasks/T-S067.md
1237@@ -2,7 +2,7 @@
1238 
1239 ## 状态
1240 
1241-- 当前状态:`待开始`
1242+- 当前状态:`已完成`
1243 - 规模预估:`M`
1244 - 依赖任务:无
1245 - 建议执行者:`Codex`
1246@@ -74,22 +74,23 @@ Gemini 当前不是 `/v1/browser/request` 的正式 raw relay 支持面。`@brow
1247 
1248 ### 开始执行
1249 
1250-- 执行者:
1251-- 开始时间:
1252+- 执行者:Codex
1253+- 开始时间:2026-04-01 17:35 CST
1254 - 状态变更:`待开始` → `进行中`
1255 
1256 ### 完成摘要
1257 
1258-- 完成时间:
1259+- 完成时间:2026-04-01 18:31 CST
1260 - 状态变更:`进行中` → `已完成`
1261-- 修改了哪些文件:
1262-- 核心实现思路:
1263-- 跑了哪些测试:
1264+- 修改了哪些文件:`plugins/baa-firefox/controller.js`、`plugins/baa-firefox/controller.test.cjs`、`plugins/baa-firefox/README.md`、`apps/conductor-daemon/src/browser-types.ts`、`apps/conductor-daemon/src/firefox-bridge.ts`、`apps/conductor-daemon/src/local-api.ts`、`apps/conductor-daemon/src/index.test.js`、`docs/api/README.md`、`docs/api/business-interfaces.md`、`docs/api/control-interfaces.md`、`docs/firefox/README.md`、`tasks/T-S067.md`、`tasks/TASK_OVERVIEW.md`、`plans/STATUS_SUMMARY.md`
1265+- 核心实现思路:把 Gemini 从单一 `geminiSendTemplate` 升级成可持久化的 shell / conversation 模板缓存,补 TTL 与 credential fingerprint 校验,并让 controller 重载后恢复;`browser.proxy_delivery` 新增 Gemini 专用 raw builder,按目标 conversation 精确命中模板并把 XSRF 重试绑定到命中的模板;conductor 侧把 Gemini 纳入 `/v1/browser/request` 正式 supported platforms,并把 `conversationId` 透传到 Firefox bridge `api_request` / `stream_request`,用于模板匹配。
1266+- 跑了哪些测试:`node --test plugins/baa-firefox/controller.test.cjs`、`node --test plugins/baa-firefox/final-message.test.cjs plugins/baa-firefox/delivery-adapters.test.cjs`、`pnpm -C apps/conductor-daemon test`
1267 
1268 ### 执行过程中遇到的问题
1269 
1270-- 
1271+- 新建 worktree 默认没有 `node_modules`,`pnpm -C apps/conductor-daemon test` 初次执行时因 `pnpm exec tsc` 找不到命令失败;通过复用主工作区 `/Users/george/code/baa-conductor` 的根目录与 `apps/conductor-daemon` 依赖后完成测试,无需额外改源码。
1272 
1273 ### 剩余风险
1274 
1275-- 
1276+- Gemini 的 `f.req` 结构仍是上游私有协议;如果 Google 后续调整 `StreamGenerate` payload 形态,模板提取或会话级匹配逻辑仍需要跟进。
1277+- 这轮只把 Gemini 纳入 business 面的正式 raw relay 合同;`/v1/browser/actions` 的正式 shell 管理 supported platforms 仍维持 `claude` / `chatgpt` 口径,如需扩面应另立任务。
1278diff --git a/tasks/TASK_OVERVIEW.md b/tasks/TASK_OVERVIEW.md
1279index 3b3a57a2492fdf11cfc13c602de1f17bd7598d91..df84b60f9c1dde1ac5efb9fe535e168f752c93bf 100644
1280--- a/tasks/TASK_OVERVIEW.md
1281+++ b/tasks/TASK_OVERVIEW.md
1282@@ -51,6 +51,7 @@
1283   - browser request 风控状态现在会持久化到 `artifact.db`,重启后会恢复限流/退避/熔断窗口,并清理遗留执行锁与 `running` renewal job
1284   - `proxy_delivery` 结果现在会异步补齐 Level 1 下游 HTTP 状态码;renewal dispatcher 仅在 `200` 时标记 `done`,`429/5xx` 改为 `retry`
1285   - ChatGPT send template 现在会持久化到插件本地缓存;controller 重载后会恢复最近模板,renewal dispatcher 对模板缺失改为 5s 起步短延迟 retry,并显式记录冷启动/预热完成日志
1286+  - Gemini send template 现在会按 shell / conversation 持久化到插件本地缓存;controller 重载后会恢复最近模板,`browser.proxy_delivery` 与 `/v1/browser/request` 都会优先匹配 Gemini 专用 raw relay builder
1287 
1288 ## 当前已确认的不一致
1289 
1290@@ -86,6 +87,7 @@
1291 | [`T-S063`](./T-S063.md) | normalize / parse 错误隔离 | S | 无 | Codex | 已完成 |
1292 | [`T-S064`](./T-S064.md) | timed-jobs 异步日志写入 | S | 无 | Codex | 已完成 |
1293 | [`T-S066`](./T-S066.md) | 风控状态持久化 | M | T-S060 | Codex | 已完成 |
1294+| [`T-S067`](./T-S067.md) | Gemini 正式接入 raw relay 支持面 | M | 无 | Codex | 已完成 |
1295 | [`T-S068`](./T-S068.md) | ChatGPT proxy send 冷启动降级保护 | S | 无 | Codex | 已完成 |
1296 | [`T-S069`](./T-S069.md) | proxy_delivery 成功语义增强 | L | T-S060 | Codex | 已完成 |
1297 
1298@@ -94,7 +96,6 @@
1299 | 项目 | 标题 | 类型 | 状态 | 说明 |
1300 |---|---|---|---|---|
1301 | [`T-S065`](./T-S065.md) | policy 配置化 | task | 待开始 | 为自动化控制指令和后续扩面提供策略入口 |
1302-| [`T-S067`](./T-S067.md) | Gemini 正式接入 raw relay 支持面 | task | 待开始 | 把 `@browser.gemini` 提升到稳定 raw relay 支持面 |
1303 | [`../bugs/OPT-004-final-message-claude-sse-fallback.md`](../bugs/OPT-004-final-message-claude-sse-fallback.md) | Claude final-message SSE fallback | opt | open | 降低上游 SSE 协议变化的脆弱性 |
1304 | [`../bugs/OPT-009-renewal-duplicate-utility-functions.md`](../bugs/OPT-009-renewal-duplicate-utility-functions.md) | renewal 工具函数去重 | opt | open | 收口重复逻辑,属于低风险代码卫生 |
1305 
1306@@ -145,7 +146,6 @@
1307 ### P1(并行优化)
1308 
1309 - [`T-S065`](./T-S065.md)
1310-- [`T-S067`](./T-S067.md)
1311 - [`../bugs/OPT-004-final-message-claude-sse-fallback.md`](../bugs/OPT-004-final-message-claude-sse-fallback.md)
1312 - [`../bugs/OPT-009-renewal-duplicate-utility-functions.md`](../bugs/OPT-009-renewal-duplicate-utility-functions.md)
1313 
1314@@ -182,10 +182,9 @@
1315 
1316 ## 当前主线判断
1317 
1318-Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060`、`T-S061`、`T-S062`、`T-S063`、`T-S064`、`T-S066`、`T-S068`、`T-S069` 已经落地。当前主线已经没有 open bug blocker,下一步是:
1319+Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060`、`T-S061`、`T-S062`、`T-S063`、`T-S064`、`T-S066`、`T-S067`、`T-S068`、`T-S069` 已经落地。当前主线已经没有 open bug blocker,下一步是:
1320 
1321 - 先做 `T-S065`
1322-- 再推进 `T-S067`
1323 - `OPT-004`、`OPT-009` 继续保留为 open opt
1324 
1325 ## 现在该读什么