pi-oracle 0.1.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.
@@ -0,0 +1,1386 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { appendFile, chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { spawn } from "node:child_process";
6
+
7
+ const jobId = process.argv[2];
8
+ if (!jobId) {
9
+ console.error("Usage: run-job.mjs <job-id>");
10
+ process.exit(1);
11
+ }
12
+
13
+ const jobDir = `/tmp/oracle-${jobId}`;
14
+ const jobPath = `${jobDir}/job.json`;
15
+ const CHATGPT_LABELS = {
16
+ composer: "Chat with ChatGPT",
17
+ addFiles: "Add files and more",
18
+ send: "Send prompt",
19
+ close: "Close",
20
+ autoSwitchToThinking: "Auto-switch to Thinking",
21
+ configure: "Configure...",
22
+ };
23
+ const MODEL_FAMILY_PREFIX = {
24
+ instant: "Instant ",
25
+ thinking: "Thinking ",
26
+ pro: "Pro ",
27
+ };
28
+
29
+ const ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
30
+ const LOCKS_DIR = join(ORACLE_STATE_DIR, "locks");
31
+ const LEASES_DIR = join(ORACLE_STATE_DIR, "leases");
32
+ const SEED_GENERATION_FILE = ".oracle-seed-generation";
33
+ const ARTIFACT_CANDIDATE_STABILITY_TIMEOUT_MS = 15_000;
34
+ const ARTIFACT_CANDIDATE_STABILITY_POLL_MS = 1_500;
35
+ const ARTIFACT_CANDIDATE_STABILITY_POLLS = 2;
36
+ const ARTIFACT_DOWNLOAD_HEARTBEAT_MS = 10_000;
37
+ const ARTIFACT_DOWNLOAD_TIMEOUT_MS = 90_000;
38
+ const ARTIFACT_DOWNLOAD_MAX_ATTEMPTS = 2;
39
+
40
+ let currentJob;
41
+ let browserStarted = false;
42
+ let cleaningUpBrowser = false;
43
+ let cleaningUpRuntime = false;
44
+ let shuttingDown = false;
45
+ let lastHeartbeatMs = 0;
46
+
47
+ async function ensurePrivateDir(path) {
48
+ await mkdir(path, { recursive: true, mode: 0o700 });
49
+ await chmod(path, 0o700).catch(() => undefined);
50
+ }
51
+
52
+ function leaseKey(kind, key) {
53
+ return `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
54
+ }
55
+
56
+ async function acquireLock(kind, key, metadata, timeoutMs = 30_000) {
57
+ const path = join(LOCKS_DIR, leaseKey(kind, key));
58
+ const deadline = Date.now() + timeoutMs;
59
+ await ensurePrivateDir(ORACLE_STATE_DIR);
60
+ await ensurePrivateDir(LOCKS_DIR);
61
+
62
+ while (Date.now() < deadline) {
63
+ try {
64
+ await mkdir(path, { recursive: false, mode: 0o700 });
65
+ await secureWriteText(join(path, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`);
66
+ return path;
67
+ } catch (error) {
68
+ if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error;
69
+ }
70
+ await sleep(200);
71
+ }
72
+
73
+ throw new Error(`Timed out waiting for oracle ${kind} lock: ${key}`);
74
+ }
75
+
76
+ async function releaseLock(path) {
77
+ if (!path) return;
78
+ await rm(path, { recursive: true, force: true }).catch(() => undefined);
79
+ }
80
+
81
+ async function withLock(kind, key, metadata, fn, timeoutMs) {
82
+ const handle = await acquireLock(kind, key, metadata, timeoutMs);
83
+ try {
84
+ return await fn();
85
+ } finally {
86
+ await releaseLock(handle);
87
+ }
88
+ }
89
+
90
+ async function releaseLease(kind, key) {
91
+ if (!key) return;
92
+ await rm(join(LEASES_DIR, leaseKey(kind, key)), { recursive: true, force: true }).catch(() => undefined);
93
+ }
94
+
95
+ async function secureWriteText(path, content) {
96
+ const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
97
+ await writeFile(tmpPath, content, { encoding: "utf8", mode: 0o600 });
98
+ await chmod(tmpPath, 0o600).catch(() => undefined);
99
+ await rename(tmpPath, path);
100
+ await chmod(path, 0o600).catch(() => undefined);
101
+ }
102
+
103
+ async function secureAppendText(path, content) {
104
+ await appendFile(path, content, { encoding: "utf8", mode: 0o600 });
105
+ await chmod(path, 0o600).catch(() => undefined);
106
+ }
107
+
108
+ async function readJobUnlocked() {
109
+ return JSON.parse(await readFile(jobPath, "utf8"));
110
+ }
111
+
112
+ async function readJob() {
113
+ return readJobUnlocked();
114
+ }
115
+
116
+ async function writeJobUnlocked(job) {
117
+ await secureWriteText(jobPath, `${JSON.stringify(job, null, 2)}\n`);
118
+ }
119
+
120
+ async function writeJob(job) {
121
+ await withLock("job", jobId, { processPid: process.pid, action: "writeJob" }, async () => {
122
+ await writeJobUnlocked(job);
123
+ });
124
+ }
125
+
126
+ async function mutateJob(mutator) {
127
+ return withLock("job", jobId, { processPid: process.pid, action: "mutateJob" }, async () => {
128
+ const job = await readJobUnlocked();
129
+ const next = mutator(job);
130
+ await writeJobUnlocked(next);
131
+ currentJob = next;
132
+ return next;
133
+ });
134
+ }
135
+
136
+ function phasePatch(phase, patch = undefined, at = new Date().toISOString()) {
137
+ return {
138
+ ...(patch || {}),
139
+ phase,
140
+ phaseAt: at,
141
+ };
142
+ }
143
+
144
+ async function heartbeat(patch = undefined, options = {}) {
145
+ const now = Date.now();
146
+ const force = options.force === true;
147
+ if (!force && !patch && now - lastHeartbeatMs < 10_000) return;
148
+ lastHeartbeatMs = now;
149
+ const heartbeatAt = new Date(now).toISOString();
150
+ await mutateJob((job) => ({
151
+ ...job,
152
+ ...(patch || {}),
153
+ heartbeatAt,
154
+ }));
155
+ }
156
+
157
+ async function log(message) {
158
+ const line = `[${new Date().toISOString()}] ${message}\n`;
159
+ await secureAppendText(`${jobDir}/logs/worker.log`, line);
160
+ }
161
+
162
+ function sleep(ms) {
163
+ return new Promise((resolve) => setTimeout(resolve, ms));
164
+ }
165
+
166
+ function spawnCommand(command, args, options = {}) {
167
+ return new Promise((resolve, reject) => {
168
+ const { timeoutMs, ...spawnOptions } = options;
169
+ const child = spawn(command, args, {
170
+ stdio: ["pipe", "pipe", "pipe"],
171
+ ...spawnOptions,
172
+ });
173
+ let stdout = "";
174
+ let stderr = "";
175
+ let timedOut = false;
176
+ let killTimer;
177
+ if (typeof timeoutMs === "number" && timeoutMs > 0) {
178
+ killTimer = setTimeout(() => {
179
+ timedOut = true;
180
+ child.kill("SIGTERM");
181
+ setTimeout(() => child.kill("SIGKILL"), 2_000).unref?.();
182
+ }, timeoutMs);
183
+ killTimer.unref?.();
184
+ }
185
+ if (options.input) child.stdin.end(options.input);
186
+ else child.stdin.end();
187
+ child.stdout.on("data", (data) => {
188
+ stdout += String(data);
189
+ });
190
+ child.stderr.on("data", (data) => {
191
+ stderr += String(data);
192
+ });
193
+ child.on("close", (code) => {
194
+ if (killTimer) clearTimeout(killTimer);
195
+ if (timedOut) {
196
+ const error = new Error(stderr || stdout || `${command} timed out after ${timeoutMs}ms`);
197
+ if (options.allowFailure) resolve({ code, stdout: stdout.trim(), stderr: error.message });
198
+ else reject(error);
199
+ return;
200
+ }
201
+ if (code === 0 || options.allowFailure) resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
202
+ else reject(new Error(stderr || stdout || `${command} exited with code ${code}`));
203
+ });
204
+ child.on("error", (error) => {
205
+ if (killTimer) clearTimeout(killTimer);
206
+ reject(error);
207
+ });
208
+ });
209
+ }
210
+
211
+ function parseConversationId(chatUrl) {
212
+ if (!chatUrl) return undefined;
213
+ try {
214
+ const parsed = new URL(chatUrl);
215
+ const match = parsed.pathname.match(/\/c\/([^/?#]+)/i);
216
+ return match?.[1];
217
+ } catch {
218
+ return undefined;
219
+ }
220
+ }
221
+
222
+ async function cloneSeedProfileToRuntime(job) {
223
+ const seedDir = job.config.browser.authSeedProfileDir;
224
+ if (!existsSync(seedDir)) {
225
+ throw new Error(`Oracle auth seed profile not found: ${seedDir}. Run /oracle-auth first.`);
226
+ }
227
+
228
+ const seedGenerationPath = join(seedDir, SEED_GENERATION_FILE);
229
+ const seedGeneration = existsSync(seedGenerationPath) ? (await readFile(seedGenerationPath, "utf8")).trim() || undefined : undefined;
230
+
231
+ await withLock("auth", "global", { jobId: job.id, processPid: process.pid, action: "cloneSeedProfile" }, async () => {
232
+ await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
233
+ await ensurePrivateDir(dirname(job.runtimeProfileDir));
234
+ const cloneArgs = job.config.browser.cloneStrategy === "apfs-clone" ? ["-cR", seedDir, job.runtimeProfileDir] : ["-R", seedDir, job.runtimeProfileDir];
235
+ await spawnCommand("cp", cloneArgs);
236
+ }, 10 * 60 * 1000);
237
+
238
+ return seedGeneration;
239
+ }
240
+
241
+ async function cleanupRuntime(job) {
242
+ if (!job || cleaningUpRuntime) return;
243
+ cleaningUpRuntime = true;
244
+ try {
245
+ await closeBrowser(job).catch(() => undefined);
246
+ await releaseLease("conversation", job.conversationId).catch(() => undefined);
247
+ await releaseLease("runtime", job.runtimeId).catch(() => undefined);
248
+ await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
249
+ } finally {
250
+ cleaningUpRuntime = false;
251
+ }
252
+ }
253
+
254
+ function browserBaseArgs(job, options = {}) {
255
+ const args = ["--session", job.runtimeSessionName];
256
+ if (options.withLaunchOptions) {
257
+ args.push("--profile", job.runtimeProfileDir);
258
+ if (job.config.browser.executablePath) args.push("--executable-path", job.config.browser.executablePath);
259
+ if (job.config.browser.userAgent) args.push("--user-agent", job.config.browser.userAgent);
260
+ if (Array.isArray(job.config.browser.args) && job.config.browser.args.length > 0) args.push("--args", job.config.browser.args.join(","));
261
+ if (options.mode === "headed") args.push("--headed");
262
+ }
263
+ return args;
264
+ }
265
+
266
+ async function closeBrowser(job) {
267
+ if (cleaningUpBrowser) return;
268
+ cleaningUpBrowser = true;
269
+ try {
270
+ await spawnCommand("agent-browser", [...browserBaseArgs(job), "close"], { allowFailure: true });
271
+ } finally {
272
+ browserStarted = false;
273
+ cleaningUpBrowser = false;
274
+ }
275
+ }
276
+
277
+ async function launchBrowser(job, url) {
278
+ await closeBrowser(job);
279
+ const mode = job.config.browser.runMode;
280
+ await spawnCommand("agent-browser", [...browserBaseArgs(job, { withLaunchOptions: true, mode }), "open", url]);
281
+ browserStarted = true;
282
+ }
283
+
284
+ async function streamStatus(job) {
285
+ const { stdout } = await spawnCommand("agent-browser", [...browserBaseArgs(job), "--json", "stream", "status"], { allowFailure: true });
286
+ try {
287
+ const parsed = JSON.parse(stdout || "{}");
288
+ return parsed?.data || {};
289
+ } catch {
290
+ return {};
291
+ }
292
+ }
293
+
294
+ async function ensureBrowserConnected(job) {
295
+ if (!browserStarted || cleaningUpBrowser) return;
296
+ const status = await streamStatus(job);
297
+ if (status.connected === false) {
298
+ throw new Error("The isolated oracle browser disconnected during the job.");
299
+ }
300
+ }
301
+
302
+ async function agentBrowser(job, ...args) {
303
+ let options;
304
+ const maybeOptions = args.at(-1);
305
+ if (
306
+ maybeOptions &&
307
+ typeof maybeOptions === "object" &&
308
+ !Array.isArray(maybeOptions) &&
309
+ (Object.hasOwn(maybeOptions, "allowFailure") ||
310
+ Object.hasOwn(maybeOptions, "input") ||
311
+ Object.hasOwn(maybeOptions, "cwd") ||
312
+ Object.hasOwn(maybeOptions, "timeoutMs"))
313
+ ) {
314
+ options = args.pop();
315
+ }
316
+ await ensureBrowserConnected(job);
317
+ return spawnCommand("agent-browser", [...browserBaseArgs(job), ...args], options);
318
+ }
319
+
320
+ function parseEvalResult(stdout) {
321
+ if (!stdout) return undefined;
322
+ let value = stdout.trim();
323
+ try {
324
+ let parsed = JSON.parse(value);
325
+ while (typeof parsed === "string") parsed = JSON.parse(parsed);
326
+ return parsed;
327
+ } catch {
328
+ return value;
329
+ }
330
+ }
331
+
332
+ function toJsonScript(expression) {
333
+ return `JSON.stringify((() => { ${expression} })(), null, 2)`;
334
+ }
335
+
336
+ async function evalPage(job, script) {
337
+ const result = await agentBrowser(job, "eval", "--stdin", { input: script });
338
+ return parseEvalResult(result.stdout);
339
+ }
340
+
341
+ async function loginProbe(job) {
342
+ const result = await evalPage(job, buildLoginProbeScript(5_000));
343
+ if (!result || typeof result !== "object") {
344
+ return { ok: false, status: 0, error: "invalid-probe-result" };
345
+ }
346
+ return {
347
+ ok: result.ok === true,
348
+ status: typeof result.status === "number" ? result.status : 0,
349
+ pageUrl: typeof result.pageUrl === "string" ? result.pageUrl : undefined,
350
+ domLoginCta: result.domLoginCta === true,
351
+ onAuthPage: result.onAuthPage === true,
352
+ error: typeof result.error === "string" ? result.error : undefined,
353
+ bodyKeys: Array.isArray(result.bodyKeys) ? result.bodyKeys : [],
354
+ bodyHasId: result.bodyHasId === true,
355
+ bodyHasEmail: result.bodyHasEmail === true,
356
+ };
357
+ }
358
+
359
+ async function currentUrl(job) {
360
+ const { stdout } = await agentBrowser(job, "get", "url");
361
+ return stdout;
362
+ }
363
+
364
+ function stripQuery(url) {
365
+ try {
366
+ const parsed = new URL(url);
367
+ parsed.hash = "";
368
+ parsed.search = "";
369
+ return parsed.toString();
370
+ } catch {
371
+ return url;
372
+ }
373
+ }
374
+
375
+ async function snapshotText(job) {
376
+ const { stdout } = await agentBrowser(job, "snapshot", "-i");
377
+ return stdout;
378
+ }
379
+
380
+ async function pageText(job) {
381
+ const { stdout } = await agentBrowser(job, "get", "text", "body", { allowFailure: true });
382
+ return stdout || "";
383
+ }
384
+
385
+ function toAsyncJsonScript(expression) {
386
+ return `(async () => JSON.stringify(await (async () => { ${expression} })(), null, 2))()`;
387
+ }
388
+
389
+ function buildLoginProbeScript(timeoutMs) {
390
+ return toAsyncJsonScript(`
391
+ const pageUrl = typeof location === 'object' && location?.href ? location.href : null;
392
+ const onAuthPage =
393
+ typeof location === 'object' &&
394
+ ((typeof location.hostname === 'string' && /^auth\.openai\.com$/i.test(location.hostname)) ||
395
+ (typeof location.pathname === 'string' && /^\\/(auth|login|signin|log-in)/i.test(location.pathname)));
396
+
397
+ const hasLoginCta = () => {
398
+ const candidates = Array.from(
399
+ document.querySelectorAll(
400
+ [
401
+ 'a[href*="/auth/login"]',
402
+ 'a[href*="/auth/signin"]',
403
+ 'button[type="submit"]',
404
+ 'button[data-testid*="login"]',
405
+ 'button[data-testid*="log-in"]',
406
+ 'button[data-testid*="sign-in"]',
407
+ 'button[data-testid*="signin"]',
408
+ 'button',
409
+ 'a',
410
+ ].join(','),
411
+ ),
412
+ );
413
+ const textMatches = (text) => {
414
+ if (!text) return false;
415
+ const normalized = text.toLowerCase().trim();
416
+ return ['log in', 'login', 'sign in', 'signin', 'continue with'].some((needle) => normalized.startsWith(needle));
417
+ };
418
+ for (const node of candidates) {
419
+ if (!(node instanceof HTMLElement)) continue;
420
+ const label =
421
+ node.textContent?.trim() ||
422
+ node.getAttribute('aria-label') ||
423
+ node.getAttribute('title') ||
424
+ '';
425
+ if (textMatches(label)) return true;
426
+ }
427
+ return false;
428
+ };
429
+
430
+ let status = 0;
431
+ let error = null;
432
+ let bodyKeys = [];
433
+ let bodyHasId = false;
434
+ let bodyHasEmail = false;
435
+ try {
436
+ if (typeof fetch === 'function') {
437
+ const controller = new AbortController();
438
+ const timeout = setTimeout(() => controller.abort(), ${timeoutMs});
439
+ try {
440
+ const response = await fetch('/backend-api/me', {
441
+ cache: 'no-store',
442
+ credentials: 'include',
443
+ signal: controller.signal,
444
+ });
445
+ status = response.status || 0;
446
+ const contentType = response.headers.get('content-type') || '';
447
+ if (contentType.includes('application/json')) {
448
+ const data = await response.clone().json().catch(() => null);
449
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
450
+ bodyKeys = Object.keys(data).slice(0, 12);
451
+ bodyHasId = typeof data.id === 'string' && data.id.length > 0;
452
+ bodyHasEmail = typeof data.email === 'string' && data.email.includes('@');
453
+ }
454
+ }
455
+ } finally {
456
+ clearTimeout(timeout);
457
+ }
458
+ }
459
+ } catch (err) {
460
+ error = err ? String(err) : 'unknown';
461
+ }
462
+
463
+ const domLoginCta = hasLoginCta();
464
+ const loginSignals = domLoginCta || onAuthPage;
465
+ return {
466
+ ok: !loginSignals && (status === 0 || status === 200),
467
+ status,
468
+ pageUrl,
469
+ domLoginCta,
470
+ onAuthPage,
471
+ error,
472
+ bodyKeys,
473
+ bodyHasId,
474
+ bodyHasEmail,
475
+ };
476
+ `);
477
+ }
478
+
479
+ function parseSnapshotEntries(snapshot) {
480
+ return snapshot
481
+ .split("\n")
482
+ .map((line) => {
483
+ const refMatch = line.match(/\bref=(e\d+)\b/);
484
+ if (!refMatch) return undefined;
485
+ const kindMatch = line.match(/^\s*-\s*([^\s]+)/);
486
+ const quotedMatch = line.match(/"([^"]*)"/);
487
+ const valueMatch = line.match(/:\s*(.+)$/);
488
+ return {
489
+ line,
490
+ ref: `@${refMatch[1]}`,
491
+ kind: kindMatch ? kindMatch[1] : undefined,
492
+ label: quotedMatch ? quotedMatch[1] : undefined,
493
+ value: valueMatch ? valueMatch[1].trim() : undefined,
494
+ disabled: /\bdisabled\b/.test(line),
495
+ };
496
+ })
497
+ .filter(Boolean);
498
+ }
499
+
500
+ function findEntry(snapshot, predicate) {
501
+ return parseSnapshotEntries(snapshot).find(predicate);
502
+ }
503
+
504
+ function findLastEntry(snapshot, predicate) {
505
+ const entries = parseSnapshotEntries(snapshot);
506
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
507
+ if (predicate(entries[index])) return entries[index];
508
+ }
509
+ return undefined;
510
+ }
511
+
512
+ function matchesModelFamilyButton(candidate, family) {
513
+ return candidate.kind === "button" && typeof candidate.label === "string" && candidate.label.startsWith(MODEL_FAMILY_PREFIX[family]) && !candidate.disabled;
514
+ }
515
+
516
+ function titleCase(value) {
517
+ return value ? `${value[0].toUpperCase()}${value.slice(1)}` : value;
518
+ }
519
+
520
+ function requestedEffortLabel(job) {
521
+ return job.effort ? titleCase(job.effort) : undefined;
522
+ }
523
+
524
+ function effortSelectionVisible(snapshot, effortLabel) {
525
+ if (!effortLabel) return true;
526
+ const entries = parseSnapshotEntries(snapshot);
527
+ return entries.some((entry) => {
528
+ if (entry.disabled) return false;
529
+ if (entry.kind === "combobox" && entry.value === effortLabel) return true;
530
+ if (entry.kind !== "button") return false;
531
+ const label = String(entry.label || "").toLowerCase();
532
+ const normalizedEffort = effortLabel.toLowerCase();
533
+ return (
534
+ label === normalizedEffort ||
535
+ label === `${normalizedEffort} thinking` ||
536
+ label === `${normalizedEffort}, click to remove` ||
537
+ label === `${normalizedEffort} thinking, click to remove`
538
+ );
539
+ });
540
+ }
541
+
542
+ function thinkingChipVisible(snapshot) {
543
+ return /button "(?:Light|Standard|Extended|Heavy)(?: thinking)?(?:, click to remove)?"/i.test(snapshot);
544
+ }
545
+
546
+ function snapshotHasModelConfigurationUi(snapshot) {
547
+ const entries = parseSnapshotEntries(snapshot);
548
+ const visibleFamilies = new Set(
549
+ entries
550
+ .filter((entry) => entry.kind === "button" && typeof entry.label === "string")
551
+ .flatMap((entry) =>
552
+ Object.entries(MODEL_FAMILY_PREFIX)
553
+ .filter(([, prefix]) => entry.label.startsWith(prefix))
554
+ .map(([family]) => family),
555
+ ),
556
+ );
557
+ const hasCloseButton = entries.some((entry) => entry.kind === "button" && entry.label === CHATGPT_LABELS.close && !entry.disabled);
558
+ const hasEffortCombobox = entries.some(
559
+ (entry) => entry.kind === "combobox" && ["Light", "Standard", "Extended", "Heavy"].includes(entry.value || "") && !entry.disabled,
560
+ );
561
+ return visibleFamilies.size >= 2 || hasCloseButton || hasEffortCombobox;
562
+ }
563
+
564
+ function snapshotStronglyMatchesRequestedModel(snapshot, job) {
565
+ const entries = parseSnapshotEntries(snapshot);
566
+ const familyMatched = entries.some((entry) => matchesModelFamilyButton(entry, job.chatModelFamily));
567
+ if (job.chatModelFamily === "thinking") {
568
+ return familyMatched || effortSelectionVisible(snapshot, requestedEffortLabel(job));
569
+ }
570
+ if (job.chatModelFamily === "pro") {
571
+ return familyMatched;
572
+ }
573
+ return familyMatched;
574
+ }
575
+
576
+ function snapshotWeaklyMatchesRequestedModel(snapshot, job) {
577
+ if (job.chatModelFamily === "thinking") {
578
+ return effortSelectionVisible(snapshot, requestedEffortLabel(job));
579
+ }
580
+ if (job.chatModelFamily === "pro") {
581
+ return !thinkingChipVisible(snapshot);
582
+ }
583
+ if (job.chatModelFamily === "instant") {
584
+ return !thinkingChipVisible(snapshot);
585
+ }
586
+ return false;
587
+ }
588
+
589
+ async function clickRef(job, ref) {
590
+ await agentBrowser(job, "click", ref);
591
+ }
592
+
593
+ async function clickLabeledEntry(job, label, options = {}) {
594
+ const snapshot = await snapshotText(job);
595
+ const entry = (options.last ? findLastEntry : findEntry)(
596
+ snapshot,
597
+ (candidate) => candidate.label === label && (!options.kind || candidate.kind === options.kind) && !candidate.disabled,
598
+ );
599
+ if (!entry) throw new Error(`Could not find labeled entry: ${label}`);
600
+ await clickRef(job, entry.ref);
601
+ return entry;
602
+ }
603
+
604
+ async function maybeClickLabeledEntry(job, label, options = {}) {
605
+ const snapshot = await snapshotText(job);
606
+ const entry = (options.last ? findLastEntry : findEntry)(
607
+ snapshot,
608
+ (candidate) => candidate.label === label && (!options.kind || candidate.kind === options.kind) && !candidate.disabled,
609
+ );
610
+ if (!entry) return false;
611
+ await clickRef(job, entry.ref);
612
+ return true;
613
+ }
614
+
615
+ async function openEffortDropdown(job) {
616
+ const snapshot = await snapshotText(job);
617
+ const effortLabels = new Set(["Light", "Standard", "Extended", "Heavy"]);
618
+ const entry = findEntry(
619
+ snapshot,
620
+ (candidate) => candidate.kind === "combobox" && candidate.value && effortLabels.has(candidate.value) && !candidate.disabled,
621
+ );
622
+ if (!entry) return false;
623
+ await clickRef(job, entry.ref);
624
+ return true;
625
+ }
626
+
627
+ async function setComposerText(job, text) {
628
+ const snapshot = await snapshotText(job);
629
+ const entry = findEntry(snapshot, (candidate) => candidate.kind === "textbox" && candidate.label === CHATGPT_LABELS.composer);
630
+ if (!entry) throw new Error("Could not find ChatGPT composer textbox");
631
+ await agentBrowser(job, "fill", entry.ref, text);
632
+ }
633
+
634
+ function classifyChatPage({ job, url, snapshot, body, probe }) {
635
+ const text = `${snapshot}\n${body}`;
636
+ const challengePatterns = [
637
+ /just a moment/i,
638
+ /verify you are human/i,
639
+ /cloudflare/i,
640
+ /captcha|turnstile|hcaptcha/i,
641
+ /unusual activity detected/i,
642
+ /we detect suspicious activity/i,
643
+ ];
644
+ if (challengePatterns.some((pattern) => pattern.test(text))) {
645
+ return { state: "challenge_blocking", message: "ChatGPT is showing a challenge/verification page" };
646
+ }
647
+
648
+ const outagePatterns = [
649
+ /something went wrong/i,
650
+ /a network error occurred/i,
651
+ /an error occurred while connecting to the websocket/i,
652
+ /try again later/i,
653
+ /rate limit/i,
654
+ ];
655
+ if (outagePatterns.some((pattern) => pattern.test(text))) {
656
+ return { state: "transient_outage_error", message: "ChatGPT is showing a transient outage/error page" };
657
+ }
658
+
659
+ const allowedOrigins = [new URL(job.config.browser.chatUrl).origin, "https://auth.openai.com"];
660
+ const onAllowedOrigin = typeof url === "string" && allowedOrigins.some((origin) => url.startsWith(origin));
661
+ const onAuthPath = typeof url === "string" && url.includes("/auth/");
662
+ const hasComposer = snapshot.includes(`textbox "${CHATGPT_LABELS.composer}"`);
663
+ const hasAddFiles = snapshot.includes(`button "${CHATGPT_LABELS.addFiles}"`);
664
+ const hasModelControl = snapshot.includes('button "Model selector"') || /button "(Instant|Thinking|Pro)(?: [^"]*)?"/.test(snapshot);
665
+
666
+ if (probe?.status === 401 || probe?.status === 403) {
667
+ return { state: "login_required", message: "ChatGPT login is required. Run /oracle-auth." };
668
+ }
669
+
670
+ if (onAuthPath || probe?.onAuthPage) {
671
+ if (probe?.bodyHasId || probe?.bodyHasEmail) {
672
+ return {
673
+ state: "auth_transitioning",
674
+ message: "ChatGPT is on an auth page even though the backend session is partially authenticated. Rerun /oracle-auth.",
675
+ };
676
+ }
677
+ return { state: "login_required", message: "ChatGPT login is required. Run /oracle-auth." };
678
+ }
679
+
680
+ if (onAllowedOrigin && probe?.status === 200 && hasComposer && hasAddFiles && hasModelControl) {
681
+ if (probe?.domLoginCta && (probe?.bodyHasId || probe?.bodyHasEmail)) {
682
+ return {
683
+ state: "auth_transitioning",
684
+ message: "ChatGPT backend session is authenticated, but the web shell still shows public login CTA chrome. Rerun /oracle-auth.",
685
+ };
686
+ }
687
+ return { state: "authenticated_and_ready", message: "ChatGPT is authenticated and ready." };
688
+ }
689
+
690
+ if (url && !onAllowedOrigin) {
691
+ return { state: "login_required", message: "ChatGPT redirected away from the expected authenticated chat origin." };
692
+ }
693
+
694
+ return { state: "unknown", message: "ChatGPT page is not ready yet." };
695
+ }
696
+
697
+ async function captureDiagnostics(job, reason) {
698
+ if (!browserStarted) return;
699
+ try {
700
+ const [url, snapshot, body] = await Promise.all([
701
+ currentUrl(job).catch(() => ""),
702
+ snapshotText(job).catch(() => ""),
703
+ pageText(job).catch(() => ""),
704
+ ]);
705
+ await secureWriteText(join(job.logsDir, `${reason}.url.txt`), `${url || ""}\n`);
706
+ await secureWriteText(join(job.logsDir, `${reason}.snapshot.txt`), `${snapshot || ""}\n`);
707
+ await secureWriteText(join(job.logsDir, `${reason}.body.txt`), `${body || ""}\n`);
708
+ await agentBrowser(job, "screenshot", join(job.logsDir, `${reason}.png`)).catch(() => undefined);
709
+ } catch {
710
+ // best effort only
711
+ }
712
+ }
713
+
714
+ async function waitForOracleReady(job) {
715
+ const startedAt = Date.now();
716
+ const timeoutAt = startedAt + 30_000;
717
+ let retriedOutage = false;
718
+ let retriedAuthTransition = false;
719
+
720
+ while (Date.now() < timeoutAt) {
721
+ const [url, snapshot, body, probe] = await Promise.all([
722
+ currentUrl(job).catch(() => ""),
723
+ snapshotText(job).catch(() => ""),
724
+ pageText(job).catch(() => ""),
725
+ loginProbe(job).catch(() => ({ ok: false, status: 0, error: "probe-failed" })),
726
+ ]);
727
+ const classification = classifyChatPage({ job, url, snapshot, body, probe });
728
+ if (classification.state === "authenticated_and_ready") return;
729
+ if (classification.state === "auth_transitioning") {
730
+ const elapsedMs = Date.now() - startedAt;
731
+ if (!retriedAuthTransition && elapsedMs >= 5_000) {
732
+ retriedAuthTransition = true;
733
+ await agentBrowser(job, "reload").catch(() => undefined);
734
+ await sleep(1500);
735
+ continue;
736
+ }
737
+ if (elapsedMs >= 15_000) {
738
+ await captureDiagnostics(job, "preflight-auth-transition");
739
+ throw new Error("ChatGPT backend session is authenticated, but the web shell stayed in a partially logged-in state. Rerun /oracle-auth.");
740
+ }
741
+ await sleep(1000);
742
+ continue;
743
+ }
744
+ if (classification.state === "transient_outage_error" && !retriedOutage) {
745
+ retriedOutage = true;
746
+ await agentBrowser(job, "reload").catch(() => undefined);
747
+ await sleep(1500);
748
+ continue;
749
+ }
750
+ if (classification.state !== "unknown") {
751
+ await captureDiagnostics(job, "preflight");
752
+ throw new Error(classification.message);
753
+ }
754
+ await sleep(1000);
755
+ }
756
+
757
+ await captureDiagnostics(job, "preflight-timeout");
758
+ throw new Error("Timed out waiting for the ChatGPT chat UI to become ready");
759
+ }
760
+
761
+ function detectUploadErrorText(text) {
762
+ const patterns = [
763
+ "Failed upload",
764
+ "upload failed",
765
+ "files.oaiusercontent.com",
766
+ "Please ensure your network settings allow access to this site",
767
+ "could not upload",
768
+ ];
769
+ return patterns.find((pattern) => text.toLowerCase().includes(pattern.toLowerCase()));
770
+ }
771
+
772
+ function composerSnapshotSlice(snapshot) {
773
+ const lines = snapshot.split("\n");
774
+ let composerIndex = -1;
775
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
776
+ if (lines[index].includes(`textbox "${CHATGPT_LABELS.composer}"`)) {
777
+ composerIndex = index;
778
+ break;
779
+ }
780
+ }
781
+ if (composerIndex === -1) return snapshot;
782
+ const startIndex = Math.max(0, composerIndex - 16);
783
+ const endIndex = Math.min(lines.length, composerIndex + 16);
784
+ return lines.slice(startIndex, endIndex).join("\n");
785
+ }
786
+
787
+ function composerFileEntryCount(snapshot, fileLabel) {
788
+ const composerSlice = composerSnapshotSlice(snapshot);
789
+ return parseSnapshotEntries(composerSlice).filter((candidate) => candidate.label === fileLabel).length;
790
+ }
791
+
792
+ async function waitForUploadConfirmed(job, fileLabel, baselineCount) {
793
+ const timeoutAt = Date.now() + 10 * 60 * 1000;
794
+ let stableCount = 0;
795
+
796
+ while (Date.now() < timeoutAt) {
797
+ await heartbeat();
798
+ const [snapshot, body] = await Promise.all([snapshotText(job), pageText(job).catch(() => "")]);
799
+
800
+ const errorText = detectUploadErrorText(`${snapshot}\n${body}`);
801
+ if (errorText) {
802
+ throw new Error(`Upload error detected: ${errorText}`);
803
+ }
804
+
805
+ const sendEntry = findEntry(
806
+ snapshot,
807
+ (candidate) => candidate.kind === "button" && candidate.label === CHATGPT_LABELS.send && !candidate.disabled,
808
+ );
809
+ const fileCount = composerFileEntryCount(snapshot, fileLabel);
810
+
811
+ if (sendEntry && fileCount > baselineCount) {
812
+ stableCount += 1;
813
+ if (stableCount >= 2) return sendEntry;
814
+ } else {
815
+ stableCount = 0;
816
+ }
817
+
818
+ await sleep(1000);
819
+ }
820
+
821
+ throw new Error(`Timed out waiting for upload confirmation for ${fileLabel}`);
822
+ }
823
+
824
+ async function waitForSendReady(job) {
825
+ const timeoutAt = Date.now() + 5 * 60 * 1000;
826
+ while (Date.now() < timeoutAt) {
827
+ await heartbeat();
828
+ const snapshot = await snapshotText(job);
829
+ const body = await pageText(job).catch(() => "");
830
+ const errorText = detectUploadErrorText(`${snapshot}\n${body}`);
831
+ if (errorText) {
832
+ throw new Error(`Upload error detected: ${errorText}`);
833
+ }
834
+
835
+ const entry = findEntry(
836
+ snapshot,
837
+ (candidate) => candidate.kind === "button" && candidate.label === CHATGPT_LABELS.send && !candidate.disabled,
838
+ );
839
+ if (entry) return entry;
840
+ await sleep(1000);
841
+ }
842
+ throw new Error(`Timed out waiting for ${CHATGPT_LABELS.send} to become enabled`);
843
+ }
844
+
845
+ async function clickSend(job) {
846
+ const entry = await waitForSendReady(job);
847
+ await clickRef(job, entry.ref);
848
+ }
849
+
850
+ async function openModelConfiguration(job) {
851
+ const openerPredicates = [
852
+ (candidate) => candidate.kind === "button" && candidate.label === "Model selector" && !candidate.disabled,
853
+ (candidate) => candidate.kind === "button" && ["Instant", "Thinking", "Pro"].includes(candidate.label || "") && !candidate.disabled,
854
+ ];
855
+
856
+ const initialSnapshot = await snapshotText(job);
857
+ if (snapshotHasModelConfigurationUi(initialSnapshot)) return initialSnapshot;
858
+
859
+ for (const predicate of openerPredicates) {
860
+ const snapshot = await snapshotText(job);
861
+ const entry = findEntry(snapshot, predicate);
862
+ if (!entry) continue;
863
+ await clickRef(job, entry.ref);
864
+ await agentBrowser(job, "wait", "800");
865
+ const after = await snapshotText(job);
866
+ if (snapshotHasModelConfigurationUi(after)) return after;
867
+
868
+ const configureEntry = findEntry(
869
+ after,
870
+ (candidate) => candidate.kind === "menuitem" && candidate.label === CHATGPT_LABELS.configure && !candidate.disabled,
871
+ );
872
+
873
+ if (configureEntry) {
874
+ await clickRef(job, configureEntry.ref);
875
+ await agentBrowser(job, "wait", "1200");
876
+ const postConfigure = await snapshotText(job);
877
+ if (snapshotHasModelConfigurationUi(postConfigure)) return postConfigure;
878
+ }
879
+ }
880
+
881
+ throw new Error("Could not open model configuration UI");
882
+ }
883
+
884
+ async function configureModel(job) {
885
+ const initialSnapshot = await snapshotText(job);
886
+ if (snapshotStronglyMatchesRequestedModel(initialSnapshot, job)) {
887
+ await log(`Model already appears configured for family=${job.chatModelFamily} effort=${job.effort || "(none)"}; skipping reconfiguration`);
888
+ return;
889
+ }
890
+
891
+ await log(`Configuring model family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
892
+ let familySnapshot = await openModelConfiguration(job);
893
+
894
+ let familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyButton(candidate, job.chatModelFamily));
895
+ if (!familyEntry && snapshotStronglyMatchesRequestedModel(familySnapshot, job)) {
896
+ await log("Model configuration UI opened with requested settings already selected");
897
+ }
898
+ if (!familyEntry && !snapshotStronglyMatchesRequestedModel(familySnapshot, job)) {
899
+ throw new Error(`Could not find model family button for ${job.chatModelFamily}`);
900
+ }
901
+
902
+ if (familyEntry) {
903
+ await clickRef(job, familyEntry.ref);
904
+ await agentBrowser(job, "wait", "800");
905
+ familySnapshot = await snapshotText(job);
906
+ }
907
+
908
+ if (job.chatModelFamily === "thinking" || job.chatModelFamily === "pro") {
909
+ const effortLabel = requestedEffortLabel(job);
910
+ if (effortLabel && !effortSelectionVisible(familySnapshot, effortLabel)) {
911
+ const opened = await openEffortDropdown(job);
912
+ if (!opened) {
913
+ throw new Error(`Could not open effort dropdown for requested effort: ${effortLabel}`);
914
+ }
915
+ await agentBrowser(job, "wait", "300");
916
+ await clickLabeledEntry(job, effortLabel, { kind: "option" });
917
+ await agentBrowser(job, "wait", "400");
918
+ const effortSnapshot = await snapshotText(job);
919
+ const selectedEffort = findEntry(
920
+ effortSnapshot,
921
+ (candidate) => candidate.kind === "combobox" && candidate.value === effortLabel && !candidate.disabled,
922
+ );
923
+ if (!selectedEffort && !effortSelectionVisible(effortSnapshot, effortLabel)) {
924
+ throw new Error(`Requested effort did not remain selected: ${effortLabel}`);
925
+ }
926
+ }
927
+ }
928
+
929
+ if (job.chatModelFamily === "instant" && job.autoSwitchToThinking) {
930
+ await maybeClickLabeledEntry(job, CHATGPT_LABELS.autoSwitchToThinking);
931
+ }
932
+
933
+ if (!(await maybeClickLabeledEntry(job, CHATGPT_LABELS.close, { kind: "button" }))) {
934
+ await agentBrowser(job, "press", "Escape").catch(() => undefined);
935
+ }
936
+ await agentBrowser(job, "wait", "500");
937
+
938
+ const postCloseSnapshot = await snapshotText(job);
939
+ if (!snapshotWeaklyMatchesRequestedModel(postCloseSnapshot, job)) {
940
+ throw new Error(`Could not verify requested model settings after configuration for ${job.chatModelFamily}`);
941
+ }
942
+ }
943
+
944
+ async function uploadArchive(job) {
945
+ if (!existsSync(job.archivePath)) {
946
+ throw new Error(`Archive missing: ${job.archivePath}`);
947
+ }
948
+
949
+ const fileLabel = basename(job.archivePath);
950
+ const addFilesSnapshot = await snapshotText(job);
951
+ const baselineComposerFileCount = composerFileEntryCount(addFilesSnapshot, fileLabel);
952
+ const addFilesEntry = findEntry(
953
+ addFilesSnapshot,
954
+ (candidate) => candidate.label === CHATGPT_LABELS.addFiles && candidate.kind === "button",
955
+ );
956
+ if (!addFilesEntry) {
957
+ throw new Error(`Could not find "${CHATGPT_LABELS.addFiles}" button`);
958
+ }
959
+
960
+ await clickRef(job, addFilesEntry.ref);
961
+ await agentBrowser(job, "wait", "500");
962
+ await agentBrowser(job, "upload", "input[type=file]", job.archivePath);
963
+ await log(`Selected archive for upload: ${job.archivePath}`);
964
+ await waitForUploadConfirmed(job, fileLabel, baselineComposerFileCount);
965
+ await log(`Upload confirmed for: ${fileLabel}`);
966
+ await rm(job.archivePath, { force: true });
967
+ await mutateJob((current) => ({ ...current, archiveDeletedAfterUpload: true }));
968
+ }
969
+
970
+ async function assistantMessages(job) {
971
+ const result = await evalPage(
972
+ job,
973
+ toJsonScript(`
974
+ const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]'))
975
+ .filter((el) => (el.textContent || '').trim() === 'ChatGPT said:');
976
+ const renderText = (node) => {
977
+ if (!node) return '';
978
+ const clone = node.cloneNode(true);
979
+ const host = document.createElement('div');
980
+ host.style.position = 'fixed';
981
+ host.style.left = '-99999px';
982
+ host.style.top = '0';
983
+ host.style.whiteSpace = 'pre-wrap';
984
+ host.style.pointerEvents = 'none';
985
+ host.appendChild(clone);
986
+ document.body.appendChild(host);
987
+ let text = (host.innerText || host.textContent || '').trim();
988
+ host.remove();
989
+ const endings = ['\\nChatGPT can make mistakes. Check important info.'];
990
+ for (const ending of endings) {
991
+ if (text.includes(ending)) text = text.split(ending)[0].trim();
992
+ }
993
+ text = text
994
+ .split('\\n')
995
+ .map((line) => line.trimEnd())
996
+ .filter((line) => line.trim() && !/^Thought for\\b/i.test(line.trim()))
997
+ .join('\\n')
998
+ .trim();
999
+ return text;
1000
+ };
1001
+ return {
1002
+ messages: headings.map((heading) => ({ text: renderText(heading.nextElementSibling) })),
1003
+ };
1004
+ `),
1005
+ );
1006
+
1007
+ if (!Array.isArray(result?.messages)) return [];
1008
+ return result.messages.map((message) => ({ text: typeof message?.text === "string" ? message.text : "" }));
1009
+ }
1010
+
1011
+ function assistantSnapshotSlice(snapshot, responseIndex) {
1012
+ const lines = snapshot.split("\n");
1013
+ const assistantHeadingIndices = lines.flatMap((line, index) => (line.includes('heading "ChatGPT said:"') ? [index] : []));
1014
+ const startIndex = assistantHeadingIndices[responseIndex];
1015
+ if (startIndex === undefined) return undefined;
1016
+
1017
+ const endCandidates = [];
1018
+ const nextAssistantIndex = assistantHeadingIndices[responseIndex + 1];
1019
+ if (nextAssistantIndex !== undefined) endCandidates.push(nextAssistantIndex);
1020
+
1021
+ const composerIndex = lines.findIndex(
1022
+ (line, index) => index > startIndex && line.includes(`textbox "${CHATGPT_LABELS.composer}"`),
1023
+ );
1024
+ if (composerIndex !== -1) endCandidates.push(composerIndex);
1025
+
1026
+ const endIndex = endCandidates.length > 0 ? Math.min(...endCandidates) : undefined;
1027
+ return lines.slice(startIndex, endIndex).join("\n");
1028
+ }
1029
+
1030
+ async function waitForStableChatUrl(job, previousChatUrl) {
1031
+ const timeoutAt = Date.now() + 60_000;
1032
+ let lastUrl = "";
1033
+ let stableCount = 0;
1034
+
1035
+ while (Date.now() < timeoutAt) {
1036
+ await heartbeat();
1037
+ const url = stripQuery(await currentUrl(job));
1038
+ let isConversationUrl = false;
1039
+ try {
1040
+ isConversationUrl = /\/c\/[A-Za-z0-9-]+$/i.test(new URL(url).pathname);
1041
+ } catch {
1042
+ isConversationUrl = false;
1043
+ }
1044
+ const isKnownFollowUpUrl = previousChatUrl ? stripQuery(previousChatUrl) === url : false;
1045
+
1046
+ if (isConversationUrl || isKnownFollowUpUrl) {
1047
+ if (url === lastUrl) stableCount += 1;
1048
+ else stableCount = 1;
1049
+ lastUrl = url;
1050
+ if (stableCount >= 2) return url;
1051
+ }
1052
+
1053
+ await sleep(1000);
1054
+ }
1055
+
1056
+ return previousChatUrl || stripQuery(await currentUrl(job));
1057
+ }
1058
+
1059
+ async function waitForChatCompletion(job, baselineAssistantCount) {
1060
+ const timeoutAt = Date.now() + job.config.worker.completionTimeoutMs;
1061
+ let lastText = "";
1062
+ let stableCount = 0;
1063
+
1064
+ while (Date.now() < timeoutAt) {
1065
+ await heartbeat();
1066
+ const snapshot = await snapshotText(job);
1067
+ const hasStopStreaming = snapshot.includes("Stop streaming");
1068
+ const copyResponseCount = (snapshot.match(/Copy response/g) || []).length;
1069
+ const messages = await assistantMessages(job);
1070
+ const targetMessage = messages[baselineAssistantCount];
1071
+ const targetText = targetMessage?.text || "";
1072
+ const hasTargetCopyResponse = copyResponseCount > baselineAssistantCount;
1073
+
1074
+ if (!hasStopStreaming && hasTargetCopyResponse && targetText) {
1075
+ if (targetText === lastText) stableCount += 1;
1076
+ else stableCount = 1;
1077
+ lastText = targetText;
1078
+ if (stableCount >= 2) {
1079
+ return { responseIndex: baselineAssistantCount, responseText: targetText };
1080
+ }
1081
+ }
1082
+
1083
+ await sleep(job.config.worker.pollMs);
1084
+ }
1085
+
1086
+ throw new Error("Timed out waiting for ChatGPT response completion");
1087
+ }
1088
+
1089
+ async function sha256(path) {
1090
+ const buffer = await readFile(path);
1091
+ return createHash("sha256").update(buffer).digest("hex");
1092
+ }
1093
+
1094
+ async function detectType(path) {
1095
+ const result = await spawnCommand("file", ["-b", path], { allowFailure: true });
1096
+ return result.stdout || "unknown";
1097
+ }
1098
+
1099
+ function isLikelyArtifactLabel(label) {
1100
+ const normalized = String(label || "").trim();
1101
+ if (!normalized) return false;
1102
+ const upper = normalized.toUpperCase();
1103
+ if (upper === "ATTACHED" || upper === "DONE") return true;
1104
+ return /(?:^|[^\w])[^\n]*\.[A-Za-z0-9]{1,12}(?:$|[^\w])/.test(normalized);
1105
+ }
1106
+
1107
+ function preferredArtifactName(label, index) {
1108
+ const normalized = String(label || "").trim();
1109
+ const fileNameMatch = normalized.match(/([A-Za-z0-9._-]+\.[A-Za-z0-9]{1,12})(?!.*[A-Za-z0-9._-]+\.[A-Za-z0-9]{1,12})/);
1110
+ if (fileNameMatch) return basename(fileNameMatch[1]).replace(/[^a-zA-Z0-9._-]/g, "_");
1111
+ return `artifact-${String(index + 1).padStart(2, "0")}`;
1112
+ }
1113
+
1114
+ function artifactCandidatesFromEntries(entries) {
1115
+ const excluded = new Set([
1116
+ "Copy response",
1117
+ "Good response",
1118
+ "Bad response",
1119
+ "Share",
1120
+ "Switch model",
1121
+ "More actions",
1122
+ CHATGPT_LABELS.addFiles,
1123
+ "Start dictation",
1124
+ "Start Voice",
1125
+ "Model selector",
1126
+ "Open conversation options",
1127
+ "Scroll to bottom",
1128
+ CHATGPT_LABELS.close,
1129
+ ]);
1130
+
1131
+ const seen = new Set();
1132
+ const candidates = [];
1133
+ for (const entry of entries) {
1134
+ if (!entry.label) continue;
1135
+ if (excluded.has(entry.label)) continue;
1136
+ if (entry.label.startsWith("Thought for ")) continue;
1137
+ if (entry.kind !== "button" && entry.kind !== "link") continue;
1138
+ if (!isLikelyArtifactLabel(entry.label)) continue;
1139
+ if (seen.has(entry.label)) continue;
1140
+ seen.add(entry.label);
1141
+ candidates.push({ label: entry.label });
1142
+ }
1143
+ return candidates;
1144
+ }
1145
+
1146
+ async function collectArtifactCandidates(job, responseIndex) {
1147
+ const snapshot = await snapshotText(job);
1148
+ const targetSlice = assistantSnapshotSlice(snapshot, responseIndex);
1149
+ if (!targetSlice) return { snapshot, targetSlice, candidates: [] };
1150
+ return {
1151
+ snapshot,
1152
+ targetSlice,
1153
+ candidates: artifactCandidatesFromEntries(parseSnapshotEntries(targetSlice)),
1154
+ };
1155
+ }
1156
+
1157
+ async function waitForStableArtifactCandidates(job, responseIndex) {
1158
+ const deadline = Date.now() + ARTIFACT_CANDIDATE_STABILITY_TIMEOUT_MS;
1159
+ let lastSignature;
1160
+ let stablePolls = 0;
1161
+ let latest = { snapshot: "", targetSlice: undefined, candidates: [] };
1162
+
1163
+ while (Date.now() < deadline) {
1164
+ latest = await collectArtifactCandidates(job, responseIndex);
1165
+ const signature = latest.candidates.map((candidate) => candidate.label).join("\n");
1166
+ if (signature === lastSignature) stablePolls += 1;
1167
+ else {
1168
+ lastSignature = signature;
1169
+ stablePolls = 1;
1170
+ }
1171
+ if (stablePolls >= ARTIFACT_CANDIDATE_STABILITY_POLLS) return latest;
1172
+ await heartbeat();
1173
+ await sleep(ARTIFACT_CANDIDATE_STABILITY_POLL_MS);
1174
+ }
1175
+
1176
+ return latest;
1177
+ }
1178
+
1179
+ async function reopenConversationForArtifacts(job, responseIndex, reason) {
1180
+ const targetUrl = job.chatUrl || stripQuery(await currentUrl(job));
1181
+ await log(`Reopening conversation before artifact capture (${reason}): ${targetUrl}`);
1182
+ await agentBrowser(job, "open", targetUrl);
1183
+ await agentBrowser(job, "wait", "1500");
1184
+ return waitForStableArtifactCandidates(job, responseIndex);
1185
+ }
1186
+
1187
+ async function withHeartbeatWhile(task, intervalMs = ARTIFACT_DOWNLOAD_HEARTBEAT_MS) {
1188
+ let inFlight = true;
1189
+ let heartbeatRunning = false;
1190
+ const timer = setInterval(() => {
1191
+ if (!inFlight || heartbeatRunning) return;
1192
+ heartbeatRunning = true;
1193
+ void heartbeat()
1194
+ .catch(() => undefined)
1195
+ .finally(() => {
1196
+ heartbeatRunning = false;
1197
+ });
1198
+ }, intervalMs);
1199
+ timer.unref?.();
1200
+ try {
1201
+ return await task();
1202
+ } finally {
1203
+ inFlight = false;
1204
+ clearInterval(timer);
1205
+ }
1206
+ }
1207
+
1208
+ async function flushArtifactsState(artifacts) {
1209
+ await secureWriteText(`${jobDir}/artifacts.json`, `${JSON.stringify(artifacts, null, 2)}\n`);
1210
+ await mutateJob((current) => ({
1211
+ ...current,
1212
+ artifactPaths: artifacts.flatMap((artifact) => (artifact.copiedPath && existsSync(artifact.copiedPath) ? [artifact.copiedPath] : [])),
1213
+ }));
1214
+ }
1215
+
1216
+ async function downloadArtifacts(job, responseIndex) {
1217
+ if (!job.config.artifacts.capture) {
1218
+ await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
1219
+ await mutateJob((current) => ({ ...current, artifactPaths: [] }));
1220
+ return [];
1221
+ }
1222
+
1223
+ const { targetSlice, candidates } = await reopenConversationForArtifacts(job, responseIndex, "initial");
1224
+ if (!targetSlice) {
1225
+ await log(`No assistant response found in snapshot for response index ${responseIndex}`);
1226
+ await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
1227
+ await mutateJob((current) => ({ ...current, artifactPaths: [] }));
1228
+ return [];
1229
+ }
1230
+
1231
+ await log(`Artifact candidates: ${candidates.map((candidate) => candidate.label).join(", ") || "(none)"}`);
1232
+
1233
+ const artifactsDir = `${jobDir}/artifacts`;
1234
+ await ensurePrivateDir(artifactsDir);
1235
+ const artifacts = [];
1236
+ await flushArtifactsState(artifacts);
1237
+
1238
+ for (const [index, candidate] of candidates.entries()) {
1239
+ let downloaded = false;
1240
+ for (let attempt = 1; attempt <= ARTIFACT_DOWNLOAD_MAX_ATTEMPTS && !downloaded; attempt += 1) {
1241
+ const freshSnapshot = await snapshotText(job);
1242
+ const freshSlice = assistantSnapshotSlice(freshSnapshot, responseIndex);
1243
+ if (!freshSlice) break;
1244
+ const freshEntries = parseSnapshotEntries(freshSlice);
1245
+ const entry = freshEntries.find(
1246
+ (artifactEntry) => artifactEntry.label === candidate.label && (artifactEntry.kind === "button" || artifactEntry.kind === "link") && !artifactEntry.disabled,
1247
+ );
1248
+ if (!entry) {
1249
+ await log(`Artifact "${candidate.label}" not found in fresh snapshot, skipping`);
1250
+ break;
1251
+ }
1252
+
1253
+ const destinationPath = join(artifactsDir, preferredArtifactName(candidate.label, index));
1254
+ await rm(destinationPath, { force: true }).catch(() => undefined);
1255
+ try {
1256
+ await log(`Artifact "${candidate.label}" download attempt ${attempt}/${ARTIFACT_DOWNLOAD_MAX_ATTEMPTS} using ref ${entry.ref}`);
1257
+ await withHeartbeatWhile(() =>
1258
+ agentBrowser(job, "download", entry.ref, destinationPath, {
1259
+ timeoutMs: ARTIFACT_DOWNLOAD_TIMEOUT_MS,
1260
+ }),
1261
+ );
1262
+ await heartbeat(undefined, { force: true });
1263
+ await chmod(destinationPath, 0o600).catch(() => undefined);
1264
+ const [size, checksum, detectedType] = await Promise.all([
1265
+ stat(destinationPath).then((stats) => stats.size),
1266
+ sha256(destinationPath),
1267
+ detectType(destinationPath),
1268
+ ]);
1269
+ artifacts.push({
1270
+ displayName: candidate.label,
1271
+ fileName: basename(destinationPath),
1272
+ copiedPath: destinationPath,
1273
+ size,
1274
+ sha256: checksum,
1275
+ detectedType,
1276
+ });
1277
+ downloaded = true;
1278
+ } catch (error) {
1279
+ const message = error instanceof Error ? error.message : String(error);
1280
+ await rm(destinationPath, { force: true }).catch(() => undefined);
1281
+ await log(`Artifact "${candidate.label}" download failed on attempt ${attempt}/${ARTIFACT_DOWNLOAD_MAX_ATTEMPTS}: ${message}`);
1282
+ if (attempt >= ARTIFACT_DOWNLOAD_MAX_ATTEMPTS) {
1283
+ artifacts.push({ displayName: candidate.label, unconfirmed: true, error: message });
1284
+ } else {
1285
+ await reopenConversationForArtifacts(job, responseIndex, `retry ${attempt + 1} for ${candidate.label}`);
1286
+ await sleep(1_000);
1287
+ }
1288
+ } finally {
1289
+ await flushArtifactsState(artifacts);
1290
+ }
1291
+ }
1292
+ }
1293
+
1294
+ return artifacts;
1295
+ }
1296
+
1297
+ function installSignalHandlers(job) {
1298
+ const handleSignal = (signal) => {
1299
+ if (shuttingDown) return;
1300
+ shuttingDown = true;
1301
+ void (async () => {
1302
+ await log(`Received ${signal}, cleaning up oracle runtime`);
1303
+ await cleanupRuntime(job);
1304
+ process.exit(0);
1305
+ })();
1306
+ };
1307
+
1308
+ process.on("SIGTERM", () => handleSignal("SIGTERM"));
1309
+ process.on("SIGINT", () => handleSignal("SIGINT"));
1310
+ }
1311
+
1312
+ async function run() {
1313
+ await ensurePrivateDir(jobDir);
1314
+ await ensurePrivateDir(`${jobDir}/logs`);
1315
+ currentJob = await readJob();
1316
+ installSignalHandlers(currentJob);
1317
+
1318
+ try {
1319
+ await log(`Starting oracle worker for job ${currentJob.id}`);
1320
+ await heartbeat(phasePatch("cloning_runtime", { status: "waiting" }), { force: true });
1321
+ await closeBrowser(currentJob);
1322
+
1323
+ const seedGeneration = await cloneSeedProfileToRuntime(currentJob);
1324
+ currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("launching_browser", { seedGeneration, heartbeatAt: new Date().toISOString() }) }));
1325
+
1326
+ const targetUrl = currentJob.chatUrl || currentJob.config.browser.chatUrl;
1327
+ await launchBrowser(currentJob, targetUrl);
1328
+ currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("verifying_auth", { heartbeatAt: new Date().toISOString() }) }));
1329
+ await waitForOracleReady(currentJob);
1330
+ currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("configuring_model", { heartbeatAt: new Date().toISOString() }) }));
1331
+ await configureModel(currentJob);
1332
+ currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("uploading_archive", { heartbeatAt: new Date().toISOString() }) }));
1333
+ await uploadArchive(currentJob);
1334
+ await setComposerText(currentJob, await readFile(currentJob.promptPath, "utf8"));
1335
+ const baselineAssistantCount = (await assistantMessages(currentJob)).length;
1336
+ await log(`Assistant response count before send: ${baselineAssistantCount}`);
1337
+ await clickSend(currentJob);
1338
+
1339
+ const chatUrl = await waitForStableChatUrl(currentJob, currentJob.chatUrl);
1340
+ const conversationId = parseConversationId(chatUrl) || currentJob.conversationId;
1341
+ currentJob = await mutateJob((job) => ({
1342
+ ...job,
1343
+ ...phasePatch("awaiting_response", { chatUrl, conversationId, heartbeatAt: new Date().toISOString() }),
1344
+ }));
1345
+
1346
+ const completion = await waitForChatCompletion(currentJob, baselineAssistantCount);
1347
+ currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("extracting_response", { heartbeatAt: new Date().toISOString() }) }));
1348
+ await secureWriteText(currentJob.responsePath, `${completion.responseText.trim()}\n`);
1349
+ currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("downloading_artifacts", { heartbeatAt: new Date().toISOString() }) }));
1350
+ const artifacts = await downloadArtifacts(currentJob, completion.responseIndex);
1351
+ const artifactFailureCount = artifacts.filter((artifact) => artifact.unconfirmed || artifact.error).length;
1352
+
1353
+ await heartbeat(
1354
+ phasePatch(artifactFailureCount > 0 ? "complete_with_artifact_errors" : "complete", {
1355
+ status: "complete",
1356
+ completedAt: new Date().toISOString(),
1357
+ responsePath: currentJob.responsePath,
1358
+ responseFormat: "text/plain",
1359
+ artifactFailureCount,
1360
+ }),
1361
+ { force: true },
1362
+ );
1363
+ const persistedJob = await readJob().catch(() => undefined);
1364
+ await log(`Persisted final status after completion write: ${persistedJob?.status || "unknown"}`);
1365
+ await log(`Job ${currentJob.id} complete`);
1366
+ } catch (error) {
1367
+ if (!shuttingDown) {
1368
+ const message = error instanceof Error ? error.message : String(error);
1369
+ await captureDiagnostics(currentJob, "failure");
1370
+ await log(`Job failed: ${message}`);
1371
+ await heartbeat(
1372
+ phasePatch("failed", {
1373
+ status: "failed",
1374
+ completedAt: new Date().toISOString(),
1375
+ error: message,
1376
+ }),
1377
+ { force: true },
1378
+ );
1379
+ process.exitCode = 1;
1380
+ }
1381
+ } finally {
1382
+ await cleanupRuntime(currentJob).catch(() => undefined);
1383
+ }
1384
+ }
1385
+
1386
+ await run();