@steipete/oracle 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +55 -10
  2. package/dist/bin/oracle-cli.js +104 -16
  3. package/dist/src/browser/actions/archiveConversation.js +224 -0
  4. package/dist/src/browser/actions/assistantResponse.js +26 -0
  5. package/dist/src/browser/actions/deepResearch.js +662 -0
  6. package/dist/src/browser/actions/modelSelection.js +78 -13
  7. package/dist/src/browser/actions/navigation.js +22 -0
  8. package/dist/src/browser/actions/projectSources.js +491 -0
  9. package/dist/src/browser/actions/promptComposer.js +52 -27
  10. package/dist/src/browser/actions/thinkingStatus.js +391 -0
  11. package/dist/src/browser/artifacts.js +150 -0
  12. package/dist/src/browser/attachRunning.js +31 -0
  13. package/dist/src/browser/chatgptImages.js +315 -0
  14. package/dist/src/browser/chromeLifecycle.js +214 -3
  15. package/dist/src/browser/config.js +26 -2
  16. package/dist/src/browser/constants.js +8 -0
  17. package/dist/src/browser/controlPlan.js +81 -0
  18. package/dist/src/browser/detect.js +206 -33
  19. package/dist/src/browser/domDebug.js +49 -0
  20. package/dist/src/browser/index.js +1257 -485
  21. package/dist/src/browser/liveTabs.js +434 -0
  22. package/dist/src/browser/profileState.js +83 -3
  23. package/dist/src/browser/projectSourcesRunner.js +366 -0
  24. package/dist/src/browser/reattach.js +117 -45
  25. package/dist/src/browser/reattachHelpers.js +1 -1
  26. package/dist/src/browser/sessionRunner.js +53 -1
  27. package/dist/src/browser/tabLeaseRegistry.js +182 -0
  28. package/dist/src/cli/bridge/claudeConfig.js +12 -8
  29. package/dist/src/cli/bridge/codexConfig.js +2 -2
  30. package/dist/src/cli/browserConfig.js +40 -0
  31. package/dist/src/cli/browserDefaults.js +31 -7
  32. package/dist/src/cli/browserTabs.js +228 -0
  33. package/dist/src/cli/dryRun.js +33 -1
  34. package/dist/src/cli/duplicatePromptGuard.js +10 -2
  35. package/dist/src/cli/help.js +1 -1
  36. package/dist/src/cli/options.js +4 -0
  37. package/dist/src/cli/projectSources.js +116 -0
  38. package/dist/src/cli/sessionCommand.js +51 -0
  39. package/dist/src/cli/sessionDisplay.js +121 -9
  40. package/dist/src/cli/sessionRunner.js +51 -7
  41. package/dist/src/mcp/consultPresets.js +19 -0
  42. package/dist/src/mcp/server.js +2 -0
  43. package/dist/src/mcp/tools/consult.js +201 -26
  44. package/dist/src/mcp/tools/projectSources.js +123 -0
  45. package/dist/src/mcp/types.js +7 -0
  46. package/dist/src/mcp/utils.js +6 -1
  47. package/dist/src/oracle/run.js +4 -1
  48. package/dist/src/projectSources/plan.js +27 -0
  49. package/dist/src/projectSources/types.js +1 -0
  50. package/dist/src/projectSources/url.js +23 -0
  51. package/dist/src/sessionManager.js +1 -0
  52. package/package.json +2 -1
