baa-conductor

git clone 

commit
e7c1701
parent
756d5d5
author
codex@macbookpro
date
2026-03-30 15:37:10 +0800 CST
feat: add renewal automation conversation controls
6 files changed,  +1164, -8
M apps/conductor-daemon/src/firefox-ws.ts
+39, -0
 1@@ -3,6 +3,7 @@ import { appendFileSync } from "node:fs";
 2 import type { IncomingMessage } from "node:http";
 3 import { join } from "node:path";
 4 import type { Socket } from "node:net";
 5+import type { ArtifactStore } from "../../../packages/artifact-db/dist/index.js";
 6 import type { ControlPlaneRepository } from "../../../packages/db/dist/index.js";
 7 
 8 import { BaaBrowserDeliveryBridge } from "./artifacts/upload-session.js";
 9@@ -27,6 +28,7 @@ import type {
10 import type { BaaLiveInstructionIngest } from "./instructions/ingest.js";
11 import { buildSystemStateData, setAutomationMode } from "./local-api.js";
12 import type { ConductorRuntimeSnapshot } from "./index.js";
13+import { observeRenewalConversation } from "./renewal/conversations.js";
14 
15 const FIREFOX_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
16 const FIREFOX_WS_PROTOCOL = "baa.firefox.local";
17@@ -47,6 +49,7 @@ type IntervalHandle = ReturnType<typeof globalThis.setInterval>;
18 type FirefoxWsAction = "pause" | "resume" | "drain";
19 
20 interface FirefoxWebSocketServerOptions {
21+  artifactStore?: ArtifactStore | null;
22   artifactInlineThreshold?: number | null;
23   artifactSummaryLength?: number | null;
24   baseUrlLoader: () => string;
25@@ -1004,6 +1007,7 @@ class FirefoxWebSocketConnection {
26 }
27 
28 export class ConductorFirefoxWebSocketServer {
29+  private readonly artifactStore: ArtifactStore | null;
30   private readonly baseUrlLoader: () => string;
31   private readonly bridgeService: FirefoxBridgeService;
32   private readonly deliveryBridge: BaaBrowserDeliveryBridge;
33@@ -1021,6 +1025,7 @@ export class ConductorFirefoxWebSocketServer {
34   private pollTimer: IntervalHandle | null = null;
35 
36   constructor(options: FirefoxWebSocketServerOptions) {
37+    this.artifactStore = options.artifactStore ?? null;
38     this.baseUrlLoader = options.baseUrlLoader;
39     this.ingestLogDir = options.ingestLogDir ?? null;
40     this.instructionIngest = options.instructionIngest ?? null;
41@@ -1656,6 +1661,7 @@ export class ConductorFirefoxWebSocketServer {
42 
43     connection.addFinalMessage(finalMessage);
44     this.deliveryBridge.observeRoute(route);
45+    await this.observeRenewalConversation(connection, finalMessage, route);
46     await this.broadcastStateSnapshot("browser.final_message");
47 
48     this.writeIngestLog({
49@@ -1728,6 +1734,39 @@ export class ConductorFirefoxWebSocketServer {
50     }
51   }
52 
53+  private async observeRenewalConversation(
54+    connection: FirefoxWebSocketConnection,
55+    finalMessage: {
56+      assistant_message_id: string;
57+      conversation_id: string | null;
58+      observed_at: number;
59+      page_title: string | null;
60+      page_url: string | null;
61+      platform: string;
62+    },
63+    route: BaaDeliveryRouteSnapshot | null
64+  ): Promise<void> {
65+    if (this.artifactStore == null) {
66+      return;
67+    }
68+
69+    try {
70+      await observeRenewalConversation({
71+        assistantMessageId: finalMessage.assistant_message_id,
72+        clientId: connection.getClientId(),
73+        observedAt: finalMessage.observed_at,
74+        pageTitle: finalMessage.page_title,
75+        pageUrl: finalMessage.page_url,
76+        platform: finalMessage.platform,
77+        remoteConversationId: finalMessage.conversation_id,
78+        route,
79+        store: this.artifactStore
80+      });
81+    } catch (error) {
82+      console.error(`[baa-renewal] observe conversation failed: ${String(error)}`);
83+    }
84+  }
85+
86   private handlePluginDiagnosticLog(
87     connection: FirefoxWebSocketConnection,
88     message: Record<string, unknown>
M apps/conductor-daemon/src/index.test.js
+142, -0
  1@@ -5642,6 +5642,148 @@ test("ConductorRuntime routes browser.final_message into live instruction ingest
  2   }
  3 });
  4 
  5+test("ConductorRuntime persists renewal conversation links from browser.final_message and exposes renewal controls", async () => {
  6+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-renewal-control-"));
  7+  const runtime = new ConductorRuntime(
  8+    {
  9+      nodeId: "mini-main",
 10+      host: "mini",
 11+      role: "primary",
 12+      controlApiBase: "https://control.example.test",
 13+      localApiBase: "http://127.0.0.1:0",
 14+      sharedToken: "replace-me",
 15+      paths: {
 16+        runsDir: "/tmp/runs",
 17+        stateDir
 18+      }
 19+    },
 20+    {
 21+      autoStartLoops: false,
 22+      now: () => 200
 23+    }
 24+  );
 25+
 26+  let client = null;
 27+
 28+  try {
 29+    const snapshot = await runtime.start();
 30+    const baseUrl = snapshot.controlApi.localApiBase;
 31+    client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-renewal-control");
 32+
 33+    client.socket.send(
 34+      JSON.stringify({
 35+        type: "browser.final_message",
 36+        platform: "chatgpt",
 37+        conversation_id: "conv-renewal-control",
 38+        assistant_message_id: "msg-renewal-control-1",
 39+        raw_text: "hello renewal control",
 40+        observed_at: 1_710_000_030_000,
 41+        page_title: "ChatGPT Renewal",
 42+        page_url: "https://chatgpt.com/c/conv-renewal-control",
 43+        tab_id: 42
 44+      })
 45+    );
 46+
 47+    const linksPayload = await waitForCondition(async () => {
 48+      const linksResponse = await fetch(
 49+        `${baseUrl}/v1/renewal/links?platform=chatgpt&remote_conversation_id=conv-renewal-control`
 50+      );
 51+      assert.equal(linksResponse.status, 200);
 52+      const payload = await linksResponse.json();
 53+      assert.equal(payload.data.count, 1);
 54+      return payload;
 55+    }, 5_000, 50);
 56+    assert.equal(linksPayload.data.links[0].route.pattern, "/c/:conversationId");
 57+    assert.equal(linksPayload.data.links[0].target.kind, "browser.proxy_delivery");
 58+    assert.equal(linksPayload.data.links[0].target.payload.tabId, 42);
 59+    const localConversationId = linksPayload.data.links[0].local_conversation_id;
 60+
 61+    const conversationResponse = await fetch(`${baseUrl}/v1/renewal/conversations/${localConversationId}`);
 62+    assert.equal(conversationResponse.status, 200);
 63+    const conversationPayload = await conversationResponse.json();
 64+    assert.equal(conversationPayload.data.local_conversation_id, localConversationId);
 65+    assert.equal(conversationPayload.data.automation_status, "manual");
 66+    assert.equal(conversationPayload.data.last_message_id, "msg-renewal-control-1");
 67+    assert.equal(conversationPayload.data.links.length, 1);
 68+
 69+    const autoResponse = await fetch(
 70+      `${baseUrl}/v1/renewal/conversations/${localConversationId}/auto`,
 71+      {
 72+        method: "POST"
 73+      }
 74+    );
 75+    assert.equal(autoResponse.status, 200);
 76+    const autoPayload = await autoResponse.json();
 77+    assert.equal(autoPayload.data.automation_status, "auto");
 78+    assert.equal(autoPayload.data.paused_at, undefined);
 79+
 80+    const pausedResponse = await fetch(
 81+      `${baseUrl}/v1/renewal/conversations/${localConversationId}/paused`,
 82+      {
 83+        method: "POST"
 84+      }
 85+    );
 86+    assert.equal(pausedResponse.status, 200);
 87+    const pausedPayload = await pausedResponse.json();
 88+    assert.equal(pausedPayload.data.automation_status, "paused");
 89+    assert.equal(typeof pausedPayload.data.paused_at, "number");
 90+
 91+    client.socket.send(
 92+      JSON.stringify({
 93+        type: "browser.final_message",
 94+        platform: "chatgpt",
 95+        conversation_id: "conv-renewal-control",
 96+        assistant_message_id: "msg-renewal-control-2",
 97+        raw_text: "hello renewal control updated",
 98+        observed_at: 1_710_000_031_000,
 99+        page_title: "ChatGPT Renewal Updated",
100+        page_url: "https://chatgpt.com/c/conv-renewal-control",
101+        tab_id: 42
102+      })
103+    );
104+
105+    const updatedConversationPayload = await waitForCondition(async () => {
106+      const updatedConversationResponse = await fetch(
107+        `${baseUrl}/v1/renewal/conversations/${localConversationId}`
108+      );
109+      assert.equal(updatedConversationResponse.status, 200);
110+      const payload = await updatedConversationResponse.json();
111+      assert.equal(payload.data.last_message_id, "msg-renewal-control-2");
112+      return payload;
113+    }, 5_000, 50);
114+    assert.equal(updatedConversationPayload.data.local_conversation_id, localConversationId);
115+    assert.equal(updatedConversationPayload.data.automation_status, "paused");
116+    assert.equal(updatedConversationPayload.data.last_message_id, "msg-renewal-control-2");
117+    assert.equal(updatedConversationPayload.data.active_link.page_title, "ChatGPT Renewal Updated");
118+
119+    const listResponse = await fetch(`${baseUrl}/v1/renewal/conversations?platform=chatgpt&status=paused`);
120+    assert.equal(listResponse.status, 200);
121+    const listPayload = await listResponse.json();
122+    assert.equal(listPayload.data.count, 1);
123+    assert.equal(listPayload.data.conversations[0].local_conversation_id, localConversationId);
124+    assert.equal(listPayload.data.conversations[0].active_link.link_id, linksPayload.data.links[0].link_id);
125+
126+    const manualResponse = await fetch(
127+      `${baseUrl}/v1/renewal/conversations/${localConversationId}/manual`,
128+      {
129+        method: "POST"
130+      }
131+    );
132+    assert.equal(manualResponse.status, 200);
133+    const manualPayload = await manualResponse.json();
134+    assert.equal(manualPayload.data.automation_status, "manual");
135+    assert.equal(manualPayload.data.paused_at, undefined);
136+  } finally {
137+    client?.queue.stop();
138+    client?.socket.close(1000, "done");
139+    await runtime.stop();
140+    rmSync(stateDir, {
141+      force: true,
142+      recursive: true
143+    });
144+  }
145+});
146+
147 test("persistent live ingest survives restart and /v1/browser restores recent history from the journal", async () => {
148   const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-final-message-persist-"));
149   const databasePath = join(stateDir, "control-plane.sqlite");
M apps/conductor-daemon/src/index.ts
+1, -0
1@@ -799,6 +799,7 @@ class ConductorLocalHttpServer {
2     });
3     this.instructionIngest = instructionIngest;
4     this.firefoxWebSocketServer = new ConductorFirefoxWebSocketServer({
5+      artifactStore: this.artifactStore,
6       artifactInlineThreshold,
7       artifactSummaryLength,
8       baseUrlLoader: () => this.resolvedBaseUrl,
M apps/conductor-daemon/src/local-api.ts
+341, -2
  1@@ -5,7 +5,8 @@ import {
  2   buildArtifactRelativePath,
  3   buildArtifactPublicUrl,
  4   getArtifactContentType,
  5-  type ArtifactStore
  6+  type ArtifactStore,
  7+  type ConversationAutomationStatus
  8 } from "../../../packages/artifact-db/dist/index.js";
  9 import {
 10   AUTOMATION_STATE_KEY,
 11@@ -71,6 +72,12 @@ import {
 12   type BrowserRequestPolicyLease
 13 } from "./browser-request-policy.js";
 14 import type { BaaBrowserDeliveryBridge } from "./artifacts/upload-session.js";
 15+import {
 16+  RenewalConversationNotFoundError,
 17+  getRenewalConversationDetail,
 18+  listRenewalConversationDetails,
 19+  setRenewalConversationAutomationStatus
 20+} from "./renewal/conversations.js";
 21 
 22 interface FileStatsLike {
 23   isDirectory(): boolean;
 24@@ -193,6 +200,7 @@ const RESERVED_BROWSER_REQUEST_RESPONSE_MODES = [] as const;
 25 const MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS = 60_000;
 26 const MAX_BROWSER_WS_RECONNECT_REPEAT_COUNT = 20;
 27 const MAX_BROWSER_WS_RECONNECT_REPEAT_INTERVAL_MS = 60_000;
 28+const RENEWAL_AUTOMATION_STATUS_SET = new Set<ConversationAutomationStatus>(["manual", "auto", "paused"]);
 29 
 30 type LocalApiRouteMethod = "GET" | "POST";
 31 type LocalApiRouteKind = "probe" | "read" | "write";
 32@@ -753,6 +761,48 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
 33     method: "GET",
 34     pathPattern: "/v1/sessions/latest",
 35     summary: "最近活跃会话及关联消息和执行 URL"
 36+  },
 37+  {
 38+    id: "renewal.conversations.list",
 39+    kind: "read",
 40+    method: "GET",
 41+    pathPattern: "/v1/renewal/conversations",
 42+    summary: "查询本地对话自动化状态(分页、按 platform/status 过滤)"
 43+  },
 44+  {
 45+    id: "renewal.conversations.read",
 46+    kind: "read",
 47+    method: "GET",
 48+    pathPattern: "/v1/renewal/conversations/:local_conversation_id",
 49+    summary: "读取单个本地对话状态及关联目标"
 50+  },
 51+  {
 52+    id: "renewal.links.list",
 53+    kind: "read",
 54+    method: "GET",
 55+    pathPattern: "/v1/renewal/links",
 56+    summary: "查询本地对话关联表(分页、按 platform/conversation/client 过滤)"
 57+  },
 58+  {
 59+    id: "renewal.conversations.manual",
 60+    kind: "write",
 61+    method: "POST",
 62+    pathPattern: "/v1/renewal/conversations/:local_conversation_id/manual",
 63+    summary: "将本地对话自动化状态切到 manual"
 64+  },
 65+  {
 66+    id: "renewal.conversations.auto",
 67+    kind: "write",
 68+    method: "POST",
 69+    pathPattern: "/v1/renewal/conversations/:local_conversation_id/auto",
 70+    summary: "将本地对话自动化状态切到 auto"
 71+  },
 72+  {
 73+    id: "renewal.conversations.paused",
 74+    kind: "write",
 75+    method: "POST",
 76+    pathPattern: "/v1/renewal/conversations/:local_conversation_id/paused",
 77+    summary: "将本地对话自动化状态切到 paused"
 78   }
 79 ];
 80 
 81@@ -1720,6 +1770,58 @@ function readOptionalQueryString(url: URL, ...fieldNames: string[]): string | un
 82   return undefined;
 83 }
 84 
 85+function readOptionalBooleanQuery(url: URL, ...fieldNames: string[]): boolean | undefined {
 86+  const rawValue = readOptionalQueryString(url, ...fieldNames);
 87+
 88+  if (rawValue == null) {
 89+    return undefined;
 90+  }
 91+
 92+  switch (rawValue.toLowerCase()) {
 93+    case "1":
 94+    case "true":
 95+    case "yes":
 96+      return true;
 97+    case "0":
 98+    case "false":
 99+    case "no":
100+      return false;
101+    default:
102+      throw new LocalApiHttpError(
103+        400,
104+        "invalid_request",
105+        `Query parameter "${fieldNames[0] ?? "value"}" must be a boolean.`,
106+        {
107+          field: fieldNames[0] ?? "value"
108+        }
109+      );
110+  }
111+}
112+
113+function readOptionalRenewalAutomationStatusQuery(
114+  url: URL,
115+  ...fieldNames: string[]
116+): ConversationAutomationStatus | undefined {
117+  const rawValue = readOptionalQueryString(url, ...fieldNames);
118+
119+  if (rawValue == null) {
120+    return undefined;
121+  }
122+
123+  if (!RENEWAL_AUTOMATION_STATUS_SET.has(rawValue as ConversationAutomationStatus)) {
124+    throw new LocalApiHttpError(
125+      400,
126+      "invalid_request",
127+      `Query parameter "${fieldNames[0] ?? "automation_status"}" must be one of manual, auto, paused.`,
128+      {
129+        field: fieldNames[0] ?? "automation_status"
130+      }
131+    );
132+  }
133+
134+  return rawValue as ConversationAutomationStatus;
135+}
136+
137 function readOptionalNumberField(body: JsonObject, fieldName: string): number | undefined {
138   const value = body[fieldName];
139 
140@@ -4258,7 +4360,10 @@ function routeBelongsToSurface(
141       "artifact.executions.list",
142       "artifact.executions.read",
143       "artifact.sessions.list",
144-      "artifact.sessions.latest"
145+      "artifact.sessions.latest",
146+      "renewal.conversations.list",
147+      "renewal.conversations.read",
148+      "renewal.links.list"
149     ].includes(route.id);
150   }
151 
152@@ -4275,6 +4380,12 @@ function routeBelongsToSurface(
153     "system.pause",
154     "system.resume",
155     "system.drain",
156+    "renewal.conversations.list",
157+    "renewal.conversations.read",
158+    "renewal.links.list",
159+    "renewal.conversations.manual",
160+    "renewal.conversations.auto",
161+    "renewal.conversations.paused",
162     "host.exec",
163     "host.files.read",
164     "host.files.write",
165@@ -6966,6 +7077,222 @@ async function handleArtifactSessionsLatest(context: LocalApiRequestContext): Pr
166   });
167 }
168 
169+function buildRenewalConversationLinkData(link: {
170+  clientId: string | null;
171+  createdAt: number;
172+  isActive: boolean;
173+  linkId: string;
174+  localConversationId: string;
175+  observedAt: number;
176+  pageTitle: string | null;
177+  pageUrl: string | null;
178+  platform: string;
179+  remoteConversationId: string | null;
180+  routeParams: string | null;
181+  routePath: string | null;
182+  routePattern: string | null;
183+  targetId: string | null;
184+  targetKind: string | null;
185+  targetPayload: string | null;
186+  updatedAt: number;
187+}): JsonObject {
188+  const route =
189+    link.routePath == null && link.routePattern == null && link.routeParams == null
190+      ? null
191+      : compactJsonObject({
192+          path: link.routePath ?? undefined,
193+          pattern: link.routePattern ?? undefined,
194+          params: tryParseJson(link.routeParams) ?? undefined
195+        });
196+  const target =
197+    link.targetKind == null && link.targetId == null && link.targetPayload == null
198+      ? null
199+      : compactJsonObject({
200+          kind: link.targetKind ?? undefined,
201+          id: link.targetId ?? undefined,
202+          payload: tryParseJson(link.targetPayload) ?? undefined
203+        });
204+
205+  return compactJsonObject({
206+    link_id: link.linkId,
207+    local_conversation_id: link.localConversationId,
208+    platform: link.platform,
209+    remote_conversation_id: link.remoteConversationId ?? undefined,
210+    client_id: link.clientId ?? undefined,
211+    page_url: link.pageUrl ?? undefined,
212+    page_title: link.pageTitle ?? undefined,
213+    route,
214+    target,
215+    is_active: link.isActive,
216+    observed_at: link.observedAt,
217+    created_at: link.createdAt,
218+    updated_at: link.updatedAt
219+  });
220+}
221+
222+function buildRenewalConversationData(
223+  detail: NonNullable<Awaited<ReturnType<typeof getRenewalConversationDetail>>>,
224+  includeLinks: boolean
225+): JsonObject {
226+  return compactJsonObject({
227+    local_conversation_id: detail.conversation.localConversationId,
228+    platform: detail.conversation.platform,
229+    automation_status: detail.conversation.automationStatus,
230+    title: detail.conversation.title ?? undefined,
231+    summary: detail.conversation.summary ?? undefined,
232+    last_message_id: detail.conversation.lastMessageId ?? undefined,
233+    last_message_at: detail.conversation.lastMessageAt ?? undefined,
234+    cooldown_until: detail.conversation.cooldownUntil ?? undefined,
235+    paused_at: detail.conversation.pausedAt ?? undefined,
236+    created_at: detail.conversation.createdAt,
237+    updated_at: detail.conversation.updatedAt,
238+    active_link: detail.activeLink == null ? null : buildRenewalConversationLinkData(detail.activeLink),
239+    links: includeLinks ? detail.links.map((entry) => buildRenewalConversationLinkData(entry)) : undefined
240+  });
241+}
242+
243+async function handleRenewalConversationsList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
244+  const store = requireArtifactStore(context.artifactStore);
245+  const platform = readOptionalQueryString(context.url, "platform");
246+  const automationStatus = readOptionalRenewalAutomationStatusQuery(
247+    context.url,
248+    "automation_status",
249+    "status"
250+  );
251+  const limit = readPositiveIntegerQuery(context.url, "limit", ARTIFACT_DEFAULT_SESSION_LIMIT, ARTIFACT_LIST_MAX_LIMIT);
252+  const offset = readNonNegativeIntegerQuery(context.url, "offset", 0);
253+  const conversations = await listRenewalConversationDetails(store, {
254+    automationStatus,
255+    limit,
256+    offset,
257+    platform
258+  });
259+
260+  return buildSuccessEnvelope(context.requestId, 200, {
261+    count: conversations.length,
262+    filters: compactJsonObject({
263+      automation_status: automationStatus ?? undefined,
264+      limit,
265+      offset,
266+      platform: platform ?? undefined
267+    }),
268+    conversations: conversations.map((entry) => buildRenewalConversationData(entry, false))
269+  });
270+}
271+
272+async function handleRenewalConversationRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
273+  const store = requireArtifactStore(context.artifactStore);
274+  const localConversationId = context.params.local_conversation_id;
275+
276+  if (!localConversationId) {
277+    throw new LocalApiHttpError(
278+      400,
279+      "invalid_request",
280+      'Route parameter "local_conversation_id" is required.'
281+    );
282+  }
283+
284+  const detail = await getRenewalConversationDetail(store, localConversationId);
285+
286+  if (detail == null) {
287+    throw new LocalApiHttpError(
288+      404,
289+      "not_found",
290+      `Local conversation "${localConversationId}" was not found.`,
291+      {
292+        resource: "local_conversation",
293+        resource_id: localConversationId
294+      }
295+    );
296+  }
297+
298+  return buildSuccessEnvelope(context.requestId, 200, buildRenewalConversationData(detail, true));
299+}
300+
301+async function handleRenewalLinksList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
302+  const store = requireArtifactStore(context.artifactStore);
303+  const clientId = readOptionalQueryString(context.url, "client_id", "clientId");
304+  const localConversationId = readOptionalQueryString(
305+    context.url,
306+    "local_conversation_id",
307+    "localConversationId"
308+  );
309+  const platform = readOptionalQueryString(context.url, "platform");
310+  const remoteConversationId = readOptionalQueryString(
311+    context.url,
312+    "remote_conversation_id",
313+    "conversation_id",
314+    "conversationId"
315+  );
316+  const isActive = readOptionalBooleanQuery(context.url, "is_active", "active");
317+  const limit = readPositiveIntegerQuery(context.url, "limit", ARTIFACT_DEFAULT_SESSION_LIMIT, ARTIFACT_LIST_MAX_LIMIT);
318+  const offset = readNonNegativeIntegerQuery(context.url, "offset", 0);
319+  const links = await store.listConversationLinks({
320+    clientId,
321+    isActive,
322+    limit,
323+    localConversationId,
324+    offset,
325+    platform,
326+    remoteConversationId
327+  });
328+
329+  return buildSuccessEnvelope(context.requestId, 200, {
330+    count: links.length,
331+    filters: compactJsonObject({
332+      active: isActive,
333+      client_id: clientId ?? undefined,
334+      limit,
335+      local_conversation_id: localConversationId ?? undefined,
336+      offset,
337+      platform: platform ?? undefined,
338+      remote_conversation_id: remoteConversationId ?? undefined
339+    }),
340+    links: links.map((entry) => buildRenewalConversationLinkData(entry))
341+  });
342+}
343+
344+async function handleRenewalConversationMutation(
345+  context: LocalApiRequestContext,
346+  automationStatus: ConversationAutomationStatus
347+): Promise<ConductorHttpResponse> {
348+  const store = requireArtifactStore(context.artifactStore);
349+  const localConversationId = context.params.local_conversation_id;
350+
351+  if (!localConversationId) {
352+    throw new LocalApiHttpError(
353+      400,
354+      "invalid_request",
355+      'Route parameter "local_conversation_id" is required.'
356+    );
357+  }
358+
359+  try {
360+    const detail = await setRenewalConversationAutomationStatus({
361+      automationStatus,
362+      localConversationId,
363+      observedAt: context.now() * 1000,
364+      store
365+    });
366+
367+    return buildSuccessEnvelope(context.requestId, 200, buildRenewalConversationData(detail, true));
368+  } catch (error) {
369+    if (error instanceof RenewalConversationNotFoundError) {
370+      throw new LocalApiHttpError(
371+        404,
372+        "not_found",
373+        error.message,
374+        {
375+          resource: "local_conversation",
376+          resource_id: error.localConversationId
377+        }
378+      );
379+    }
380+
381+    throw error;
382+  }
383+}
384+
385 function tryParseJson(value: string | null): JsonValue | null {
386   if (value == null) {
387     return null;
388@@ -7105,6 +7432,18 @@ async function dispatchBusinessRoute(
389       return handleArtifactSessionsList(context);
390     case "artifact.sessions.latest":
391       return handleArtifactSessionsLatest(context);
392+    case "renewal.conversations.list":
393+      return handleRenewalConversationsList(context);
394+    case "renewal.conversations.read":
395+      return handleRenewalConversationRead(context);
396+    case "renewal.links.list":
397+      return handleRenewalLinksList(context);
398+    case "renewal.conversations.manual":
399+      return handleRenewalConversationMutation(context, "manual");
400+    case "renewal.conversations.auto":
401+      return handleRenewalConversationMutation(context, "auto");
402+    case "renewal.conversations.paused":
403+      return handleRenewalConversationMutation(context, "paused");
404     default:
405       throw new LocalApiHttpError(404, "not_found", `No local route matches "${context.url.pathname}".`);
406   }
A apps/conductor-daemon/src/renewal/conversations.ts
+621, -0
  1@@ -0,0 +1,621 @@
  2+import { randomUUID } from "node:crypto";
  3+import type {
  4+  ArtifactStore,
  5+  ConversationAutomationStatus,
  6+  ConversationLinkRecord,
  7+  ListConversationLinksOptions,
  8+  ListLocalConversationsOptions,
  9+  LocalConversationRecord
 10+} from "../../../../packages/artifact-db/dist/index.js";
 11+
 12+import type { BaaDeliveryRouteSnapshot } from "../artifacts/types.js";
 13+
 14+const LOCAL_CONVERSATION_ID_PREFIX = "lc_";
 15+const CONVERSATION_LINK_ID_PREFIX = "link_";
 16+const DEFAULT_LINK_SCAN_LIMIT = 50;
 17+
 18+export interface ObserveRenewalConversationInput {
 19+  assistantMessageId: string;
 20+  clientId?: string | null;
 21+  observedAt: number;
 22+  pageTitle?: string | null;
 23+  pageUrl?: string | null;
 24+  platform: string;
 25+  remoteConversationId?: string | null;
 26+  route?: BaaDeliveryRouteSnapshot | null;
 27+  store: ArtifactStore;
 28+}
 29+
 30+export interface ObserveRenewalConversationResult {
 31+  conversation: LocalConversationRecord;
 32+  created: boolean;
 33+  link: ConversationLinkRecord;
 34+  resolvedBy: "active_link" | "new" | "remote_conversation";
 35+}
 36+
 37+export interface RenewalConversationDetail {
 38+  activeLink: ConversationLinkRecord | null;
 39+  conversation: LocalConversationRecord;
 40+  links: ConversationLinkRecord[];
 41+}
 42+
 43+export class RenewalConversationNotFoundError extends Error {
 44+  constructor(readonly localConversationId: string) {
 45+    super(`Local conversation "${localConversationId}" was not found.`);
 46+  }
 47+}
 48+
 49+interface NormalizedObservedRoute {
 50+  pageTitle: string | null;
 51+  pageUrl: string | null;
 52+  remoteConversationId: string | null;
 53+  routeParams: Record<string, string> | null;
 54+  routePath: string | null;
 55+  routePattern: string | null;
 56+  targetId: string | null;
 57+  targetKind: string;
 58+  targetPayload: Record<string, unknown>;
 59+}
 60+
 61+interface ExistingLinkResolution {
 62+  link: ConversationLinkRecord | null;
 63+  resolvedBy: ObserveRenewalConversationResult["resolvedBy"];
 64+}
 65+
 66+export async function getRenewalConversationDetail(
 67+  store: ArtifactStore,
 68+  localConversationId: string,
 69+  linkLimit: number = DEFAULT_LINK_SCAN_LIMIT
 70+): Promise<RenewalConversationDetail | null> {
 71+  const conversation = await store.getLocalConversation(localConversationId);
 72+
 73+  if (conversation == null) {
 74+    return null;
 75+  }
 76+
 77+  const links = await store.listConversationLinks({
 78+    limit: linkLimit,
 79+    localConversationId
 80+  });
 81+
 82+  return {
 83+    activeLink: links.find((entry) => entry.isActive) ?? null,
 84+    conversation,
 85+    links
 86+  };
 87+}
 88+
 89+export async function listRenewalConversationDetails(
 90+  store: ArtifactStore,
 91+  options: ListLocalConversationsOptions = {},
 92+  linkLimit: number = DEFAULT_LINK_SCAN_LIMIT
 93+): Promise<RenewalConversationDetail[]> {
 94+  const conversations = await store.listLocalConversations(options);
 95+
 96+  return Promise.all(
 97+    conversations.map(async (conversation) => {
 98+      const links = await store.listConversationLinks({
 99+        limit: linkLimit,
100+        localConversationId: conversation.localConversationId
101+      });
102+
103+      return {
104+        activeLink: links.find((entry) => entry.isActive) ?? null,
105+        conversation,
106+        links
107+      };
108+    })
109+  );
110+}
111+
112+export async function observeRenewalConversation(
113+  input: ObserveRenewalConversationInput
114+): Promise<ObserveRenewalConversationResult> {
115+  const platform = normalizeRequiredString(input.platform, "platform");
116+  const observedAt = normalizeTimestamp(input.observedAt, "observedAt");
117+  const clientId = normalizeOptionalString(input.clientId);
118+  const observedRoute = buildObservedRoute(platform, {
119+    clientId,
120+    pageTitle: input.pageTitle,
121+    pageUrl: input.pageUrl,
122+    remoteConversationId: input.remoteConversationId,
123+    route: input.route ?? null
124+  });
125+  const existingResolution = await resolveExistingConversationLink(input.store, {
126+    clientId,
127+    observedRoute,
128+    platform
129+  });
130+  let conversation = await loadOrCreateLocalConversation(input.store, {
131+    assistantMessageId: input.assistantMessageId,
132+    existingLocalConversationId: existingResolution.link?.localConversationId ?? null,
133+    observedAt,
134+    pageTitle: observedRoute.pageTitle,
135+    platform
136+  });
137+
138+  await deactivateConflictingConversationLinks(input.store, {
139+    clientId,
140+    keepLinkId: existingResolution.link?.linkId ?? null,
141+    localConversationId: conversation.localConversationId,
142+    observedAt,
143+    observedRoute,
144+    platform
145+  });
146+
147+  const link = await input.store.upsertConversationLink({
148+    clientId,
149+    createdAt: existingResolution.link?.createdAt ?? observedAt,
150+    isActive: true,
151+    linkId: existingResolution.link?.linkId ?? buildConversationLinkId(),
152+    localConversationId: conversation.localConversationId,
153+    observedAt,
154+    pageTitle: observedRoute.pageTitle ?? undefined,
155+    pageUrl: observedRoute.pageUrl ?? undefined,
156+    platform,
157+    remoteConversationId: observedRoute.remoteConversationId ?? undefined,
158+    routeParams: observedRoute.routeParams ?? undefined,
159+    routePath: observedRoute.routePath ?? undefined,
160+    routePattern: observedRoute.routePattern ?? undefined,
161+    targetId: observedRoute.targetId ?? undefined,
162+    targetKind: observedRoute.targetKind ?? undefined,
163+    targetPayload: Object.keys(observedRoute.targetPayload).length === 0 ? undefined : observedRoute.targetPayload,
164+    updatedAt: observedAt
165+  });
166+
167+  conversation = await input.store.upsertLocalConversation({
168+    lastMessageAt: observedAt,
169+    lastMessageId: normalizeRequiredString(input.assistantMessageId, "assistantMessageId"),
170+    localConversationId: conversation.localConversationId,
171+    platform: conversation.platform,
172+    title: observedRoute.pageTitle ?? undefined,
173+    updatedAt: observedAt
174+  });
175+
176+  return {
177+    conversation,
178+    created: existingResolution.link == null,
179+    link,
180+    resolvedBy: existingResolution.resolvedBy
181+  };
182+}
183+
184+export async function setRenewalConversationAutomationStatus(input: {
185+  automationStatus: ConversationAutomationStatus;
186+  localConversationId: string;
187+  observedAt?: number | null;
188+  store: ArtifactStore;
189+}): Promise<RenewalConversationDetail> {
190+  const localConversationId = normalizeRequiredString(input.localConversationId, "localConversationId");
191+  const existing = await input.store.getLocalConversation(localConversationId);
192+
193+  if (existing == null) {
194+    throw new RenewalConversationNotFoundError(localConversationId);
195+  }
196+
197+  const updatedAt = normalizeTimestamp(
198+    input.observedAt ?? Date.now(),
199+    "observedAt"
200+  );
201+  await input.store.upsertLocalConversation({
202+    automationStatus: input.automationStatus,
203+    localConversationId,
204+    pausedAt: input.automationStatus === "paused" ? updatedAt : null,
205+    platform: existing.platform,
206+    updatedAt
207+  });
208+
209+  const detail = await getRenewalConversationDetail(input.store, localConversationId);
210+
211+  if (detail == null) {
212+    throw new RenewalConversationNotFoundError(localConversationId);
213+  }
214+
215+  return detail;
216+}
217+
218+function buildConversationLinkId(): string {
219+  return `${CONVERSATION_LINK_ID_PREFIX}${randomUUID()}`;
220+}
221+
222+function buildLocalConversationId(): string {
223+  return `${LOCAL_CONVERSATION_ID_PREFIX}${randomUUID()}`;
224+}
225+
226+async function deactivateConflictingConversationLinks(
227+  store: ArtifactStore,
228+  input: {
229+    clientId: string | null;
230+    keepLinkId: string | null;
231+    localConversationId: string;
232+    observedAt: number;
233+    observedRoute: NormalizedObservedRoute;
234+    platform: string;
235+  }
236+): Promise<void> {
237+  const filters: ListConversationLinksOptions = {
238+    isActive: true,
239+    limit: DEFAULT_LINK_SCAN_LIMIT,
240+    platform: input.platform
241+  };
242+
243+  if (input.clientId != null) {
244+    filters.clientId = input.clientId;
245+  }
246+
247+  const activeLinks = await store.listConversationLinks(filters);
248+
249+  for (const candidate of activeLinks) {
250+    if (candidate.linkId === input.keepLinkId || candidate.isActive !== true) {
251+      continue;
252+    }
253+
254+    if (candidate.localConversationId === input.localConversationId) {
255+      continue;
256+    }
257+
258+    if (!shouldDeactivateLink(candidate, input.observedRoute)) {
259+      continue;
260+    }
261+
262+    await store.upsertConversationLink({
263+      isActive: false,
264+      linkId: candidate.linkId,
265+      localConversationId: candidate.localConversationId,
266+      observedAt: candidate.observedAt,
267+      platform: candidate.platform,
268+      updatedAt: input.observedAt
269+    });
270+  }
271+}
272+
273+function shouldDeactivateLink(
274+  candidate: ConversationLinkRecord,
275+  observedRoute: NormalizedObservedRoute
276+): boolean {
277+  if (observedRoute.targetId != null && candidate.targetId === observedRoute.targetId) {
278+    return true;
279+  }
280+
281+  if (observedRoute.pageUrl != null && candidate.pageUrl === observedRoute.pageUrl) {
282+    return true;
283+  }
284+
285+  return observedRoute.routePath != null && candidate.routePath === observedRoute.routePath;
286+}
287+
288+function buildObservedRoute(
289+  platform: string,
290+  input: {
291+    clientId: string | null;
292+    pageTitle?: string | null;
293+    pageUrl?: string | null;
294+    remoteConversationId?: string | null;
295+    route: BaaDeliveryRouteSnapshot | null;
296+  }
297+): NormalizedObservedRoute {
298+  const explicitPageTitle = normalizeOptionalString(input.pageTitle);
299+  const explicitPageUrl = normalizeOptionalString(input.pageUrl);
300+  const routePageTitle = normalizeOptionalString(input.route?.pageTitle);
301+  const routePageUrl = normalizeOptionalString(input.route?.pageUrl);
302+  const pageTitle = explicitPageTitle ?? routePageTitle;
303+  const pageUrl = explicitPageUrl ?? routePageUrl;
304+  const remoteConversationId =
305+    normalizeOptionalString(input.remoteConversationId)
306+    ?? normalizeOptionalString(input.route?.conversationId)
307+    ?? extractConversationIdFromPageUrl(platform, pageUrl);
308+  const routeSnapshot = buildRouteSnapshot(platform, pageUrl, remoteConversationId);
309+  const tabId = input.route?.tabId ?? null;
310+  const targetId = Number.isInteger(tabId)
311+    ? `tab:${tabId}`
312+    : (pageUrl ?? input.clientId ?? null);
313+
314+  return {
315+    pageTitle,
316+    pageUrl,
317+    remoteConversationId,
318+    routeParams: routeSnapshot.routeParams,
319+    routePath: routeSnapshot.routePath,
320+    routePattern: routeSnapshot.routePattern,
321+    targetId,
322+    targetKind: input.route?.shellPage === true ? "browser.shell_page" : "browser.proxy_delivery",
323+    targetPayload: compactObject({
324+      clientId: input.clientId ?? undefined,
325+      conversationId: remoteConversationId ?? undefined,
326+      organizationId: normalizeOptionalString(input.route?.organizationId) ?? undefined,
327+      pageTitle: pageTitle ?? undefined,
328+      pageUrl: pageUrl ?? undefined,
329+      shellPage: input.route?.shellPage === true ? true : undefined,
330+      tabId: tabId ?? undefined
331+    })
332+  };
333+}
334+
335+function buildRouteSnapshot(
336+  platform: string,
337+  pageUrl: string | null,
338+  remoteConversationId: string | null
339+): Pick<NormalizedObservedRoute, "routeParams" | "routePath" | "routePattern"> {
340+  if (pageUrl == null) {
341+    return {
342+      routeParams: remoteConversationId == null ? null : { conversationId: remoteConversationId },
343+      routePath: null,
344+      routePattern: null
345+    };
346+  }
347+
348+  let parsedUrl: URL;
349+
350+  try {
351+    parsedUrl = new URL(pageUrl, resolvePlatformOrigin(platform));
352+  } catch {
353+    return {
354+      routeParams: remoteConversationId == null ? null : { conversationId: remoteConversationId },
355+      routePath: null,
356+      routePattern: null
357+    };
358+  }
359+
360+  const routePath = normalizePathname(parsedUrl.pathname);
361+  const params = remoteConversationId == null ? null : { conversationId: remoteConversationId };
362+
363+  switch (platform) {
364+    case "chatgpt":
365+      return {
366+        routeParams: params,
367+        routePath,
368+        routePattern: remoteConversationId != null && /^\/c\/[^/]+$/u.test(routePath)
369+          ? "/c/:conversationId"
370+          : routePath
371+      };
372+    case "gemini":
373+      return {
374+        routeParams: params,
375+        routePath,
376+        routePattern: remoteConversationId != null && /^\/app\/[^/]+$/u.test(routePath)
377+          ? "/app/:conversationId"
378+          : routePath
379+      };
380+    case "claude":
381+      return {
382+        routeParams: params,
383+        routePath,
384+        routePattern: replaceTrailingConversationSegment(routePath, remoteConversationId)
385+      };
386+    default:
387+      return {
388+        routeParams: params,
389+        routePath,
390+        routePattern: routePath
391+      };
392+  }
393+}
394+
395+function replaceTrailingConversationSegment(
396+  routePath: string,
397+  remoteConversationId: string | null
398+): string {
399+  if (remoteConversationId == null) {
400+    return routePath;
401+  }
402+
403+  const encodedConversationId = encodeURIComponent(remoteConversationId);
404+
405+  if (routePath === `/${encodedConversationId}`) {
406+    return "/:conversationId";
407+  }
408+
409+  if (routePath.endsWith(`/${encodedConversationId}`)) {
410+    return `${routePath.slice(0, -encodedConversationId.length)}:conversationId`;
411+  }
412+
413+  return routePath;
414+}
415+
416+function compactObject(
417+  input: Record<string, unknown>
418+): Record<string, unknown> {
419+  return Object.fromEntries(
420+    Object.entries(input).filter(([, value]) => value !== undefined)
421+  );
422+}
423+
424+function extractConversationIdFromPageUrl(platform: string, pageUrl: string | null): string | null {
425+  if (pageUrl == null) {
426+    return null;
427+  }
428+
429+  try {
430+    const parsed = new URL(pageUrl, resolvePlatformOrigin(platform));
431+
432+    switch (platform) {
433+      case "chatgpt": {
434+        const chatgptMatch = normalizePathname(parsed.pathname).match(/^\/c\/([^/?#]+)/u);
435+        return normalizeOptionalString(chatgptMatch?.[1]) ?? normalizeOptionalString(parsed.searchParams.get("conversation_id"));
436+      }
437+      case "gemini": {
438+        const geminiMatch = normalizePathname(parsed.pathname).match(/^\/app\/([^/?#]+)/u);
439+        return normalizeOptionalString(geminiMatch?.[1]) ?? normalizeOptionalString(parsed.searchParams.get("conversation_id"));
440+      }
441+      case "claude": {
442+        const claudeMatch = normalizePathname(parsed.pathname).match(/([a-f0-9-]{36})(?:\/)?$/iu);
443+        return normalizeOptionalString(claudeMatch?.[1]);
444+      }
445+      default:
446+        return null;
447+    }
448+  } catch {
449+    return null;
450+  }
451+}
452+
453+async function loadOrCreateLocalConversation(
454+  store: ArtifactStore,
455+  input: {
456+    assistantMessageId: string;
457+    existingLocalConversationId: string | null;
458+    observedAt: number;
459+    pageTitle: string | null;
460+    platform: string;
461+  }
462+): Promise<LocalConversationRecord> {
463+  const existingConversation = input.existingLocalConversationId == null
464+    ? null
465+    : await store.getLocalConversation(input.existingLocalConversationId);
466+
467+  if (existingConversation != null) {
468+    return existingConversation;
469+  }
470+
471+  return store.upsertLocalConversation({
472+    automationStatus: "manual",
473+    createdAt: input.observedAt,
474+    lastMessageAt: input.observedAt,
475+    lastMessageId: normalizeRequiredString(input.assistantMessageId, "assistantMessageId"),
476+    localConversationId: buildLocalConversationId(),
477+    platform: input.platform,
478+    title: input.pageTitle,
479+    updatedAt: input.observedAt
480+  });
481+}
482+
483+async function resolveExistingConversationLink(
484+  store: ArtifactStore,
485+  input: {
486+    clientId: string | null;
487+    observedRoute: NormalizedObservedRoute;
488+    platform: string;
489+  }
490+): Promise<ExistingLinkResolution> {
491+  if (input.observedRoute.remoteConversationId != null) {
492+    const remoteConversationLink = await store.findConversationLinkByRemoteConversation(
493+      input.platform,
494+      input.observedRoute.remoteConversationId
495+    );
496+
497+    if (remoteConversationLink != null) {
498+      return {
499+        link: remoteConversationLink,
500+        resolvedBy: "remote_conversation"
501+      };
502+    }
503+  }
504+
505+  if (
506+    input.clientId == null
507+    && input.observedRoute.targetId == null
508+    && input.observedRoute.pageUrl == null
509+    && input.observedRoute.routePath == null
510+  ) {
511+    return {
512+      link: null,
513+      resolvedBy: "new"
514+    };
515+  }
516+
517+  const filters: ListConversationLinksOptions = {
518+    isActive: true,
519+    limit: DEFAULT_LINK_SCAN_LIMIT,
520+    platform: input.platform
521+  };
522+
523+  if (input.clientId != null) {
524+    filters.clientId = input.clientId;
525+  }
526+
527+  const candidates = await store.listConversationLinks(filters);
528+  let bestLink: ConversationLinkRecord | null = null;
529+  let bestScore = 0;
530+
531+  for (const candidate of candidates) {
532+    const score = scoreConversationLink(candidate, input.observedRoute);
533+
534+    if (score <= 0) {
535+      continue;
536+    }
537+
538+    if (
539+      bestLink == null
540+      || score > bestScore
541+      || (score === bestScore && candidate.observedAt > bestLink.observedAt)
542+    ) {
543+      bestLink = candidate;
544+      bestScore = score;
545+    }
546+  }
547+
548+  return {
549+    link: bestLink,
550+    resolvedBy: bestLink == null ? "new" : "active_link"
551+  };
552+}
553+
554+function scoreConversationLink(
555+  candidate: ConversationLinkRecord,
556+  observedRoute: NormalizedObservedRoute
557+): number {
558+  let score = 0;
559+
560+  if (observedRoute.targetId != null && candidate.targetId === observedRoute.targetId) {
561+    score += 100;
562+  }
563+
564+  if (observedRoute.pageUrl != null && candidate.pageUrl === observedRoute.pageUrl) {
565+    score += 80;
566+  }
567+
568+  if (observedRoute.routePath != null && candidate.routePath === observedRoute.routePath) {
569+    score += 60;
570+  }
571+
572+  if (observedRoute.pageTitle != null && candidate.pageTitle === observedRoute.pageTitle) {
573+    score += 10;
574+  }
575+
576+  return score;
577+}
578+
579+function normalizeOptionalString(value: unknown): string | null {
580+  if (typeof value !== "string") {
581+    return null;
582+  }
583+
584+  const normalized = value.trim();
585+  return normalized === "" ? null : normalized;
586+}
587+
588+function normalizePathname(value: string): string {
589+  const normalized = value.replace(/\/+$/u, "");
590+  return normalized === "" ? "/" : normalized;
591+}
592+
593+function normalizeRequiredString(value: unknown, fieldName: string): string {
594+  const normalized = normalizeOptionalString(value);
595+
596+  if (normalized == null) {
597+    throw new Error(`Field "${fieldName}" must be a non-empty string.`);
598+  }
599+
600+  return normalized;
601+}
602+
603+function normalizeTimestamp(value: unknown, fieldName: string): number {
604+  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
605+    throw new Error(`Field "${fieldName}" must be a positive finite number.`);
606+  }
607+
608+  return Math.round(value);
609+}
610+
611+function resolvePlatformOrigin(platform: string): string {
612+  switch (platform) {
613+    case "claude":
614+      return "https://claude.ai/";
615+    case "chatgpt":
616+      return "https://chatgpt.com/";
617+    case "gemini":
618+      return "https://gemini.google.com/";
619+    default:
620+      return "https://example.invalid/";
621+  }
622+}
M tasks/T-S056.md
+20, -6
 1@@ -2,7 +2,7 @@
 2 
 3 ## 状态
 4 
 5-- 当前状态:`待开始`
 6+- 当前状态:`已完成`
 7 - 规模预估:`M`
 8 - 依赖任务:`T-S055`
 9 - 建议执行者:`Claude` 或 `Codex`
10@@ -156,22 +156,36 @@
11 
12 ### 开始执行
13 
14-- 执行者:
15-- 开始时间:
16+- 执行者:`Codex`
17+- 开始时间:`2026-03-30 15:12:08 CST`
18 - 状态变更:`待开始` → `进行中`
19 
20 ### 完成摘要
21 
22-- 完成时间:
23+- 完成时间:`2026-03-30 15:36:27 CST`
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+  - `apps/conductor-daemon/src/renewal/conversations.ts`
27+  - `apps/conductor-daemon/src/firefox-ws.ts`
28+  - `apps/conductor-daemon/src/local-api.ts`
29+  - `apps/conductor-daemon/src/index.ts`
30+  - `apps/conductor-daemon/src/index.test.js`
31+  - `tasks/T-S056.md`
32 - 核心实现思路:
33+  - 新增 `renewal/conversations.ts`,把本地对话解析、remote conversation 映射、页面路由快照提取、目标快照落库、三态状态切换都收敛到独立辅助模块
34+  - 在 `browser.final_message` 入站链路中接入本地对话解析与 `conversation_links` 更新,确保新消息能复用已有映射或创建新的 `local_conversations` 记录,同时不影响现有 live ingest / delivery 主链路
35+  - 在 `/v1/renewal/` 下补齐本地对话列表/详情、关联查询、`manual / auto / paused` 写接口,并把新接口纳入 describe/capabilities 暴露面
36+  - 用 runtime 集成测试覆盖 Firefox WS final-message → renewal store → renewal HTTP API 的闭环,并验证状态切换不会破坏既有 `browser.final_message` 流程
37 - 跑了哪些测试:
38+  - `cd /Users/george/code/baa-conductor-renewal-automation-control && pnpm install`
39+  - `cd /Users/george/code/baa-conductor-renewal-automation-control && pnpm -C apps/conductor-daemon test`
40+  - `cd /Users/george/code/baa-conductor-renewal-automation-control && pnpm build`
41 
42 ### 执行过程中遇到的问题
43 
44-- 
45+- 新 worktree 初始没有安装依赖,第一次 `pnpm -C apps/conductor-daemon build` 失败于 `pnpm exec tsc` 找不到;补跑一次 `pnpm install` 后恢复正常验证流程
46 
47 ### 剩余风险
48 
49-- 
50+- 当前页面路由快照解析主要覆盖 Claude 尾段 UUID、ChatGPT `/c/:conversationId`、Gemini `/app/:conversationId` 这几类已知 URL 形态;如果平台后续变更前端路由,需要同步扩充 `renewal/conversations.ts` 的解析规则
51+- 当 final-message 同时缺失 remote conversation、tab/page URL 时,只能退化依赖当前活跃 link 做最佳匹配;极端多标签并发场景下,后续如果插件补更多 page identity,建议再增强关联判定信号