pi-oracle 0.7.7 → 0.7.9

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.
@@ -30,12 +30,14 @@ import {
30
30
  effortSelectionVisible,
31
31
  snapshotCanSafelySkipModelConfiguration,
32
32
  snapshotHasModelConfigurationUi,
33
+ snapshotHasModelOpener,
33
34
  snapshotHasUsableComposerControls,
34
35
  snapshotStronglyMatchesRequestedModel,
35
36
  snapshotWeaklyMatchesRequestedModel,
36
37
  autoSwitchToThinkingSelectionVisible,
38
+ stripChatGptResponseChrome,
37
39
  } from "./chatgpt-ui-helpers.mjs";
38
- import { assistantSnapshotSlice, nextStableValueState, resolveStableConversationUrlCandidate, stripUrlQueryAndHash } from "./chatgpt-flow-helpers.mjs";
40
+ import { assistantSnapshotSlice, nextStableValueState, providerSendAccepted, resolveStableConversationUrlCandidate, stripUrlQueryAndHash } from "./chatgpt-flow-helpers.mjs";
39
41
  import { assertNotKnownBrowserUserDataPath, scrubSweetCookieSafeStoragePasswordEnv, sweetCookieSafeStoragePasswordScrubbedEnv } from "../shared/browser-profile-helpers.mjs";
40
42
  import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withLock } from "./state-locks.mjs";
41
43
 
@@ -83,11 +85,15 @@ const POST_SEND_SETTLE_MS = 15_000;
83
85
  const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
84
86
  (candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
85
87
  ) || "agent-browser";
88
+ const CHROME_DEVTOOLS_READY_TIMEOUT_MS = 15_000;
86
89
  const CP_BIN = process.env.PI_ORACLE_CP_PATH?.trim() || "cp";
87
90
  scrubSweetCookieSafeStoragePasswordEnv();
88
91
 
92
+ let cpSupportsApfsCloneFlag;
89
93
  let currentJob;
90
94
  let browserStarted = false;
95
+ let browserProcess;
96
+ let browserProcessError;
91
97
  let cleaningUpBrowser = false;
92
98
  let cleaningUpRuntime = false;
93
99
  let shuttingDown = false;
@@ -277,6 +283,14 @@ function spawnCommand(command, args, options = {}) {
277
283
  });
278
284
  }
279
285
 