@@ -0,0 +1,434 @@
1
+ import CDP from "chrome-remote-interface";
2
+ import { createHash } from "node:crypto";
3
+ import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, MODEL_BUTTON_SELECTOR, SEND_BUTTON_SELECTORS, STOP_BUTTON_SELECTOR, } from "./constants.js";
4
+ import { captureAssistantMarkdown, readAssistantSnapshot } from "./actions/assistantResponse.js";
5
+ import { delay } from "./utils.js";
6
+ export const DEFAULT_REMOTE_CHROME_HOST = "127.0.0.1";
7
+ export const DEFAULT_REMOTE_CHROME_PORT = 9222;
8
+ const LOGIN_CTA_PATTERN = /\b(log in|login|sign up|sign in|continue with google|continue with microsoft)\b/i;
9
+ const noopLogger = Object.assign((_message) => { }, {});
10
+ function trimToSnippet(text, max = 140) {
11
+ const normalized = String(text ?? "")
12
+ .replace(/\s+/g, " ")
13
+ .trim();
14
+ if (normalized.length <= max) {
15
+ return normalized;
16
+ }
17
+ return `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
18
+ }
19
+ function normalizeHostPort(input = {}) {
20
+ return {
21
+ host: input.host ?? DEFAULT_REMOTE_CHROME_HOST,
22
+ port: input.port ?? DEFAULT_REMOTE_CHROME_PORT,
23
+ };
24
+ }
25
+ function normalizeUrl(value) {
26
+ return String(value ?? "").trim();
27
+ }
28
+ function normalizeTitle(value) {
29
+ return String(value ?? "")
30
+ .replace(/\s+/g, " ")
31
+ .trim();
32
+ }
33
+ function buildTargetFingerprint(summary) {
34
+ return createHash("sha1")
35
+ .update(`${summary.targetId ?? ""}|${summary.url ?? ""}|${summary.lastAssistantText ?? ""}`)
36
+ .digest("hex");
37
+ }
38
+ function isChatGptUrl(url) {
39
+ const normalized = normalizeUrl(url).toLowerCase();
40
+ return (normalized.startsWith("https://chatgpt.com") || normalized.startsWith("https://chat.openai.com"));
41
+ }
42
+ function isChatGptConversationUrl(url) {
43
+ return /\/c\//.test(normalizeUrl(url));
44
+ }
45
+ function isChatGptTarget(target) {
46
+ if (!target || target.type !== "page") {
47
+ return false;
48
+ }
49
+ return isChatGptUrl(target.url ?? "") || /chatgpt/i.test(target.title ?? "");
50
+ }
51
+ function extractTargetId(target) {
52
+ return target?.targetId ?? target?.id ?? null;
53
+ }
54
+ function escapeLiteral(value) {
55
+ return JSON.stringify(value);
56
+ }
57
+ function buildTabInspectionExpression() {
58
+ const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
59
+ const sendSelectorsLiteral = JSON.stringify(SEND_BUTTON_SELECTORS);
60
+ const answerSelectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
61
+ const turnSelectorLiteral = escapeLiteral(CONVERSATION_TURN_SELECTOR);
62
+ const assistantRoleLiteral = escapeLiteral(ASSISTANT_ROLE_SELECTOR);
63
+ const modelButtonSelectorLiteral = escapeLiteral(MODEL_BUTTON_SELECTOR);
64
+ const stopSelectorLiteral = escapeLiteral(STOP_BUTTON_SELECTOR);
65
+ return `(() => {
66
+ const INPUT_SELECTORS = ${inputSelectorsLiteral};
67
+ const SEND_SELECTORS = ${sendSelectorsLiteral};
68
+ const ANSWER_SELECTORS = ${answerSelectorsLiteral};
69
+ const TURN_SELECTOR = ${turnSelectorLiteral};
70
+ const ASSISTANT_ROLE_SELECTOR = ${assistantRoleLiteral};
71
+ const MODEL_BUTTON_SELECTOR = ${modelButtonSelectorLiteral};
72
+ const STOP_BUTTON_SELECTOR = ${stopSelectorLiteral};
73
+ const LOGIN_CTA = ${LOGIN_CTA_PATTERN.toString()};
74
+ const normalize = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim();
75
+ const isVisible = (node) => {
76
+ if (!(node instanceof Element)) return false;
77
+ const style = window.getComputedStyle(node);
78
+ if (!style) return false;
79
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
80
+ const rect = node.getBoundingClientRect();
81
+ return rect.width > 0 && rect.height > 0;
82
+ };
83
+ const firstVisible = (selectors) => {
84
+ for (const selector of selectors) {
85
+ const node = document.querySelector(selector);
86
+ if (node && isVisible(node)) return node;
87
+ }
88
+ return null;
89
+ };
90
+ const loginButtonExists = Array.from(document.querySelectorAll('button,a,[role="button"]')).some((node) => {
91
+ const label = normalize(node.textContent || node.getAttribute('aria-label') || node.getAttribute('title'));
92
+ return LOGIN_CTA.test(label);
93
+ });
94
+ const stopButton = document.querySelector(STOP_BUTTON_SELECTOR);
95
+ const stopExists = Boolean(stopButton && isVisible(stopButton));
96
+ const sendButton = firstVisible(SEND_SELECTORS);
97
+ const sendExists = Boolean(sendButton);
98
+ const promptNode = firstVisible(INPUT_SELECTORS);
99
+ const promptReady = Boolean(promptNode);
100
+ const turns = Array.from(document.querySelectorAll(TURN_SELECTOR));
101
+ const assistantTurns = turns.filter((turn) => {
102
+ const role = normalize(turn.getAttribute('data-message-author-role') || turn.getAttribute('data-turn')).toLowerCase();
103
+ if (role === 'assistant') return true;
104
+ return Boolean(turn.querySelector(ASSISTANT_ROLE_SELECTOR));
105
+ });
106
+ const userTurns = turns.filter((turn) => {
107
+ const role = normalize(turn.getAttribute('data-message-author-role') || turn.getAttribute('data-turn')).toLowerCase();
108
+ return role === 'user';
109
+ });
110
+ const answerNode = ANSWER_SELECTORS
111
+ .map((selector) => document.querySelectorAll(selector))
112
+ .find((matches) => matches && matches.length > 0);
113
+ const currentModelButton = document.querySelector(MODEL_BUTTON_SELECTOR);
114
+ const hasProPill = Boolean(document.querySelector('button.__composer-pill, button[aria-label="Pro, click to remove"]'));
115
+ let currentModelLabel = normalize(currentModelButton?.textContent || currentModelButton?.getAttribute?.('aria-label') || '');
116
+ if (currentModelLabel === 'ChatGPT' && hasProPill) {
117
+ currentModelLabel = 'ChatGPT + Pro';
118
+ }
119
+ const assistantTexts = assistantTurns
120
+ .map((node) => normalize(node.textContent))
121
+ .filter(Boolean);
122
+ const userTexts = userTurns
123
+ .map((node) => normalize(node.textContent))
124
+ .filter(Boolean);
125
+ const answerTexts = Array.from(answerNode || []).map((node) => normalize(node.textContent)).filter(Boolean);
126
+ const assistantCount = assistantTurns.length > 0 ? assistantTurns.length : answerTexts.length;
127
+ const lastAssistantText = assistantTexts[assistantTexts.length - 1] || answerTexts[answerTexts.length - 1] || '';
128
+ const lastUserText = userTexts[userTexts.length - 1] || '';
129
+ const authenticated = !loginButtonExists && (promptReady || sendExists || stopExists || assistantCount > 0);
130
+ return {
131
+ title: normalize(document.title),
132
+ url: location.href,
133
+ currentModelLabel,
134
+ stopExists,
135
+ sendExists,
136
+ promptReady,
137
+ loginButtonExists,
138
+ authenticated,
139
+ assistantCount,
140
+ lastAssistantText,
141
+ lastUserText,
142
+ visibilityState: document.visibilityState,
143
+ focused: Boolean(document.hasFocus?.()),
144
+ };
145
+ })()`;
146
+ }
147
+ export async function listChatGptTargets(options = {}) {
148
+ const { host, port } = normalizeHostPort(options);
149
+ const targets = (await CDP.List({ host, port }));
150
+ return targets.filter(isChatGptTarget);
151
+ }
152
+ export async function openChatGptTarget(options = {}) {
153
+ const { host, port } = normalizeHostPort(options);
154
+ const url = options.url ?? "https://chatgpt.com/";
155
+ const target = await CDP.New({ host, port, url });
156
+ return target.id;
157
+ }
158
+ async function connectToTarget(host, port, targetId) {
159
+ const client = await CDP({ host, port, target: targetId });
160
+ const { Runtime, DOM } = client;
161
+ if (Runtime?.enable) {
162
+ await Runtime.enable();
163
+ }
164
+ if (DOM?.enable) {
165
+ await DOM.enable();
166
+ }
167
+ return client;
168
+ }
169
+ export async function inspectChatGptTab(options) {
170
+ const { host, port } = normalizeHostPort(options);
171
+ const target = options.target;
172
+ const targetId = extractTargetId(target);
173
+ if (!targetId) {
174
+ throw new Error("inspectChatGptTab requires a target with targetId.");
175
+ }
176
+ const client = await connectToTarget(host, port, targetId);
177
+ try {
178
+ const { Runtime } = client;
179
+ const evaluation = await Runtime.evaluate({
180
+ expression: buildTabInspectionExpression(),
181
+ returnByValue: true,
182
+ awaitPromise: true,
183
+ });
184
+ const info = (evaluation.result?.value ?? {});
185
+ const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
186
+ const lastAssistantText = typeof snapshot?.text === "string" && snapshot.text.trim().length > 0
187
+ ? snapshot.text.trim()
188
+ : String(info.lastAssistantText ?? "").trim();
189
+ const lastUserText = String(info.lastUserText ?? "").trim();
190
+ const summary = {
191
+ host,
192
+ port,
193
+ targetId,
194
+ title: normalizeTitle(info.title ?? target.title ?? ""),
195
+ url: normalizeUrl(info.url ?? target.url ?? ""),
196
+ currentModelLabel: normalizeTitle(info.currentModelLabel ?? ""),
197
+ stopExists: Boolean(info.stopExists),
198
+ sendExists: Boolean(info.sendExists),
199
+ promptReady: Boolean(info.promptReady),
200
+ loginButtonExists: Boolean(info.loginButtonExists),
201
+ authenticated: Boolean(info.authenticated),
202
+ assistantCount: Number.isFinite(info.assistantCount) ? Number(info.assistantCount) : 0,
203
+ lastAssistantText,
204
+ lastAssistantSnippet: trimToSnippet(lastAssistantText),
205
+ lastUserText,
206
+ lastUserSnippet: trimToSnippet(lastUserText),
207
+ focused: Boolean(info.focused),
208
+ visibilityState: typeof info.visibilityState === "string" ? info.visibilityState : "",
209
+ conversationId: extractConversationIdFromUrl(info.url ?? target.url ?? ""),
210
+ fingerprint: "",
211
+ state: "detached",
212
+ lastAssistantMarkdown: null,
213
+ lastAssistantMessageId: typeof snapshot?.messageId === "string" ? snapshot.messageId : undefined,
214
+ lastAssistantTurnId: typeof snapshot?.turnId === "string" ? snapshot.turnId : undefined,
215
+ };
216
+ summary.state = classifyTabState(summary);
217
+ summary.fingerprint = buildTargetFingerprint(summary);
218
+ return summary;
219
+ }
220
+ finally {
221
+ await client.close().catch(() => undefined);
222
+ }
223
+ }
224
+ export function classifyTabState(summary) {
225
+ if (!summary?.authenticated) {
226
+ return "detached";
227
+ }
228
+ if (summary.stopExists) {
229
+ return "running";
230
+ }
231
+ if (summary.sendExists || summary.promptReady || summary.assistantCount > 0) {
232
+ return "completed";
233
+ }
234
+ return "detached";
235
+ }
236
+ export async function collectChatGptTabs(options = {}) {
237
+ const { host, port } = normalizeHostPort(options);
238
+ const targets = await listChatGptTargets({ host, port });
239
+ const summaries = [];
240
+ for (const target of targets) {
241
+ try {
242
+ const summary = await inspectChatGptTab({ host, port, target });
243
+ summaries.push(summary);
244
+ }
245
+ catch (error) {
246
+ summaries.push({
247
+ host,
248
+ port,
249
+ targetId: extractTargetId(target) ?? "",
250
+ title: normalizeTitle(target.title ?? ""),
251
+ url: normalizeUrl(target.url ?? ""),
252
+ currentModelLabel: "",
253
+ stopExists: false,
254
+ sendExists: false,
255
+ promptReady: false,
256
+ loginButtonExists: false,
257
+ authenticated: false,
258
+ assistantCount: 0,
259
+ lastAssistantText: "",
260
+ lastAssistantSnippet: "",
261
+ lastUserText: "",
262
+ lastUserSnippet: "",
263
+ focused: false,
264
+ visibilityState: "",
265
+ conversationId: extractConversationIdFromUrl(target.url ?? ""),
266
+ fingerprint: "",
267
+ state: "detached",
268
+ error: error instanceof Error ? error.message : String(error),
269
+ lastAssistantMarkdown: null,
270
+ });
271
+ }
272
+ }
273
+ return summaries.sort((left, right) => {
274
+ const leftScore = (left.focused ? 100 : 0) + (isChatGptConversationUrl(left.url) ? 10 : 0);
275
+ const rightScore = (right.focused ? 100 : 0) + (isChatGptConversationUrl(right.url) ? 10 : 0);
276
+ return rightScore - leftScore;
277
+ });
278
+ }
279
+ function resolveChatGptTabFromSummaries(summaries, ref) {
280
+ if (!Array.isArray(summaries) || summaries.length === 0) {
281
+ throw new Error("No live ChatGPT tabs found on the configured Chrome DevTools endpoint.");
282
+ }
283
+ const trimmedRef = String(ref ?? "").trim();
284
+ if (!trimmedRef || trimmedRef.toLowerCase() === "current") {
285
+ return summaries[0];
286
+ }
287
+ const exactId = summaries.find((tab) => tab.targetId === trimmedRef);
288
+ if (exactId) {
289
+ return exactId;
290
+ }
291
+ const exactUrl = summaries.find((tab) => tab.url === trimmedRef);
292
+ if (exactUrl) {
293
+ return exactUrl;
294
+ }
295
+ const lower = trimmedRef.toLowerCase();
296
+ const titleMatches = summaries.filter((tab) => tab.title.toLowerCase().includes(lower));
297
+ if (titleMatches.length === 1) {
298
+ return titleMatches[0];
299
+ }
300
+ if (titleMatches.length > 1) {
301
+ const details = titleMatches
302
+ .map((tab) => `${tab.targetId}: ${tab.title || "(untitled)"} — ${tab.url}`)
303
+ .join("\n");
304
+ throw new Error(`Multiple ChatGPT tabs match "${trimmedRef}":\n${details}`);
305
+ }
306
+ throw new Error(`No ChatGPT tab matched "${trimmedRef}". Use "oracle-tabs" or "oracle status --browser-tabs" to inspect live targets.`);
307
+ }
308
+ export function resolveChatGptTabFromSummariesForTest(summaries, ref) {
309
+ return resolveChatGptTabFromSummaries(summaries, ref);
310
+ }
311
+ export async function resolveChatGptTab(options = {}) {
312
+ const { host, port } = normalizeHostPort(options);
313
+ const summaries = await collectChatGptTabs({ host, port });
314
+ return resolveChatGptTabFromSummaries(summaries, options.ref);
315
+ }
316
+ export async function connectToExistingChatGptTab(options = {}) {
317
+ const { host, port } = normalizeHostPort(options);
318
+ const tab = await resolveChatGptTab({ host, port, ref: options.ref });
319
+ const client = await connectToTarget(host, port, tab.targetId);
320
+ return { client, targetId: tab.targetId, tab };
321
+ }
322
+ export async function harvestChatGptTab(options = {}) {
323
+ const { host, port } = normalizeHostPort(options);
324
+ const resolved = options.target
325
+ ? await inspectChatGptTab({ host, port, target: options.target })
326
+ : await resolveChatGptTab({ host, port, ref: options.ref });
327
+ const client = await connectToTarget(host, port, resolved.targetId);
328
+ try {
329
+ const { Runtime } = client;
330
+ const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
331
+ let assistantMarkdown = null;
332
+ if (snapshot?.messageId || snapshot?.turnId) {
333
+ assistantMarkdown = await captureAssistantMarkdown(Runtime, {
334
+ messageId: snapshot.messageId,
335
+ turnId: snapshot.turnId,
336
+ }, noopLogger).catch(() => null);
337
+ }
338
+ const latestText = typeof snapshot?.text === "string" && snapshot.text.trim().length > 0
339
+ ? snapshot.text.trim()
340
+ : resolved.lastAssistantText;
341
+ const lastAssistantText = latestText ?? "";
342
+ const nowSummary = await inspectChatGptTab({
343
+ host,
344
+ port,
345
+ target: {
346
+ targetId: resolved.targetId,
347
+ title: resolved.title,
348
+ url: resolved.url,
349
+ type: "page",
350
+ },
351
+ });
352
+ const harvested = {
353
+ ...nowSummary,
354
+ lastAssistantText,
355
+ lastAssistantSnippet: trimToSnippet(lastAssistantText),
356
+ lastAssistantMarkdown: assistantMarkdown ?? (lastAssistantText || null),
357
+ lastAssistantMessageId: typeof snapshot?.messageId === "string"
358
+ ? snapshot.messageId
359
+ : nowSummary.lastAssistantMessageId,
360
+ lastAssistantTurnId: typeof snapshot?.turnId === "string" ? snapshot.turnId : nowSummary.lastAssistantTurnId,
361
+ };
362
+ if (harvested.stopExists && options.stallWindowMs && options.stallWindowMs > 0) {
363
+ const firstFingerprint = harvested.fingerprint;
364
+ await delay(options.stallWindowMs);
365
+ const followup = await inspectChatGptTab({
366
+ host,
367
+ port,
368
+ target: {
369
+ targetId: harvested.targetId,
370
+ title: harvested.title,
371
+ url: harvested.url,
372
+ type: "page",
373
+ },
374
+ });
375
+ harvested.stopExists = followup.stopExists;
376
+ harvested.sendExists = followup.sendExists;
377
+ harvested.promptReady = followup.promptReady;
378
+ harvested.currentModelLabel = followup.currentModelLabel;
379
+ harvested.focused = followup.focused;
380
+ harvested.visibilityState = followup.visibilityState;
381
+ harvested.assistantCount = followup.assistantCount;
382
+ harvested.authenticated = followup.authenticated;
383
+ harvested.loginButtonExists = followup.loginButtonExists;
384
+ harvested.lastUserText = followup.lastUserText;
385
+ harvested.lastUserSnippet = followup.lastUserSnippet;
386
+ harvested.fingerprint = followup.fingerprint;
387
+ harvested.state =
388
+ harvested.stopExists && firstFingerprint === followup.fingerprint
389
+ ? "stalled"
390
+ : classifyTabState(harvested);
391
+ }
392
+ else {
393
+ harvested.state = classifyTabState(harvested);
394
+ }
395
+ return harvested;
396
+ }
397
+ finally {
398
+ await client.close().catch(() => undefined);
399
+ }
400
+ }
401
+ export function extractConversationIdFromUrl(url) {
402
+ const match = normalizeUrl(url).match(/\/c\/([^/?#]+)/);
403
+ return match?.[1] ?? undefined;
404
+ }
405
+ export function formatBrowserTabState(tab) {
406
+ return tab.state ?? classifyTabState(tab);
407
+ }
408
+ export function sessionMatchesTab(meta, tab) {
409
+ const runtime = meta?.browser?.runtime ?? {};
410
+ const harvest = meta?.browser?.harvest ?? {};
411
+ const conversationId = tab.conversationId ?? extractConversationIdFromUrl(tab.url ?? "");
412
+ const portMatches = [runtime.chromePort, meta?.browser?.config?.remoteChrome?.port]
413
+ .filter(Boolean)
414
+ .some((port) => Number(port) === Number(DEFAULT_REMOTE_CHROME_PORT) ||
415
+ Number(port) === Number(tab.port ?? port));
416
+ const hostMatches = [runtime.chromeHost, meta?.browser?.config?.remoteChrome?.host]
417
+ .filter(Boolean)
418
+ .every((host) => !host || host === (tab.host ?? host));
419
+ if (!hostMatches) {
420
+ return false;
421
+ }
422
+ const matches = [
423
+ runtime.chromeTargetId && runtime.chromeTargetId === tab.targetId,
424
+ harvest.targetId && harvest.targetId === tab.targetId,
425
+ runtime.tabUrl && runtime.tabUrl === tab.url,
426
+ harvest.url && harvest.url === tab.url,
427
+ conversationId && runtime.conversationId && runtime.conversationId === conversationId,
428
+ conversationId && harvest.conversationId && harvest.conversationId === conversationId,
429
+ ].some(Boolean);
430
+ return Boolean(matches ||
431
+ (portMatches &&
432
+ conversationId &&
433
+ (runtime.conversationId === conversationId || harvest.conversationId === conversationId)));
434
+ }
@@ -69,6 +69,77 @@ export async function writeChromePid(userDataDir, pid) {
69
69
  // best effort
70
70
  }
71
71
  }
72
+ export async function findRunningChromeDebugTargetForProfile(userDataDir) {
73
+ if (process.platform === "win32") {
74
+ return null;
75
+ }
76
+ try {
77
+ const { stdout } = await execFileAsync("ps", ["-ax", "-o", "pid=", "-o", "command="], {
78
+ maxBuffer: 10 * 1024 * 1024,
79
+ });
80
+ return findChromeDebugTargetForProfileFromProcessList(String(stdout ?? ""), userDataDir);
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ function findChromeDebugTargetForProfileFromProcessList(processList, userDataDir) {
87
+ for (const line of processList.split("\n")) {
88
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
89
+ if (!match)
90
+ continue;
91
+ const pid = Number.parseInt(match[1] ?? "", 10);
92
+ const command = match[2] ?? "";
93
+ const lower = command.toLowerCase();
94
+ if (!Number.isFinite(pid) || pid <= 0)
95
+ continue;
96
+ if (!lower.includes("chrome") && !lower.includes("chromium"))
97
+ continue;
98
+ if (!lower.includes("user-data-dir") || !command.includes(userDataDir))
99
+ continue;
100
+ const portMatch = command.match(/--remote-debugging-port(?:=|\s+)(\d+)/);
101
+ const port = Number.parseInt(portMatch?.[1] ?? "", 10);
102
+ if (!Number.isFinite(port) || port <= 0)
103
+ continue;
104
+ return { pid, port };
105
+ }
106
+ return null;
107
+ }
108
+ export function findChromeDebugTargetForProfileFromProcessListForTest(processList, userDataDir) {
109
+ return findChromeDebugTargetForProfileFromProcessList(processList, userDataDir);
110
+ }
111
+ export async function terminateRecordedChromeForProfile(userDataDir, logger) {
112
+ const pid = await readChromePid(userDataDir);
113
+ if (!pid || !isProcessAlive(pid)) {
114
+ return false;
115
+ }
116
+ const command = await readProcessCommand(pid);
117
+ if (!isChromeCommandForUserDataDir(command, userDataDir)) {
118
+ logger?.(`Recorded Chrome pid ${pid} does not match ${userDataDir}; skipping termination`);
119
+ return false;
120
+ }
121
+ try {
122
+ process.kill(pid, "SIGTERM");
123
+ logger?.(`Terminated shared manual-login Chrome pid ${pid}`);
124
+ return true;
125
+ }
126
+ catch (error) {
127
+ const message = error instanceof Error ? error.message : String(error);
128
+ logger?.(`Failed to terminate shared manual-login Chrome pid ${pid}: ${message}`);
129
+ return false;
130
+ }
131
+ }
132
+ function isChromeCommandForUserDataDir(command, userDataDir) {
133
+ if (!command)
134
+ return false;
135
+ const lower = command.toLowerCase();
136
+ return ((lower.includes("chrome") || lower.includes("chromium")) &&
137
+ lower.includes("user-data-dir") &&
138
+ command.includes(userDataDir));
139
+ }
140
+ export function isChromeCommandForUserDataDirForTest(command, userDataDir) {
141
+ return isChromeCommandForUserDataDir(command, userDataDir);
142
+ }
72
143
  export function isProcessAlive(pid) {
73
144
  if (!Number.isFinite(pid) || pid <= 0)
74
145
  return false;
@@ -202,9 +273,6 @@ export async function verifyDevToolsReachable({ port, host = "127.0.0.1", attemp
202
273
  return { ok: false, error: "unreachable" };
203
274
  }
204
275
  export async function shouldCleanupManualLoginProfileState(userDataDir, logger, options = {}) {
205
- if (!options.connectionClosedUnexpectedly) {
206
- return true;
207
- }
208
276
  const port = await readDevToolsPort(userDataDir);
209
277
  if (!port) {
210
278
  return true;
@@ -283,3 +351,15 @@ async function isChromeUsingUserDataDir(userDataDir) {
283
351
  }
284
352
  return false;
285
353
  }
354
+ async function readProcessCommand(pid) {
355
+ try {
356
+ const { stdout } = await execFileAsync("ps", ["-p", String(Math.trunc(pid)), "-o", "command="], {
357
+ maxBuffer: 1024 * 1024,
358
+ });
359
+ const command = String(stdout ?? "").trim();
360
+ return command || null;
361
+ }
362
+ catch {
363
+ return null;
364
+ }
365
+ }