- 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
+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>
+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");
+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,
+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 }
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+}
+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,建议再增强关联判定信号