286
+ async function cpSupportsApfsClone() {
287
+ if (process.platform !== "darwin") return false;
288
+ if (cpSupportsApfsCloneFlag !== undefined) return cpSupportsApfsCloneFlag;
289
+ const probe = await spawnCommand(CP_BIN, ["-c"], { allowFailure: true, timeoutMs: 5_000 });
290
+ cpSupportsApfsCloneFlag = !/invalid option\s+--\s+['"]?c/i.test(`${probe.stderr}\n${probe.stdout}`);
291
+ return cpSupportsApfsCloneFlag;
292
+ }
293
+
280
294
  function parseConversationId(chatUrl) {
281
295
  if (!chatUrl) return undefined;
282
296
  try {
@@ -321,7 +335,7 @@ async function cloneSeedProfileToRuntime(job) {
321
335
  await withLock(ORACLE_STATE_DIR, "auth", "global", { jobId: job.id, processPid: process.pid, action: "cloneSeedProfile" }, async () => {
322
336
  await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
323
337
  await ensurePrivateDir(dirname(job.runtimeProfileDir));
324
- if (job.config.browser.cloneStrategy === "apfs-clone" && process.platform === "darwin") {
338
+ if (job.config.browser.cloneStrategy === "apfs-clone" && await cpSupportsApfsClone()) {
325
339
  try {
326
340
  await spawnCommand(CP_BIN, ["-cR", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
327
341
  } catch (error) {
@@ -344,16 +358,24 @@ async function cleanupRuntime(job) {
344
358
  cleaningUpRuntime = true;
345
359
  const warnings = [];
346
360
  try {
361
+ let browserClosed = true;
347
362
  await closeBrowser(job).catch(async (error) => {
363
+ browserClosed = false;
348
364
  const message = `Browser close warning during cleanup: ${error instanceof Error ? error.message : String(error)}`;
349
365
  warnings.push(message);
350
366
  await log(message).catch(() => undefined);
351
367
  });
352
- try {
353
- assertSafeRuntimeProfilePath(job.runtimeProfileDir, "runtime profile", job.config);
354
- await rm(job.runtimeProfileDir, { recursive: true, force: true });
355
- } catch (error) {
356
- const message = `Runtime profile cleanup warning: ${error instanceof Error ? error.message : String(error)}`;
368
+ if (browserClosed) {
369
+ try {
370
+ assertSafeRuntimeProfilePath(job.runtimeProfileDir, "runtime profile", job.config);
371
+ await rm(job.runtimeProfileDir, { recursive: true, force: true });
372
+ } catch (error) {
373
+ const message = `Runtime profile cleanup warning: ${error instanceof Error ? error.message : String(error)}`;
374
+ warnings.push(message);
375
+ await log(message).catch(() => undefined);
376
+ }
377
+ } else {
378
+ const message = `Runtime profile cleanup skipped because isolated browser close did not complete: ${job.runtimeProfileDir}`;
357
379
  warnings.push(message);
358
380
  await log(message).catch(() => undefined);
359
381
  }
@@ -531,6 +553,39 @@ function browserBaseArgs(job, options = {}) {
531
553
  return args;
532
554
  }
533
555
 
556
+ function waitForChildClose(child, timeoutMs) {
557
+ if (!child || child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
558
+ return new Promise((resolve) => {
559
+ let settled = false;
560
+ const timer = setTimeout(() => {
561
+ if (settled) return;
562
+ settled = true;
563
+ resolve(false);
564
+ }, timeoutMs);
565
+ timer.unref?.();
566
+ child.once("close", () => {
567
+ if (settled) return;
568
+ settled = true;
569
+ clearTimeout(timer);
570
+ resolve(true);
571
+ });
572
+ });
573
+ }
574
+
575
+ async function terminateBrowserProcess() {
576
+ if (!browserProcess) return;
577
+ const child = browserProcess;
578
+ browserProcess = undefined;
579
+ browserProcessError = undefined;
580
+ if (child.exitCode !== null || child.signalCode !== null) return;
581
+ killProcessTree(child);
582
+ if (await waitForChildClose(child, 2_000)) return;
583
+ killProcess(child);
584
+ if (!(await waitForChildClose(child, 2_000))) {
585
+ throw new Error(`Timed out terminating isolated Chrome process ${child.pid ?? "(unknown pid)"}`);
586
+ }
587
+ }
588
+
534
589
  async function closeBrowser(job) {
535
590
  if (cleaningUpBrowser) return;
536
591
  cleaningUpBrowser = true;
@@ -543,15 +598,107 @@ async function closeBrowser(job) {
543
598
  throw new Error(result.stderr || result.stdout || `agent-browser close exited with code ${result.code}`);
544
599
  }
545
600
  } finally {
601
+ await terminateBrowserProcess();
546
602
  browserStarted = false;
547
603
  cleaningUpBrowser = false;
548
604
  }
549
605
  }
550
606
 
607
+ function assertSafeBrowserLaunchArg(arg) {
608
+ const value = String(arg).trim().toLowerCase();
609
+ const managedFlags = [
610
+ "--user-data-dir",
611
+ "--remote-debugging-port",
612
+ "--remote-debugging-pipe",
613
+ "--remote-debugging-address",
614
+ "--remote-allow-origins",
615
+ ];
616
+ const flag = managedFlags.find((candidate) => value === candidate || value.startsWith(`${candidate}=`) || value.startsWith(`${candidate} `));
617
+ if (flag) {
618
+ throw new Error(`browser.args cannot override oracle-managed Chrome launch isolation flag ${flag}`);
619
+ }
620
+ }
621
+
622
+ function safeBrowserLaunchArgs(job) {
623
+ if (!Array.isArray(job.config.browser.args)) return [];
624
+ for (const arg of job.config.browser.args) assertSafeBrowserLaunchArg(arg);
625
+ return job.config.browser.args;
626
+ }
627
+
628
+ function chromeLaunchArgs(job, url) {
629
+ const args = [
630
+ "--remote-debugging-port=0",
631
+ "--remote-allow-origins=*",
632
+ "--no-first-run",
633
+ "--no-default-browser-check",
634
+ "--disable-background-networking",
635
+ "--disable-backgrounding-occluded-windows",
636
+ "--disable-component-update",
637
+ "--disable-default-apps",
638
+ "--disable-hang-monitor",
639
+ "--disable-popup-blocking",
640
+ "--disable-prompt-on-repost",
641
+ "--disable-sync",
642
+ "--disable-features=Translate",
643
+ "--enable-features=NetworkService,NetworkServiceInProcess",
644
+ "--metrics-recording-only",
645
+ "--password-store=basic",
646
+ "--use-mock-keychain",
647
+ "--enable-unsafe-swiftshader",
648
+ "--window-size=1280,720",
649
+ `--user-data-dir=${job.runtimeProfileDir}`,
650
+ ];
651
+ if (job.config.browser.runMode !== "headed") args.push("--headless=new", "--hide-scrollbars");
652
+ if (job.config.browser.userAgent) args.push(`--user-agent=${job.config.browser.userAgent}`);
653
+ args.push(...safeBrowserLaunchArgs(job));
654
+ args.push(url);
655
+ return args;
656
+ }
657
+
658
+ async function waitForDevToolsEndpoint(job) {
659
+ const path = join(job.runtimeProfileDir, "DevToolsActivePort");
660
+ const startedAt = Date.now();
661
+ while (Date.now() - startedAt < CHROME_DEVTOOLS_READY_TIMEOUT_MS) {
662
+ if (browserProcessError) {
663
+ throw new Error(`Chrome failed before DevTools became available: ${browserProcessError instanceof Error ? browserProcessError.message : String(browserProcessError)}`);
664
+ }
665
+ if (browserProcess?.exitCode !== null && browserProcess?.exitCode !== undefined) {
666
+ throw new Error(`Chrome exited before DevTools became available (exit code ${browserProcess.exitCode}).`);
667
+ }
668
+ if (existsSync(path)) {
669
+ const lines = (await readFile(path, "utf8")).trim().split(/\r?\n/);
670
+ const port = lines[0]?.trim();
671
+ const browserPath = lines[1]?.trim();
672
+ if (/^\d+$/.test(port)) {
673
+ return browserPath ? `ws://127.0.0.1:${port}${browserPath}` : `http://127.0.0.1:${port}`;
674
+ }
675
+ }
676
+ await sleep(100);
677
+ }
678
+ throw new Error(`Timed out waiting for Chrome DevTools endpoint at ${path}.`);
679
+ }
680
+
551
681
  async function launchBrowser(job, url) {
552
682
  await closeBrowser(job);
553
- const mode = job.config.browser.runMode;
554
- await spawnCommand(AGENT_BROWSER_BIN, [...browserBaseArgs(job, { withLaunchOptions: true, mode }), "open", url]);
683
+ const executablePath = job.config.browser.executablePath;
684
+ if (!executablePath) throw new Error("Oracle requires browser.executablePath when launching isolated browser runtimes without owning the global agent-browser daemon.");
685
+ const args = chromeLaunchArgs(job, url);
686
+ await log(`Launching isolated Chrome directly for agent-browser attach: ${JSON.stringify([executablePath, ...args])}`);
687
+ browserProcessError = undefined;
688
+ browserProcess = spawn(executablePath, args, {
689
+ env: sweetCookieSafeStoragePasswordScrubbedEnv(),
690
+ stdio: "ignore",
691
+ detached: false,
692
+ shell: false,
693
+ });
694
+ browserProcess.on("error", (error) => {
695
+ browserProcessError = error;
696
+ log(`Chrome process error: ${error instanceof Error ? error.message : String(error)}`).catch(() => undefined);
697
+ });
698
+ const endpoint = await waitForDevToolsEndpoint(job);
699
+ await log(`Connecting agent-browser session ${job.runtimeSessionName} to isolated Chrome DevTools endpoint`);
700
+ await spawnCommand(AGENT_BROWSER_BIN, [...browserBaseArgs(job), "connect", endpoint]);
701
+ await spawnCommand(AGENT_BROWSER_BIN, [...browserBaseArgs(job), "open", url]);
555
702
  browserStarted = true;
556
703
  }
557
704
 
@@ -774,8 +921,8 @@ function snapshotHasCompactIntelligenceMenuControls(snapshot) {
774
921
  return Boolean(findEntry(snapshot, (candidate) => {
775
922
  if (candidate.disabled) return false;
776
923
  const label = normalizeSnapshotLabel(candidate.label);
777
- return (candidate.kind === "menu" && /Intelligence.*Instant.*Medium.*High.*Pro/i.test(label))
778
- || (candidate.kind === "menuitemradio" && /^(?:Instant\s+5s|Medium\s+5\s*[–-]\s*30s|High\s+15\s*[–-]\s*60s|Pro\s+5\+\s*min)$/i.test(label));
924
+ return (candidate.kind === "menu" && /(?:Intelligence.*Instant.*Medium.*High.*Pro|^(?:Instant|Medium|High|Extra High|Pro Extended)$)/i.test(label))
925
+ || (candidate.kind === "menuitemradio" && /^(?:Instant(?:\s+5s)?|Medium(?:\s+5\s*[–-]\s*30s)?|High(?:\s+15\s*[–-]\s*60s)?|Extra High|Pro\s+5\+\s*min|Pro Standard|Pro Extended)$/i.test(label));
779
926
  }));
780
927
  }
781
928
 
@@ -783,9 +930,10 @@ function matchesRequestedModelControl(candidate, selection, options = {}) {
783
930
  if (!["button", "radio", "menuitemradio"].includes(candidate.kind || "") || typeof candidate.label !== "string" || candidate.disabled) return false;
784
931
  if (candidate.kind === "button") {
785
932
  if (/\bexpanded=true\b/.test(String(candidate.line || ""))) return false;
786
- if (options.ignoreCompactTierButtons && /^(?:Instant|Medium|High|Pro)$/i.test(candidate.label)) return false;
787
- if (options.ignoreCompactOnlyButtons && /^(?:Medium|High)$/i.test(candidate.label)) return false;
933
+ if (options.ignoreCompactTierButtons && /^(?:Instant|Medium|High|Extra High|Pro|Pro Extended)$/i.test(candidate.label)) return false;
934
+ if (options.ignoreCompactOnlyButtons && /^(?:Medium|High|Extra High)$/i.test(candidate.label)) return false;
788
935
  }
936
+ if (selection.modelFamily === "pro" && /^Pro(?:\s+Extended)?$/i.test(candidate.label)) return true;
789
937
  return matchesRequestedModelControlLabel(candidate.label, selection);
790
938
  }
791
939
 
@@ -859,7 +1007,41 @@ async function maybeClickLabeledEntry(job, label, options = {}) {
859
1007
  }
860
1008
 
861
1009
  async function openEffortDropdown(job) {
862
- const snapshot = await snapshotText(job);
1010
+ let snapshot = await snapshotText(job);
1011
+ if (job.selection?.modelFamily === "pro") {
1012
+ let proEffortEntry = findEntry(
1013
+ snapshot,
1014
+ (candidate) => candidate.kind === "menuitem" && candidate.label === "Pro effort options" && !candidate.disabled,
1015
+ );
1016
+ if (!proEffortEntry) {
1017
+ const opener = findEntry(snapshot, matchesModelConfigurationOpener);
1018
+ if (opener) {
1019
+ await clickRef(job, opener.ref);
1020
+ await agentBrowser(job, "wait", "500");
1021
+ snapshot = await snapshotText(job);
1022
+ proEffortEntry = findEntry(
1023
+ snapshot,
1024
+ (candidate) => candidate.kind === "menuitem" && candidate.label === "Pro effort options" && !candidate.disabled,
1025
+ );
1026
+ }
1027
+ }
1028
+ if (proEffortEntry) {
1029
+ try {
1030
+ await clickRef(job, proEffortEntry.ref);
1031
+ return true;
1032
+ } catch {
1033
+ // Fall through to DOM click. ChatGPT's tiny trailing Pro effort icon can
1034
+ // be covered at the accessibility click point by the parent Pro row.
1035
+ }
1036
+ }
1037
+ const clicked = await evalPage(job, toJsonScript(`
1038
+ const el = document.querySelector('[aria-label="Pro effort options"], [data-composer-intelligence-pro-effort-action]');
1039
+ if (!el) return false;
1040
+ el.click();
1041
+ return true;
1042
+ `));
1043
+ if (clicked) return true;
1044
+ }
863
1045
  const effortLabels = new Set(["Light", "Standard", "Extended", "Heavy"]);
864
1046
  const entry = findEntry(
865
1047
  snapshot,
@@ -1157,46 +1339,128 @@ async function waitForSendReady(job) {
1157
1339
  throw new Error(`Timed out waiting for ${labelsForJob(job).send} to become enabled`);
1158
1340
  }
1159
1341
 
1160
- async function clickSend(job) {
1161
- const entry = await waitForSendReady(job);
1162
- if (isGrokJob(job)) {
1163
- const result = await evalPage(job, toJsonScript(`
1164
- const button = document.querySelector('button[aria-label="Submit"]');
1165
- if (!button || button.disabled) return { ok: false };
1166
- button.click();
1167
- return { ok: true };
1168
- `));
1169
- if (result?.ok) return;
1342
+ async function activateSendButton(job) {
1343
+ const result = await evalPage(job, toJsonScript(`
1344
+ const labels = ${JSON.stringify(labelsForJob(job))};
1345
+ const buttons = Array.from(document.querySelectorAll('button'));
1346
+ const button = buttons.find((candidate) => {
1347
+ const label = (candidate.getAttribute('aria-label') || candidate.textContent || '').trim();
1348
+ return label === labels.send;
1349
+ });
1350
+ if (!button) return { ok: false, reason: 'send button not found' };
1351
+ if (button.disabled || button.getAttribute('aria-disabled') === 'true') return { ok: false, reason: 'send button disabled' };
1352
+ button.click();
1353
+ return { ok: true };
1354
+ `));
1355
+ return result;
1356
+ }
1357
+
1358
+ async function sendAcceptanceState(job, baselineAssistantCount) {
1359
+ const [urlResult, snapshot, messages] = await Promise.all([
1360
+ currentUrl(job).then((url) => ({ url, ok: true })).catch(() => ({ url: "", ok: false })),
1361
+ snapshotText(job).catch(() => ""),
1362
+ assistantMessages(job).catch(() => []),
1363
+ ]);
1364
+ return {
1365
+ url: urlResult.url,
1366
+ urlKnown: urlResult.ok,
1367
+ assistantCount: Math.max(baselineAssistantCount, messages.length),
1368
+ stopStreaming: isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming"),
1369
+ };
1370
+ }
1371
+
1372
+ async function clickSend(job, baselineAssistantCount) {
1373
+ await waitForSendReady(job);
1374
+ const beforeSend = await sendAcceptanceState(job, baselineAssistantCount);
1375
+ const activation = await activateSendButton(job);
1376
+ if (!activation?.ok) throw new Error(`Could not activate ${labelsForJob(job).send}: ${activation?.reason || "DOM activation failed"}`);
1377
+ await log(`Activated ${labelsForJob(job).send}; waiting for provider acceptance evidence`);
1378
+ if (await waitForSendAccepted(job, beforeSend, { timeoutMs: 20_000 })) return;
1379
+
1380
+ await captureDiagnostics(job, "send-not-accepted");
1381
+ throw new Error(`${isGrokJob(job) ? "Grok" : "ChatGPT"} message did not leave the composer after activating ${labelsForJob(job).send}`);
1382
+ }
1383
+
1384
+ async function waitForSendAccepted(job, beforeSend, options = {}) {
1385
+ const timeoutAt = Date.now() + (options.timeoutMs || 15_000);
1386
+ while (Date.now() < timeoutAt) {
1387
+ await heartbeat();
1388
+ const afterSend = await sendAcceptanceState(job, beforeSend.assistantCount || 0);
1389
+ if (providerSendAccepted(beforeSend, afterSend)) return true;
1390
+ await sleep(500);
1170
1391
  }
1171
- await clickRef(job, entry.ref);
1392
+ return false;
1393
+ }
1394
+
1395
+ async function dismissProFeedbackModal(job, snapshot) {
1396
+ const entries = parseSnapshotEntries(snapshot);
1397
+ const hasProFeedback = entries.some((entry) => entry.kind === "heading" && entry.label === "Pro feedback" && !entry.disabled);
1398
+ if (!hasProFeedback) return false;
1399
+ const close = entries.find((entry) => entry.kind === "button" && entry.label === CHATGPT_LABELS.close && !entry.disabled);
1400
+ if (close) {
1401
+ await clickRef(job, close.ref).catch(() => undefined);
1402
+ await agentBrowser(job, "wait", "500");
1403
+ if (!(await pageText(job).catch(() => "")).includes("Pro feedback")) return true;
1404
+ }
1405
+ await agentBrowser(job, "press", "Escape").catch(() => undefined);
1406
+ await agentBrowser(job, "wait", "500");
1407
+ if (!(await pageText(job).catch(() => "")).includes("Pro feedback")) return true;
1408
+
1409
+ const dismissed = await evalPage(job, toJsonScript(`
1410
+ const dialogText = document.body.innerText || '';
1411
+ if (!/Pro feedback/.test(dialogText)) return false;
1412
+ const button = Array.from(document.querySelectorAll('button'))
1413
+ .find((candidate) => (candidate.getAttribute('aria-label') || candidate.textContent || '').trim() === 'Close');
1414
+ if (!button) return false;
1415
+ button.click();
1416
+ return true;
1417
+ `));
1418
+ if (dismissed) await agentBrowser(job, "wait", "500");
1419
+ return Boolean(dismissed);
1172
1420
  }
1173
1421
 
1174
1422
  async function openModelConfiguration(job) {
1175
- const initialSnapshot = await snapshotText(job);
1176
- if (snapshotHasModelConfigurationUi(initialSnapshot)) return initialSnapshot;
1423
+ const timeoutAt = Date.now() + 15_000;
1424
+ let lastSnapshot = "";
1177
1425
 
1178
- for (const predicate of [matchesModelConfigurationOpener]) {
1179
- const snapshot = await snapshotText(job);
1180
- const entry = findEntry(snapshot, predicate);
1181
- if (!entry) continue;
1182
- await clickRef(job, entry.ref);
1183
- await agentBrowser(job, "wait", "800");
1184
- const after = await snapshotText(job);
1185
- if (snapshotHasModelConfigurationUi(after)) return after;
1186
- if (canUseOpenModelMenuForSelection(after, job.selection)) return after;
1426
+ while (Date.now() < timeoutAt) {
1427
+ const initialSnapshot = await snapshotText(job);
1428
+ lastSnapshot = initialSnapshot;
1429
+ if (snapshotHasModelConfigurationUi(initialSnapshot)) return initialSnapshot;
1430
+ if (await dismissProFeedbackModal(job, initialSnapshot)) continue;
1431
+
1432
+ for (const predicate of [matchesModelConfigurationOpener]) {
1433
+ const snapshot = await snapshotText(job);
1434
+ lastSnapshot = snapshot;
1435
+ const entry = findEntry(snapshot, predicate);
1436
+ if (!entry) continue;
1437
+ await clickRef(job, entry.ref);
1438
+ await agentBrowser(job, "wait", "800");
1439
+ const after = await snapshotText(job);
1440
+ lastSnapshot = after;
1441
+ if (snapshotHasModelConfigurationUi(after)) return after;
1442
+ if (canUseOpenModelMenuForSelection(after, job.selection)) return after;
1443
+
1444
+ const configureEntry = findEntry(
1445
+ after,
1446
+ (candidate) => candidate.kind === "menuitem" && candidate.label === CHATGPT_LABELS.configure && !candidate.disabled,
1447
+ );
1187
1448
 
1188
- const configureEntry = findEntry(
1189
- after,
1190
- (candidate) => candidate.kind === "menuitem" && candidate.label === CHATGPT_LABELS.configure && !candidate.disabled,
1191
- );
1449
+ if (configureEntry) {
1450
+ await clickRef(job, configureEntry.ref);
1451
+ await agentBrowser(job, "wait", "1200");
1452
+ const postConfigure = await snapshotText(job);
1453
+ lastSnapshot = postConfigure;
1454
+ if (snapshotHasModelConfigurationUi(postConfigure)) return postConfigure;
1455
+ if (canUseOpenModelMenuForSelection(postConfigure, job.selection)) return postConfigure;
1456
+ }
1457
+ }
1192
1458
 
1193
- if (configureEntry) {
1194
- await clickRef(job, configureEntry.ref);
1195
- await agentBrowser(job, "wait", "1200");
1196
- const postConfigure = await snapshotText(job);
1197
- if (snapshotHasModelConfigurationUi(postConfigure)) return postConfigure;
1198
- if (canUseOpenModelMenuForSelection(postConfigure, job.selection)) return postConfigure;
1459
+ if (composerControlsVisible(lastSnapshot, job) && !snapshotHasModelOpener(lastSnapshot)) {
1460
+ await agentBrowser(job, "wait", "1000");
1461
+ continue;
1199
1462
  }
1463
+ await agentBrowser(job, "wait", "500");
1200
1464
  }
1201
1465
 
1202
1466
  throw new Error("Could not open model configuration UI");
@@ -1303,7 +1567,11 @@ async function configureModel(job) {
1303
1567
  throw new Error(`Could not open effort dropdown for requested effort: ${effortLabel}`);
1304
1568
  }
1305
1569
  await agentBrowser(job, "wait", "300");
1306
- await clickLabeledEntry(job, effortLabel, { kind: "option" });
1570
+ if (job.selection.modelFamily === "pro" && await maybeClickLabeledEntry(job, `Pro ${effortLabel}`, { kind: "menuitemradio" })) {
1571
+ // Current ChatGPT exposes Pro effort choices as nested menu radio items.
1572
+ } else {
1573
+ await clickLabeledEntry(job, effortLabel, { kind: "option" });
1574
+ }
1307
1575
  await agentBrowser(job, "wait", "400");
1308
1576
  const effortSnapshot = await snapshotText(job);
1309
1577
  verificationSnapshot = effortSnapshot;
@@ -2000,15 +2268,8 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
2000
2268
  }
2001
2269
  }
2002
2270
 
2003
- const capturedArtifactLabels = new Set(artifacts.map((artifact) => artifact.displayName).filter(Boolean));
2004
- const capturedArtifactKeys = new Set([...capturedArtifactLabels].map((label) => String(label).replace(/\s+/g, "")));
2005
- const missedArtifactLabels = suspiciousLabels.filter((label) => !capturedArtifactLabels.has(label) && !capturedArtifactKeys.has(String(label).replace(/\s+/g, "")));
2006
- if (missedArtifactLabels.length > 0) {
2007
- await log(`Marking missed artifact signals as unconfirmed: ${missedArtifactLabels.join(", ")}`);
2008
- for (const label of missedArtifactLabels) {
2009
- artifacts.push({ displayName: label, unconfirmed: true, error: "Response-local artifact signal was present, but no downloadable artifact was captured." });
2010
- }
2011
- await flushArtifactsState(artifacts);
2271
+ if (suspiciousLabels.length > 0) {
2272
+ await log(`Ignoring plain-text artifact-like labels without downloadable controls: ${suspiciousLabels.join(", ")}`);
2012
2273
  }
2013
2274
 
2014
2275
  return artifacts;
@@ -2079,8 +2340,8 @@ async function run() {
2079
2340
  await setComposerText(currentJob, await readFile(currentJob.promptPath, "utf8"));
2080
2341
  const baselineAssistantCount = (await assistantMessages(currentJob)).length;
2081
2342
  await log(`Assistant response count before send: ${baselineAssistantCount}`);
2082
- await clickSend(currentJob);
2083
- await log(`Waiting ${POST_SEND_SETTLE_MS}ms after send to avoid streaming interruption`);
2343
+ await clickSend(currentJob, baselineAssistantCount);
2344
+ await log(`Send accepted; waiting ${POST_SEND_SETTLE_MS}ms after send to avoid streaming interruption`);
2084
2345
  await sleep(POST_SEND_SETTLE_MS);
2085
2346
 
2086
2347
  const observedChatUrl = isGrokJob(currentJob)
@@ -2118,14 +2379,15 @@ async function run() {
2118
2379
  message: "Extracting the completed response body.",
2119
2380
  patch: { heartbeatAt: new Date().toISOString() },
2120
2381
  }));
2121
- await secureWriteText(currentJob.responsePath, `${completion.responseText.trim()}\n`);
2382
+ const responseText = isGrokJob(currentJob) ? completion.responseText.trim() : stripChatGptResponseChrome(completion.responseText);
2383
+ await secureWriteText(currentJob.responsePath, `${responseText}\n`);
2122
2384
  currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "downloading_artifacts", {
2123
2385
  at: new Date().toISOString(),
2124
2386
  source: "oracle:worker",
2125
2387
  message: "Downloading any response artifacts.",
2126
2388
  patch: { heartbeatAt: new Date().toISOString() },
2127
2389
  }));
2128
- const artifacts = await downloadArtifacts(currentJob, completion.responseIndex, completion.responseText);
2390
+ const artifacts = await downloadArtifacts(currentJob, completion.responseIndex, responseText);
2129
2391
  const artifactFailureCount = artifacts.filter((artifact) => artifact.unconfirmed || artifact.error).length;
2130
2392
  const finalPhase = artifactFailureCount > 0 ? "complete_with_artifact_errors" : "complete";
2131
2393
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "description": "ChatGPT and Grok web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -41,9 +41,6 @@
41
41
  "pi": {
42
42
  "extensions": [
43
43
  "./extensions/oracle/index.ts"
44
- ],
45
- "prompts": [
46
- "./prompts"
47
44
  ]
48
45
  },
49
46
  "scripts": {
@@ -83,8 +80,8 @@
83
80
  "protobufjs": "7.6.1"
84
81
  },
85
82
  "devDependencies": {
86
- "@earendil-works/pi-ai": "^0.79.0",
87
- "@earendil-works/pi-coding-agent": "^0.79.0",
83
+ "@earendil-works/pi-ai": "0.79.1",
84
+ "@earendil-works/pi-coding-agent": "0.79.1",
88
85
  "@types/node": "^22.19.19",
89
86
  "esbuild": "^0.28.0",
90
87
  "tsx": "^4.22.3",
@@ -10,7 +10,7 @@ Required workflow:
10
10
  2. If the request does not include both a prior oracle job id and a follow-up request, stop and report: `Usage: /oracle-followup <job-id> <request>. Find the job id in the earlier oracle response or via /oracle-status.`
11
11
  3. Same-thread follow-ups cannot switch providers. If the user asks to move a ChatGPT thread to Grok or a Grok thread to ChatGPT, stop and tell them to start a new `/oracle` job instead.
12
12
  4. Call `oracle_preflight` immediately with the parsed `followUpJobId` so readiness checks use the prior job's provider.
13
- 5. If `oracle_preflight` reports `ready: false`, stop before any expensive prep. Do not read files, search the codebase, or prepare archive inputs first. If the blocker is an auth seed or stale-auth issue that `oracle_auth` can repair, call `oracle_auth` once for the provider reported by preflight, rerun `oracle_preflight` with the same `followUpJobId`, and continue only if it becomes `ready: true`. Otherwise stop immediately and report the blocking issue plus the suggested next step.
13
+ 5. If `oracle_preflight` reports `ready: false`, stop before any expensive prep. Do not read files, search the codebase, prepare archive inputs, or call `oracle_auth` automatically. Report the blocking issue plus the suggested next step.
14
14
  6. Treat the parsed job id as `followUpJobId` for `oracle_submit`.
15
15
  7. Understand whether the follow-up request is explicitly narrow or genuinely broad.
16
16
  8. Gather enough repo context to choose archive inputs and write a strong oracle prompt. Bias toward context-rich submissions when they fit within the provider archive ceiling: 250 MB for ChatGPT, 200 MiB for Grok.
@@ -31,7 +31,7 @@ Oracle provider/model (`oracle_submit`):
31
31
 
32
32
  Rules:
33
33
  - Use `oracle_preflight` before any expensive `/oracle-followup` preparation so missing persisted-session or local auth/config blockers fail fast.
34
- - If the immediately preceding oracle run for this follow-up failed because ChatGPT/Grok login is required, the worker said to rerun `/oracle-auth`, or stale auth clearly blocked execution, call `oracle_auth` once and then retry the follow-up submission once. For Grok, pass `provider: "grok"` to `oracle_auth`. Do not loop auth refreshes.
34
+ - If the immediately preceding oracle run for this follow-up failed because ChatGPT/Grok login is required, the worker said to rerun `/oracle-auth`, or stale auth clearly blocked execution, stop and tell the user to run `/oracle-auth` (or `/oracle-auth grok` for Grok). Do not call `oracle_auth` automatically from `/oracle-followup`.
35
35
  - This prompt exists so normal users can continue the same provider thread without manually constructing `followUpJobId` tool calls.
36
36
  - Always include an archive. Do not submit without context files.
37
37
  - By default, prefer context-rich archives up to the provider ceiling because more relevant context is usually better than less. The ceiling is 250 MB for ChatGPT and 200 MiB for Grok. For broad or unclear follow-up requests, include the whole repository by passing `.`. Default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like `.env` files, key material, credential dotfiles, local database files, and nested `secrets/` directories anywhere in the repo.
package/prompts/oracle.md CHANGED
@@ -5,20 +5,28 @@ You are preparing an /oracle job.
5
5
 
6
6
  Do not answer the user's request directly yet.
7
7
 
8
+ Hard requirements:
9
+ - Do not plan instead of submitting. The point of `/oracle` is dispatch.
10
+ - Do not claim preflight, auth, archive prep, or submission happened unless the matching tool call actually happened in this turn.
11
+ - If the user explicitly says ChatGPT Instant or Instant, use provider `chatgpt` and preset `instant`; never switch that request to Grok.
12
+ - If a required tool call is unavailable or fails, stop and report that exact blocker instead of fabricating progress.
13
+ - After a successful or queued `oracle_submit`, your final answer must be only a terse dispatch summary with the job id and response path. Do not ask questions, offer to watch/poll/read, list next steps, or continue working.
14
+
8
15
  Required workflow:
9
- 1. Call `oracle_preflight` immediately. If the user says to use Grok, pass `provider: "grok"` to `oracle_preflight`.
10
- 2. If `oracle_preflight` reports `ready: false`, stop before any expensive prep. Do not read files, search the codebase, or prepare archive inputs first. If the blocker is an auth seed or stale-auth issue that `oracle_auth` can repair, call `oracle_auth` once with the same provider, rerun `oracle_preflight` with the same provider, and continue only if it becomes `ready: true`. Otherwise stop immediately and report the blocking issue plus the suggested next step.
16
+ 1. Call `oracle_preflight` immediately. If the user says to use Grok, pass `provider: "grok"` to `oracle_preflight`. If the user says to use ChatGPT, pass `provider: "chatgpt"`. If the user says to use ChatGPT Instant, pass `provider: "chatgpt"` and later call `oracle_submit` with `preset: "instant"`.
17
+ 2. If `oracle_preflight` reports `ready: false`, stop before any expensive prep. Do not read files, search the codebase, prepare archive inputs, or call `oracle_auth` automatically. Report the blocking issue plus the suggested next step.
11
18
  3. Understand the request and decide whether it is explicitly narrow or genuinely broad.
12
19
  4. Gather enough repo context to choose archive inputs and write a strong oracle prompt. Bias toward context-rich submissions when they fit within the provider archive ceiling: 250 MB for ChatGPT, 200 MiB for Grok.
13
20
  5. If the user scope is explicit and narrow, start from the directly relevant area but still include nearby files, tests, docs, configs, and adjacent modules when they may improve answer quality. Keep the archive tightly minimal only when the user explicitly asks for that, privacy/sensitivity requires it, or size pressure forces it.
14
21
  6. If the request is broad, architectural, release-oriented, or otherwise repo-wide, gather broader context and usually archive `.`.
15
22
  7. Choose archive inputs for the oracle job.
16
23
  8. Craft a concise but complete oracle prompt for the selected web provider.
17
- 9. Call `oracle_submit` with the prompt and exact archive inputs.
18
- 10. Stop immediately after dispatching the oracle job.
24
+ 9. Call `oracle_submit` with the prompt and exact archive inputs. Do not ask for confirmation before this submit step unless `oracle_preflight` or `oracle_submit` returns a blocker that requires user action.
25
+ 10. Stop immediately after dispatching the oracle job. “Stop” means no follow-up questions, no offers to poll/watch/read, and no extra next-step list.
19
26
 
20
27
  Oracle provider/model (`oracle_submit`):
21
28
  - If the user says to use Grok (for example “Use the oracle to Grok about ...”), pass **`provider: "grok"`**. Grok currently supports only **`mode: "heavy"`**; omit `mode` unless the user explicitly says Heavy.
29
+ - If the user says ChatGPT, pass **`provider: "chatgpt"`**. Never route a ChatGPT request to Grok.
22
30
  - Otherwise omit **`provider`** to use the configured default provider, or pass **`provider: "chatgpt"`** only when needed for clarity.
23
31
  - To choose a specific ChatGPT model, pass **`preset`** with one of the allowed ids from the canonical preset registry.
24
32
  - Matching human-readable preset labels and common hyphen/space variants are also accepted and normalized automatically, but prefer canonical ids when readily available.
@@ -28,7 +36,7 @@ Oracle provider/model (`oracle_submit`):
28
36
 
29
37
  Rules:
30
38
  - Use `oracle_preflight` before any expensive `/oracle` preparation so missing persisted-session or local auth/config blockers fail fast.
31
- - If the immediately preceding oracle run for this request failed because ChatGPT/Grok login is required, the worker said to rerun `/oracle-auth`, or stale auth clearly blocked execution, call `oracle_auth` once and then retry the oracle submission once. For Grok, pass `provider: "grok"` to `oracle_auth`. Do not loop auth refreshes.
39
+ - If ChatGPT/Grok login is required, the worker said to rerun `/oracle-auth`, or stale auth clearly blocked execution, stop and tell the user to run `/oracle-auth` (or `/oracle-auth grok` for Grok). Do not call `oracle_auth` automatically from `/oracle`.
32
40
  - Always include an archive. Do not submit without context files.
33
41
  - By default, prefer context-rich archives up to the provider ceiling because more relevant context is usually better than less. The ceiling is 250 MB for ChatGPT and 200 MiB for Grok. For broad or unclear requests, include the whole repository by passing `.`. Default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like `.env` files, key material, credential dotfiles, local database files, and nested `secrets/` directories anywhere in the repo.
34
42
  - Only limit file selection if the user explicitly requests a tight archive, if privacy/sensitivity requires it, or if the archive would otherwise exceed the size limit after exclusions/pruning.
@@ -397,7 +397,9 @@ async function run(mode = "packed") {
397
397
  const agent1 = join(runDir, "agent1");
398
398
  const sessions1 = join(runDir, "sessions1");
399
399
  const jobs1 = join(runDir, "jobs1");
400
- mkdirSync(join(agent1, "extensions", "oracle-auth-seed-profile"), { recursive: true });
400
+ const authSeed1 = join(agent1, "extensions", "oracle-auth-seed-profile");
401
+ mkdirSync(authSeed1, { recursive: true });
402
+ writeFileSync(join(authSeed1, ".oracle-seed-generation"), "real-smoke-fake-worker\n");
401
403
  mkdirSync(sessions1, { recursive: true });
402
404
  mkdirSync(jobs1, { recursive: true });
403
405
 
@@ -444,7 +446,9 @@ async function run(mode = "packed") {
444
446
  const sessions2 = join(runDir, "sessions2");
445
447
  const jobs2 = join(runDir, "jobs2");
446
448
  const outside = join(tmpRoot, "outside");
447
- mkdirSync(join(agent2, "extensions", "oracle-auth-seed-profile"), { recursive: true });
449
+ const authSeed2 = join(agent2, "extensions", "oracle-auth-seed-profile");
450
+ mkdirSync(authSeed2, { recursive: true });
451
+ writeFileSync(join(authSeed2, ".oracle-seed-generation"), "real-smoke-fake-worker\n");
448
452
  mkdirSync(sessions2, { recursive: true });
449
453
  mkdirSync(jobs2, { recursive: true });
450
454
  mkdirSync(outside, { recursive: true });