pi-oracle 0.7.19 → 0.7.21

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.21 - 2026-09-26
4
+
5
+ ### Fixed
6
+ - recognize ChatGPT's current `Ask ChatGPT` input as well as `Chat with ChatGPT`, so authenticated browser verification finishes and closes the browser; share the aliases with prompt entry and response/upload boundaries
7
+ - retry auth observations interrupted by CDP navigation without closing the browser while concurrent reads are still running
8
+ - stop treating anonymous `ua-*` visitor IDs as signed-in account identities that need account-selection clicks or reloads
9
+ - distinguish browser verification failures from rejected source cookies in auth recovery guidance
10
+ - support ChatGPT's current Power slider and version-prefixed Pro button, verifying the selected tier before submitting
11
+ - restore the worker jobs-directory binding so cleanup can promote queued jobs
12
+
13
+ ### Validation
14
+ - reproduced the renamed-composer failure before the fix and verified a live `/oracle-auth` refresh saved the auth seed and closed Chrome automatically
15
+ - passed the local Oracle verification suite and independent correctness review
16
+
17
+ ## 0.7.20 - 2026-07-28
18
+
19
+ ### Fixed
20
+ - accepted undifferentiated ChatGPT Pro compact menus (bare Pro / Instant 5.5) so pro presets can configure without Standard/Extended rows
21
+ - replaced `\S` tool-schema non-blank patterns with a GBNF-safe character class so llama.cpp-backed OpenAI-compatible servers can compile oracle tool schemas
22
+
23
+ ### Validation
24
+ - ran the full `npm run release:check` local gate before release
25
+
3
26
  ## 0.7.19 - 2026-07-16
4
27
 
5
28
  ### Changed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `pi-oracle` lets a `pi` agent send hard, long-running work to ChatGPT.com or Grok through the web app, with repo archives, background execution, saved results, and a best-effort wake-up back into `pi` when the answer is ready.
4
4
 
5
- > Status: experimental public beta. Current local validation baseline is pi `0.80.9`; the platform-smoke harness covers macOS, Linux, and Windows native with Chromium-family browsers. Pi `0.80.9+` is the suggested tested floor for project-trust-aware package/runtime validation, but pi-bundled runtime packages remain optional wildcard peers so npm peer ranges do not block users from trying newer pi releases. Normal oracle jobs run in an isolated browser profile, not your active browser window.
5
+ > Status: experimental public beta. Current source qualification baseline is official pi `0.86.1`; the platform-smoke harness covers macOS, Linux, and Windows native with Chromium-family browsers. Pi `0.80.9+` is the suggested tested floor for project-trust-aware package/runtime validation, but pi-bundled runtime packages remain optional wildcard peers so npm peer ranges do not block users from trying newer pi releases. Normal oracle jobs run in an isolated browser profile, not your active browser window.
6
6
 
7
7
  ## What a successful run looks like
8
8
 
@@ -284,6 +284,8 @@ Grok uploads now use `.tar.gz` archives. Grok may accept `.tar.zst` uploads, but
284
284
 
285
285
  For ChatGPT, `oracle_submit` accepts canonical preset ids or a matching human-readable preset label. Keep config values on canonical ids. For Grok, use `provider: "grok"`; only Heavy is supported today.
286
286
 
287
+ ChatGPT's compact picker and current **Power** slider expose fewer choices than the legacy settings dialog: both Instant presets use Instant when no auto-switch toggle exists; Thinking Light/Standard use Medium, Extended uses High, and Heavy uses Extra High. Both Pro presets use the single Pro tier when separate effort choices are unavailable. Oracle verifies the visible selection rather than assuming the saved preference.
288
+
287
289
  ## Outputs and cleanup
288
290
 
289
291
  - Jobs persist response text, metadata, logs, and artifacts under `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/` by default.
@@ -319,7 +321,9 @@ Review the code and design docs before using it with private or regulated materi
319
321
  - For custom Chromium cookie sources, confirm `auth.chromeCookiePath` points at that profile's `Cookies` DB. On macOS, also confirm `auth.chromiumKeychain.services` names the browser's safe-storage Keychain service. On Linux, leave `auth.chromiumKeychain` unset and use Sweet Cookie's `SWEET_COOKIE_LINUX_KEYRING`, `SWEET_COOKIE_CHROME_SAFE_STORAGE_PASSWORD`, or `SWEET_COOKIE_BRAVE_SAFE_STORAGE_PASSWORD` options for encrypted Chrome/Chromium/Brave cookies.
320
322
  - Re-run `/oracle-auth`.
321
323
  - Agent callers can use `oracle_auth({})` once before retrying a stale-auth oracle submission.
322
- - If the provider is half-logged-in or challenge flow state looks weird, finish the login/challenge in the headed auth browser and retry.
324
+ - If ChatGPT reports an anonymous visitor session, sign in in the configured **source browser profile**, quit that browser fully, and rerun `/oracle-auth`. This command imports an existing login; the isolated verification window is not a manual-login setup flow.
325
+ - Navigation interruptions during verification are retried until the auth deadline. A closed browser or verification timeout does not by itself mean the source cookies are invalid; leave the isolated window open and inspect the printed diagnostics if retries fail.
326
+ - If the provider presents a challenge and Oracle explicitly leaves the headed auth browser open, complete the challenge there and retry.
323
327
 
324
328
  ### Custom Chromium auth says cookies synced but the session is rejected
325
329
 
@@ -371,6 +375,8 @@ Inspect the job directory under `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/`.
371
375
 
372
376
  ## Verification
373
377
 
378
+ For credential-free host qualification, run `npm ci --ignore-scripts` then `npm run check:compat` with an empty HOME/agent profile. It runs the existing syntax, type, isolated sanity, and pack checks plus native Pi `/oracle-status` dispatch, asserting that no browser job is created. Sanity uses fixture browser/keychain commands and private job/state directories; it needs local archive utilities (`tar` and `zstd`), not browser credentials. This is source/host proof, not authenticated ChatGPT/Grok or packed cross-platform release proof. The older suggested floor is historical support guidance, not requalified by a current-host run.
379
+
374
380
  Useful local checks:
375
381
 
376
382
  ```bash
@@ -384,6 +390,8 @@ npm test
384
390
  npm run verify:oracle
385
391
  ```
386
392
 
393
+ `npm test` runs `npm run verify:oracle`, so it is not a separate gate from the final line above.
394
+
387
395
  `npm publish` is guarded by `prepublishOnly`, which runs `npm run release:check`. That release gate now blocks unless fresh live ChatGPT preset proof exists for every canonical preset, then requires doctor-first macOS, Ubuntu, and Windows native Crabbox evidence. The required Crabbox runtime suite uses packed-install proof, not source-tree `pi -e` loading.
388
396
 
389
397
  Use the narrowest validation workflow that proves the change:
@@ -143,6 +143,9 @@ Auth bootstrap flow:
143
143
  7. clear isolated browser cookies and seed the staged profile with imported provider cookies
144
144
  8. open the configured provider in the isolated browser
145
145
  9. verify auth with provider-specific readiness checks
146
+ - recognize both `Chat with ChatGPT` and `Ask ChatGPT` composer labels via the shared UI helpers; the same aliases apply to prompt entry and response/upload boundaries
147
+ - drain concurrent page reads and discard the whole observation if a CDP evaluation was interrupted by navigation; retry within the existing bootstrap deadline without relaunching a closed browser
148
+ - ChatGPT's anonymous `ua-*` visitor IDs are not account identity evidence; a login page/public login controls with an anonymous probe require refreshing the source browser login, not account-selection clicks or reloads
146
149
  10. on success, close the isolated browser so Chrome flushes profile state cleanly
147
150
  11. atomically swap the staged profile into `browser.authSeedProfileDir`, keeping `*.prev` as rollback
148
151
  12. write a seed-generation marker used by future runtime clones
@@ -195,7 +198,7 @@ Per job:
195
198
  - login required
196
199
  - challenge/verification page
197
200
  - transient outage after one retry
198
- 6. configure ChatGPT model family/effort or Grok Heavy
201
+ 6. configure ChatGPT model family/effort or Grok Heavy; for the current Power slider, use its keyboard controls and verify both the numeric position and accessible tier announcement before closing it. Preserve the compact-menu mapping (Instant; Medium for Light/Standard; High for Extended; Extra High for Heavy; undifferentiated Pro). Reject unknown ranges or mismatched announcements instead of guessing.
199
202
  7. upload archive
200
203
  8. wait for upload confirmation scoped to the active composer
201
204
  9. fill prompt
@@ -33,7 +33,7 @@ The local extension now intercepts TUI `/oracle` and `/oracle-followup` before p
33
33
 
34
34
  Do not add `https://github.com/fitchmultz/pi-oracle` to this repository's `.pi/settings.json` just to test local oracle changes. If you already keep `npm:pi-oracle` installed globally, mixing the global npm package with a project-local git package creates two distinct package identities and can trigger prompt/tool conflicts. Use the explicit CLI extension flag above instead.
35
35
 
36
- `oracle_submit` now preflights missing, unreadable, or unverified auth seed profiles before it creates an archive or persists a job. For archive-inspection smoke tests that intentionally run without real auth, use `oracle_preflight` for the blocker path or create a test seed only in a purpose-built fixture that includes the `.oracle-seed-generation` marker.
36
+ `oracle_submit` now preflights missing, unreadable, or unverified auth seed profiles before it creates an archive or persists a job. For archive-inspection smoke tests that intentionally run without real auth, use `oracle_preflight` for the blocker path or create a test seed only in a purpose-built fixture with a non-empty `.oracle-seed-generation` value.
37
37
 
38
38
  ## Preset requirements
39
39
 
@@ -98,9 +98,11 @@ mkdir -p \
98
98
  mkdir -p \
99
99
  "$TEST1_AGENT/extensions/oracle-auth-seed-profile" \
100
100
  "$TEST2_AGENT/extensions/oracle-auth-seed-profile"
101
- touch \
102
- "$TEST1_AGENT/extensions/oracle-auth-seed-profile/.oracle-seed-generation" \
103
- "$TEST2_AGENT/extensions/oracle-auth-seed-profile/.oracle-seed-generation"
101
+ for seed in \
102
+ "$TEST1_AGENT/extensions/oracle-auth-seed-profile" \
103
+ "$TEST2_AGENT/extensions/oracle-auth-seed-profile"; do
104
+ printf '%s\n' 'isolated-smoke-fixture' > "$seed/.oracle-seed-generation"
105
+ done
104
106
 
105
107
  echo 'secret' > "$OUTSIDE/secret.txt"
106
108
  ln -s "$OUTSIDE" "$FIXTURE/linked-outside"
@@ -181,8 +183,8 @@ Expected behavior:
181
183
  Notes:
182
184
 
183
185
  - this smoke test does not require `/oracle-auth`
184
- - the snippet creates an isolated test auth seed profile plus `.oracle-seed-generation` marker for `TEST1_AGENT` because `oracle_submit` now rejects missing or unverified seed profiles before archiving
185
- - with that marker-only seed profile, the worker still fails later due to missing real auth, which is useful because the archive remains on disk for inspection
186
+ - the snippet creates an isolated test seed profile with a non-empty `.oracle-seed-generation` value; an empty file is rejected before archiving
187
+ - this marker is fixture setup, not authenticated account access; the worker still fails later due to missing real auth, leaving the archive available for inspection
186
188
 
187
189
  ### Test 2: symlink escape rejection
188
190
 
@@ -204,7 +206,7 @@ TMUX_CMD1="cd '$REPO' && env PI_CODING_AGENT_DIR='$TEST1_AGENT' PI_ORACLE_JOBS_D
204
206
 
205
207
  Use the same pattern for additional sessions, swapping the session/job directories as needed. This keeps the test on the in-repo extension and hidden in-repo command prompt without depending on `.pi/settings.json` package entries.
206
208
 
207
- `/oracle` now starts by calling `oracle_preflight`. If you want the command flow to proceed past that early guard in an isolated test without using your normal auth state, run `/oracle-auth` in the isolated agent dir or create a purpose-built verified seed fixture with `.oracle-seed-generation`.
209
+ `/oracle` now starts by calling `oracle_preflight`. If you want the command flow to proceed past that early guard in an isolated test without using your normal auth state, run `/oracle-auth` in the isolated agent dir or create a purpose-built test seed fixture with a non-empty `.oracle-seed-generation` value.
208
210
 
209
211
  ## Additional failure-mode smoke tests
210
212
 
@@ -81,7 +81,7 @@ const ORACLE_SUBMIT_PARAMS = Type.Object({
81
81
  files: Type.Array(Type.String({
82
82
  description: "Project-relative file or directory path to include in the archive.",
83
83
  minLength: 1,
84
- pattern: "^.*\\S.*$",
84
+ pattern: "^.*[^ \\t\\r\\n].*$",
85
85
  }), {
86
86
  description: "Exact project-relative files/directories to include in the oracle archive.",
87
87
  minItems: 1,
@@ -99,7 +99,7 @@ const ORACLE_SUBMIT_PARAMS = Type.Object({
99
99
  chatGptConversationId: Type.Optional(Type.String({
100
100
  description: "Existing ChatGPT conversation id, or full https://chatgpt.com/c/... URL, to continue. Omit for default behavior: starting a fresh oracle thread. Do not combine with followUpJobId.",
101
101
  minLength: 1,
102
- pattern: "^.*\\S.*$",
102
+ pattern: "^.*[^ \\t\\r\\n].*$",
103
103
  })),
104
104
  }, { additionalProperties: false });
105
105
 
@@ -109,7 +109,7 @@ const ORACLE_PREFLIGHT_PARAMS = Type.Object({
109
109
  chatGptConversationId: Type.Optional(Type.String({
110
110
  description: "Existing ChatGPT conversation id, or full https://chatgpt.com/c/... URL, whose provider/thread readiness should be checked. Do not combine with followUpJobId.",
111
111
  minLength: 1,
112
- pattern: "^.*\\S.*$",
112
+ pattern: "^.*[^ \\t\\r\\n].*$",
113
113
  })),
114
114
  }, { additionalProperties: false });
115
115
 
@@ -17,9 +17,9 @@ import {
17
17
  import { ensureAccountCookie, filterImportableAuthCookies } from "./auth-cookie-policy.mjs";
18
18
  import { getCookiesFromConfiguredChromiumSource } from "./chromium-cookie-source.mjs";
19
19
  import { parseSnapshotEntries } from "./artifact-heuristics.mjs";
20
- import { buildAllowedChatGptOrigins } from "./chatgpt-ui-helpers.mjs";
20
+ import { buildAllowedChatGptOrigins, CHATGPT_COMPOSER_LABELS } from "./chatgpt-ui-helpers.mjs";
21
21
  import { stripUrlQueryAndHash } from "./chatgpt-flow-helpers.mjs";
22
- import { buildAccountChooserCandidateLabels, classifyChatAuthPage, normalizeLoginProbeResult } from "./auth-flow-helpers.mjs";
22
+ import { buildAccountChooserCandidateLabels, classifyChatAuthPage, isAuthNavigationError, normalizeLoginProbeResult } from "./auth-flow-helpers.mjs";
23
23
 
24
24
  const rawConfig = process.argv[2];
25
25
  if (!rawConfig) {
@@ -427,7 +427,7 @@ async function snapshotText() {
427
427
  }
428
428
 
429
429
  async function pageText() {
430
- const { stdout } = await targetCommand("get", "text", "body", { allowFailure: true, logLabel: "get text body" });
430
+ const { stdout } = await targetCommand("get", "text", "body", { logLabel: "get text body" });
431
431
  return stdout || "";
432
432
  }
433
433
 
@@ -515,7 +515,16 @@ function formatAuthFailureGuidance(error) {
515
515
  const reason = error instanceof Error ? error.message : String(error);
516
516
  const lines = ["Oracle auth failed.", "", `Reason: ${reason}`, "", "Likely causes:"];
517
517
 
518
- if (usesConfiguredChromiumCookieSource()) {
518
+ if (isAuthNavigationError(error) || /browser was closed|Timed out verifying synced/i.test(reason)) {
519
+ lines.push(
520
+ "- the isolated browser navigated, closed, or never settled during verification",
521
+ "- this browser verification failure does not by itself prove the source cookies are invalid",
522
+ "",
523
+ "Next:",
524
+ "1. Re-run /oracle-auth and leave the isolated browser open while verification completes.",
525
+ "2. If it fails again, inspect the diagnostics below.",
526
+ );
527
+ } else if (usesConfiguredChromiumCookieSource()) {
519
528
  lines.push(
520
529
  "- the configured cookie DB is stale or from the wrong browser profile",
521
530
  `- ${providerName()} is logged out in that browser profile`,
@@ -708,6 +717,7 @@ function buildLoginProbeScript(timeoutMs) {
708
717
  let bodyKeys = [];
709
718
  let bodyHasId = false;
710
719
  let bodyHasEmail = false;
720
+ let bodyIsAnonymous = false;
711
721
  let resultName = '';
712
722
  let responsePreview = '';
713
723
  try {
@@ -727,6 +737,7 @@ function buildLoginProbeScript(timeoutMs) {
727
737
  if (data && typeof data === 'object' && !Array.isArray(data)) {
728
738
  bodyKeys = Object.keys(data).slice(0, 12);
729
739
  bodyHasId = typeof data.id === 'string' && data.id.length > 0;
740
+ bodyIsAnonymous = typeof data.id === 'string' && data.id.startsWith('ua-');
730
741
  bodyHasEmail = typeof data.email === 'string' && data.email.includes('@');
731
742
  const name = typeof data.name === 'string' ? data.name.trim() : '';
732
743
  if (name) resultName = name;
@@ -757,6 +768,7 @@ function buildLoginProbeScript(timeoutMs) {
757
768
  bodyKeys,
758
769
  bodyHasId,
759
770
  bodyHasEmail,
771
+ bodyIsAnonymous,
760
772
  name: resultName,
761
773
  responsePreview,
762
774
  };
@@ -868,13 +880,25 @@ async function waitForImportedAuthReady() {
868
880
  let iteration = 0;
869
881
  while (Date.now() < timeoutAt) {
870
882
  iteration += 1;
871
- const [url, snapshot, body, probe] = await Promise.all([getUrl(), snapshotText(), pageText(), loginProbe()]);
883
+ // Drain all reads before retrying or closing the browser. Promise.all can
884
+ // reject while the other CDP commands are still running against the page.
885
+ const reads = await Promise.allSettled([getUrl(), snapshotText(), pageText(), loginProbe()]);
886
+ const failures = reads.filter((read) => read.status === "rejected");
887
+ const fatal = failures.find((read) => !isAuthNavigationError(read.reason));
888
+ if (fatal) throw fatal.reason;
889
+ if (failures.length > 0) {
890
+ await log(`poll ${iteration}: auth page navigated during verification; discarding partial reads and retrying`);
891
+ await ensureBrowserConnected();
892
+ await sleep(config.auth.pollMs);
893
+ continue;
894
+ }
895
+ const [url, snapshot, body, probe] = reads.map((read) => read.value);
872
896
  await writeFile(URL_PATH, `${url}\n`, { mode: 0o600 }).catch(() => undefined);
873
897
  await writeFile(SNAPSHOT_PATH, `${snapshot}\n`, { mode: 0o600 }).catch(() => undefined);
874
898
  await writeFile(BODY_PATH, `${body}\n`, { mode: 0o600 }).catch(() => undefined);
875
899
  const classification = classifyChatPage({ url, snapshot, body, probe });
876
900
  await log(
877
- `poll ${iteration}: url=${JSON.stringify(url)} probe=${JSON.stringify(probe)} classification=${classification.state} hasComposer=${preferredProvider() === "grok" ? snapshot.includes('Ask Grok anything') || snapshot.includes('contenteditable') : snapshot.includes(`textbox \"${CHATGPT_LABELS.composer}\"`)} hasAddFiles=${preferredProvider() === "grok" ? snapshot.includes('button \"Attach\"') : snapshot.includes(`button \"${CHATGPT_LABELS.addFiles}\"`)}`,
901
+ `poll ${iteration}: url=${JSON.stringify(url)} probe=${JSON.stringify(probe)} classification=${classification.state} hasComposer=${preferredProvider() === "grok" ? snapshot.includes('Ask Grok anything') || snapshot.includes('contenteditable') : CHATGPT_COMPOSER_LABELS.some((label) => snapshot.includes(`textbox \"${label}\"`))} hasAddFiles=${preferredProvider() === "grok" ? snapshot.includes('button \"Attach\"') : snapshot.includes(`button \"${CHATGPT_LABELS.addFiles}\"`)}`,
878
902
  );
879
903
  if (classification.state === "authenticated_and_ready") return classification;
880
904
  if (classification.state === "auth_transitioning") {
@@ -8,6 +8,7 @@ export interface OracleAuthLoginProbe {
8
8
  bodyKeys?: string[];
9
9
  bodyHasId?: boolean;
10
10
  bodyHasEmail?: boolean;
11
+ bodyIsAnonymous?: boolean;
11
12
  name?: string;
12
13
  responsePreview?: string;
13
14
  }
@@ -26,6 +27,7 @@ export interface OracleAuthPageClassification {
26
27
  }
27
28
 
28
29
  export declare function normalizeLoginProbeResult(result: unknown): OracleAuthLoginProbe;
30
+ export declare function isAuthNavigationError(error: unknown): boolean;
29
31
  export declare function buildAccountChooserCandidateLabels(name?: string): string[];
30
32
  export declare function classifyChatAuthPage(args: {
31
33
  url: string;
@@ -4,7 +4,7 @@
4
4
  // Usage: Imported by auth-bootstrap.mjs and sanity tests to exercise auth classification behavior without driving a browser.
5
5
  // Invariants/Assumptions: Inputs are already captured snapshots/probe results from the live browser session; outputs are deterministic and side-effect free.
6
6
 
7
- import { snapshotHasUsableComposerControls } from "./chatgpt-ui-helpers.mjs";
7
+ import { CHATGPT_COMPOSER_LABELS, snapshotHasUsableComposerControls } from "./chatgpt-ui-helpers.mjs";
8
8
 
9
9
  /** @typedef {import("./auth-flow-helpers.d.mts").OracleAuthLoginProbe} OracleAuthLoginProbe */
10
10
  /** @typedef {import("./auth-flow-helpers.d.mts").OracleAuthPageClassification} OracleAuthPageClassification */
@@ -31,11 +31,23 @@ export function normalizeLoginProbeResult(result) {
31
31
  bodyKeys: Array.isArray(value.bodyKeys) ? value.bodyKeys.filter((entry) => typeof entry === "string") : [],
32
32
  bodyHasId: value.bodyHasId === true,
33
33
  bodyHasEmail: value.bodyHasEmail === true,
34
+ bodyIsAnonymous: value.bodyIsAnonymous === true,
34
35
  name: typeof value.name === "string" ? value.name : undefined,
35
36
  responsePreview: typeof value.responsePreview === "string" ? value.responsePreview : undefined,
36
37
  };
37
38
  }
38
39
 
40
+ /**
41
+ * CDP evaluations can be interrupted by normal redirects. Do not retry arbitrary
42
+ * protocol failures or explicitly closed/disconnected browser errors.
43
+ * @param {unknown} error
44
+ * @returns {boolean}
45
+ */
46
+ export function isAuthNavigationError(error) {
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ return /Inspected target navigated or closed|Execution context was destroyed|Cannot find context with specified id/i.test(message);
49
+ }
50
+
39
51
  /**
40
52
  * @param {string | undefined} name
41
53
  * @returns {string[]}
@@ -69,7 +81,7 @@ export function classifyChatAuthPage(args) {
69
81
  const onAllowedOrigin = args.allowedOrigins.some((origin) => args.url.startsWith(origin));
70
82
  const hasComposer = args.snapshot.includes(`textbox "${composerLabel}"`);
71
83
  const hasAddFiles = args.snapshot.includes(`button "${addFilesLabel}"`);
72
- const hasUsableComposer = composerLabel === DEFAULT_COMPOSER_LABEL && addFilesLabel === DEFAULT_ADD_FILES_LABEL
84
+ const hasUsableComposer = CHATGPT_COMPOSER_LABELS.includes(composerLabel) && addFilesLabel === DEFAULT_ADD_FILES_LABEL
73
85
  ? snapshotHasUsableComposerControls(args.snapshot)
74
86
  : hasComposer && hasAddFiles;
75
87
 
@@ -112,7 +124,16 @@ export function classifyChatAuthPage(args) {
112
124
  return { state: "transient_outage_error", message: `ChatGPT is showing a transient outage/error page. Logs: ${args.logPath}` };
113
125
  }
114
126
 
115
- const probeHasAccountIdentity = args.probe?.bodyHasId === true || args.probe?.bodyHasEmail === true;
127
+ // /backend-api/me also returns HTTP 200 and an id for anonymous visitors.
128
+ // Those ua-* identities must not drive account selection or auth reloads.
129
+ if (args.probe?.bodyIsAnonymous && (args.probe?.onAuthPage || args.probe?.domLoginCta)) {
130
+ return {
131
+ state: "login_required",
132
+ message: `Synced cookies from ${args.cookieSourceLabel}, but ChatGPT returned an anonymous visitor session, not a signed-in account. Sign in to ChatGPT in the source browser profile, quit that browser, then rerun /oracle-auth. Logs: ${args.logPath}`,
133
+ };
134
+ }
135
+
136
+ const probeHasAccountIdentity = !args.probe?.bodyIsAnonymous && (args.probe?.bodyHasId === true || args.probe?.bodyHasEmail === true);
116
137
 
117
138
  if (args.probe?.status === 401 || (args.probe?.status === 403 && (!onAllowedOrigin || !hasUsableComposer))) {
118
139
  return {
@@ -4,6 +4,8 @@
4
4
  // Usage: Imported by run-job.mjs and sanity tests to validate conversation-state heuristics without driving a browser.
5
5
  // Invariants/Assumptions: Snapshot text comes from agent-browser `snapshot -i`; URL inputs may be malformed and must fail safely.
6
6
 
7
+ import { CHATGPT_COMPOSER_LABELS } from "./chatgpt-ui-helpers.mjs";
8
+
7
9
  /** @typedef {import("./chatgpt-flow-helpers.d.mts").OracleStableValueState} OracleStableValueState */
8
10
  /** @typedef {import("./chatgpt-flow-helpers.d.mts").OracleSendAcceptanceState} OracleSendAcceptanceState */
9
11
 
@@ -23,8 +25,9 @@ export function assistantSnapshotSlice(snapshot, composerLabel, responseIndex) {
23
25
  const nextAssistantIndex = assistantHeadingIndices[responseIndex + 1];
24
26
  if (nextAssistantIndex !== undefined) endCandidates.push(nextAssistantIndex);
25
27
 
28
+ const composerLabels = CHATGPT_COMPOSER_LABELS.includes(composerLabel) ? CHATGPT_COMPOSER_LABELS : [composerLabel];
26
29
  const composerIndex = lines.findIndex(
27
- (line, index) => index > startIndex && line.includes(`textbox "${composerLabel}"`),
30
+ (line, index) => index > startIndex && composerLabels.some((label) => line.includes(`textbox "${label}"`)),
28
31
  );
29
32
  if (composerIndex !== -1) endCandidates.push(composerIndex);
30
33
 
@@ -7,6 +7,7 @@ export interface OracleUiSelection {
7
7
  autoSwitchToThinking?: boolean;
8
8
  }
9
9
 
10
+ export declare const CHATGPT_COMPOSER_LABELS: readonly string[];
10
11
  export declare const CHATGPT_CANONICAL_APP_ORIGINS: readonly string[];
11
12
 
12
13
  export declare function buildAllowedChatGptOrigins(chatUrl: string, authUrl?: string): string[];
@@ -13,6 +13,8 @@ import { parseSnapshotEntries } from "./artifact-heuristics.mjs";
13
13
  /** @typedef {{ responseText: string; artifactLabels?: string[]; suspiciousArtifactLabels?: string[] }} CompletionSignatureArgs */
14
14
  /** @typedef {{ hasStopStreaming: boolean; hasTargetCopyResponse: boolean; responseText: string; artifactLabels?: string[]; suspiciousArtifactLabels?: string[] }} DerivedCompletionSignatureArgs */
15
15
 
16
+ export const CHATGPT_COMPOSER_LABELS = Object.freeze(["Chat with ChatGPT", "Ask ChatGPT"]);
17
+
16
18
  export const CHATGPT_CANONICAL_APP_ORIGINS = Object.freeze([
17
19
  "https://chatgpt.com",
18
20
  "https://chat.openai.com",
@@ -29,9 +31,10 @@ const AUTO_SWITCH_LABEL = "Auto-switch to Thinking";
29
31
  const THINKING_EFFORT_COMBOBOX_LABEL = "Thinking effort";
30
32
  const PRO_THINKING_EFFORT_COMBOBOX_LABEL = "Pro thinking effort";
31
33
  const EFFORT_LABELS = new Set(["Light", "Standard", "Extended", "Heavy"]);
32
- const COMPACT_INTELLIGENCE_MENU_PATTERN = /(?:Intelligence.*Instant.*Medium.*High.*Pro|^(?:Instant|Medium|High|Extra High|Pro Standard|Pro Extended)$)/i;
33
- const COMPACT_INTELLIGENCE_CONTROL_PATTERN = /^(?: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;
34
- const COMPACT_INTELLIGENCE_OPENER_PATTERN = /^(?:Instant|Medium|High|Extra High|Pro|Pro Standard|Pro Extended)$/i;
34
+ const COMPACT_INTELLIGENCE_MENU_PATTERN = /(?:Intelligence.*Instant.*Medium.*High.*Pro|^(?:Instant|Medium|High|Extra High|Pro(?: Standard| Extended)?)$)/i;
35
+ // Bare Instant is the legacy top-level family radio; compact Instant rows are versioned (Instant 5s / Instant 5.5).
36
+ const COMPACT_INTELLIGENCE_CONTROL_PATTERN = /^(?:Instant\s+[\d.]+s?|Medium(?:\s+5\s*[–-]\s*30s)?|High(?:\s+15\s*[–-]\s*60s)?|Extra High|Pro(?:\s+5\+\s*min|\s+Standard|\s+Extended)?)$/i;
37
+ const COMPACT_INTELLIGENCE_OPENER_PATTERN = /^(?:Instant(?:\s+[\d.]+s?)?|Medium|High|Extra High|Pro(?: Standard| Extended)?)$/i;
35
38
  const BARE_EFFORT_PATTERN = /^(light|standard|extended|heavy)(?:, click to remove)?$/i;
36
39
  const INSTANT_CHIP_PATTERN = /^instant(?:, click to remove)?$/i;
37
40
  const THINKING_CHIP_PATTERN = /^(?:(light|standard|extended|heavy)\s+)?thinking(?:, click to remove)?$/i;
@@ -131,7 +134,7 @@ export function requestedEffortLabel(selection) {
131
134
  * @returns {string}
132
135
  */
133
136
  function normalizeChipLabel(label) {
134
- return normalizeText(label).replace(/, click to remove$/i, "").trim();
137
+ return normalizeText(label).replace(/, click to remove$/i, "").replace(/^\d+(?:\.\d+)?\s+(?=Pro$)/i, "").trim();
135
138
  }
136
139
 
137
140
  function parseComposerChipSelection(label) {
@@ -184,7 +187,7 @@ function parseCompactIntelligenceSelection(label) {
184
187
  const normalized = normalizeChipLabel(label);
185
188
  if (!COMPACT_INTELLIGENCE_CONTROL_PATTERN.test(normalized)) return undefined;
186
189
 
187
- if (/^Instant(?:\s+5s)?$/i.test(normalized)) {
190
+ if (/^Instant\s+[\d.]+s?$/i.test(normalized)) {
188
191
  return {
189
192
  modelFamily: /** @type {OracleUiModelFamily} */ ("instant"),
190
193
  compactTier: "instant",
@@ -219,6 +222,8 @@ function parseCompactIntelligenceSelection(label) {
219
222
  compactTier: "pro",
220
223
  };
221
224
  }
225
+ // "Pro 5+ min" is always the compact Pro tier. Bare "Pro" is ambiguous with the
226
+ // legacy top-level family radio and is handled with sibling context below.
222
227
  if (/^Pro\s+5\+\s*min$/i.test(normalized)) {
223
228
  return {
224
229
  modelFamily: /** @type {OracleUiModelFamily} */ ("pro"),
@@ -229,6 +234,24 @@ function parseCompactIntelligenceSelection(label) {
229
234
  return undefined;
230
235
  }
231
236
 
237
+ function parseBareProCompactSelection(label) {
238
+ if (/click to remove/i.test(String(label || ""))) return undefined;
239
+ if (!/^Pro$/i.test(normalizeChipLabel(label))) return undefined;
240
+ return {
241
+ modelFamily: /** @type {OracleUiModelFamily} */ ("pro"),
242
+ compactTier: "pro",
243
+ };
244
+ }
245
+
246
+ function snapshotHasCompactTierSiblings(entries, exceptLabel) {
247
+ const except = normalizeChipLabel(exceptLabel).toLowerCase();
248
+ return entries.some((entry) => {
249
+ if (entry.disabled || entry.kind !== "menuitemradio") return false;
250
+ if (normalizeChipLabel(entry.label).toLowerCase() === except) return false;
251
+ return Boolean(parseCompactIntelligenceSelection(entry.label));
252
+ });
253
+ }
254
+
232
255
  function hasRemovableComposerModelChip(entries) {
233
256
  return entries.some(
234
257
  (entry) => entry.kind === "button" && /click to remove/i.test(String(entry.label || "")) && parseComposerChipSelection(entry.label),
@@ -237,7 +260,7 @@ function hasRemovableComposerModelChip(entries) {
237
260
 
238
261
  function hasCompactIntelligenceMenuContext(entries) {
239
262
  return entries.some((entry) => !entry.disabled && entry.kind === "menu" && COMPACT_INTELLIGENCE_MENU_PATTERN.test(normalizeText(entry.label)))
240
- || entries.some((entry) => !entry.disabled && entry.kind === "menuitemradio" && checkedState(entry) === true && parseCompactIntelligenceSelection(entry.label));
263
+ || entries.some((entry) => !entry.disabled && entry.kind === "menuitemradio" && checkedState(entry) === true && compactSelectionFromEntry(entry, entries));
241
264
  }
242
265
 
243
266
  function hasLegacyEffortCombobox(entries) {
@@ -248,18 +271,36 @@ function hasLegacyEffortCombobox(entries) {
248
271
  });
249
272
  }
250
273
 
251
- function compactSelectionFromEntry(entry, _entries, options = {}) {
274
+ function compactSelectionFromEntry(entry, entries = [], options = {}) {
252
275
  if (entry.disabled) return undefined;
253
276
  const kind = entry.kind || "";
254
- if (COMPACT_INTELLIGENCE_CONTROL_KINDS.has(kind)) return parseCompactIntelligenceSelection(entry.label);
277
+ if (COMPACT_INTELLIGENCE_CONTROL_KINDS.has(kind)) {
278
+ const parsed = parseCompactIntelligenceSelection(entry.label);
279
+ if (parsed) return parsed;
280
+ // Bare "Pro" is compact only when versioned Instant / Medium / High / Extra High siblings exist.
281
+ if (snapshotHasCompactTierSiblings(entries, entry.label)) {
282
+ return parseBareProCompactSelection(entry.label);
283
+ }
284
+ return undefined;
285
+ }
255
286
  if (options.allowClosedButtons && kind === "button" && !/\bexpanded=true\b/.test(String(entry.line || ""))) {
256
- return parseCompactIntelligenceSelection(entry.label);
287
+ const parsed = parseCompactIntelligenceSelection(entry.label);
288
+ if (parsed) return parsed;
289
+ const barePro = parseBareProCompactSelection(entry.label);
290
+ if (barePro) return barePro;
291
+ // Closed composer pills keep bare Instant after the compact menu closes.
292
+ if (/^Instant$/i.test(normalizeChipLabel(entry.label))) {
293
+ return {
294
+ modelFamily: /** @type {OracleUiModelFamily} */ ("instant"),
295
+ compactTier: "instant",
296
+ };
297
+ }
257
298
  }
258
299
  return undefined;
259
300
  }
260
301
 
261
302
  export function matchesCompactIntelligenceControlLabel(label) {
262
- return Boolean(parseCompactIntelligenceSelection(label));
303
+ return Boolean(parseCompactIntelligenceSelection(label) || parseBareProCompactSelection(label));
263
304
  }
264
305
 
265
306
  export function snapshotHasClosedCompactSelection(snapshot, selection) {
@@ -330,7 +371,7 @@ function detectCompactIntelligenceSelection(entries) {
330
371
  }
331
372
 
332
373
  export function matchesRequestedModelControlLabel(label, selection) {
333
- const compactSelection = parseCompactIntelligenceSelection(label);
374
+ const compactSelection = parseCompactIntelligenceSelection(label) || parseBareProCompactSelection(label);
334
375
  if (compactSelection) return compactSelectionMatchesRequested(selection, compactSelection);
335
376
  return matchesModelFamilyLabel(label, selection.modelFamily);
336
377
  }
@@ -455,7 +496,7 @@ export function snapshotHasModelConfigurationUi(snapshot) {
455
496
  ),
456
497
  );
457
498
  const visibleCompactControls = entries.filter(
458
- (entry) => !entry.disabled && entry.kind === "menuitemradio" && parseCompactIntelligenceSelection(entry.label),
499
+ (entry) => !entry.disabled && entry.kind === "menuitemradio" && compactSelectionFromEntry(entry, entries),
459
500
  );
460
501
  const hasCompactIntelligenceMenu = entries.some(
461
502
  (entry) => !entry.disabled && entry.kind === "menu" && COMPACT_INTELLIGENCE_MENU_PATTERN.test(normalizeText(entry.label)),
@@ -464,7 +505,9 @@ export function snapshotHasModelConfigurationUi(snapshot) {
464
505
  const hasEffortCombobox = entries.some(
465
506
  (entry) => entry.kind === "combobox" && EFFORT_LABELS.has(entry.value || "") && !entry.disabled,
466
507
  );
467
- return visibleFamilies.size >= 2 || visibleRadioFamilies.size >= 2 || visibleCompactControls.length >= 2 || hasCompactIntelligenceMenu || hasIntelligenceHeading || hasEffortCombobox;
508
+ const hasPowerSliderMenu = entries.some((entry) => entry.kind === "menuitem" && entry.label === "Power" && !entry.disabled)
509
+ && entries.some((entry) => entry.kind === "menuitem" && entry.label === "Select model" && !entry.disabled);
510
+ return visibleFamilies.size >= 2 || visibleRadioFamilies.size >= 2 || visibleCompactControls.length >= 2 || hasCompactIntelligenceMenu || hasIntelligenceHeading || hasEffortCombobox || hasPowerSliderMenu;
468
511
  }
469
512
 
470
513
  /**
@@ -474,7 +517,7 @@ export function snapshotHasModelConfigurationUi(snapshot) {
474
517
  export function snapshotHasUsableComposerControls(snapshot) {
475
518
  /** @type {SnapshotEntry[]} */
476
519
  const entries = parseSnapshotEntries(snapshot);
477
- const hasComposer = entries.some((entry) => entry.kind === "textbox" && entry.label === "Chat with ChatGPT" && !entry.disabled);
520
+ const hasComposer = entries.some((entry) => entry.kind === "textbox" && CHATGPT_COMPOSER_LABELS.includes(entry.label || "") && !entry.disabled);
478
521
  const hasAddFiles = entries.some((entry) => entry.kind === "button" && entry.label === "Add files and more" && !entry.disabled);
479
522
  return hasComposer && hasAddFiles;
480
523
  }
@@ -23,6 +23,7 @@ import { getOracleJobsDir } from "../shared/state-path-helpers.mjs";
23
23
  import { extractArtifactLabels, FILE_LABEL_PATTERN_SOURCE, GENERIC_ARTIFACT_LABELS, parseSnapshotEntries, partitionStructuralArtifactCandidates } from "./artifact-heuristics.mjs";
24
24
  import {
25
25
  buildAllowedChatGptOrigins,
26
+ CHATGPT_COMPOSER_LABELS,
26
27
  deriveAssistantCompletionSignature,
27
28
  matchesCompactIntelligenceControlLabel,
28
29
  matchesCompactIntelligenceOpenerLabel,
@@ -51,7 +52,8 @@ if (!jobId) {
51
52
  process.exit(1);
52
53
  }
53
54
 
54
- const jobDir = join(getOracleJobsDir(), `oracle-${jobId}`);
55
+ const ORACLE_JOBS_DIR = getOracleJobsDir();
56
+ const jobDir = join(ORACLE_JOBS_DIR, `oracle-${jobId}`);
55
57
  const jobPath = `${jobDir}/job.json`;
56
58
  const CHATGPT_LABELS = {
57
59
  composer: "Chat with ChatGPT",
@@ -820,6 +822,7 @@ function buildLoginProbeScript(timeoutMs) {
820
822
  let bodyKeys = [];
821
823
  let bodyHasId = false;
822
824
  let bodyHasEmail = false;
825
+ let bodyIsAnonymous = false;
823
826
  try {
824
827
  if (typeof fetch === 'function') {
825
828
  const controller = new AbortController();
@@ -837,6 +840,7 @@ function buildLoginProbeScript(timeoutMs) {
837
840
  if (data && typeof data === 'object' && !Array.isArray(data)) {
838
841
  bodyKeys = Object.keys(data).slice(0, 12);
839
842
  bodyHasId = typeof data.id === 'string' && data.id.length > 0;
843
+ bodyIsAnonymous = typeof data.id === 'string' && data.id.startsWith('ua-');
840
844
  bodyHasEmail = typeof data.email === 'string' && data.email.includes('@');
841
845
  }
842
846
  }
@@ -860,6 +864,7 @@ function buildLoginProbeScript(timeoutMs) {
860
864
  bodyKeys,
861
865
  bodyHasId,
862
866
  bodyHasEmail,
867
+ bodyIsAnonymous,
863
868
  };
864
869
  `);
865
870
  }
@@ -895,8 +900,8 @@ function snapshotHasCompactIntelligenceMenuControls(snapshot) {
895
900
  return Boolean(findEntry(snapshot, (candidate) => {
896
901
  if (candidate.disabled) return false;
897
902
  const label = normalizeSnapshotLabel(candidate.label);
898
- return (candidate.kind === "menu" && /(?:Intelligence.*Instant.*Medium.*High.*Pro|^(?:Instant|Medium|High|Extra High|Pro Extended)$)/i.test(label))
899
- || (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));
903
+ return (candidate.kind === "menu" && /(?:Intelligence.*Instant.*Medium.*High.*Pro|^(?:Instant|Medium|High|Extra High|Pro(?: Standard| Extended)?)$)/i.test(label))
904
+ || (candidate.kind === "menuitemradio" && /^(?:Instant\s+[\d.]+s?|Medium(?:\s+5\s*[–-]\s*30s)?|High(?:\s+15\s*[–-]\s*60s)?|Extra High|Pro(?:\s+5\+\s*min|\s+Standard|\s+Extended)?)$/i.test(label));
900
905
  }));
901
906
  }
902
907
 
@@ -904,7 +909,7 @@ function matchesRequestedModelControl(candidate, selection, options = {}) {
904
909
  if (!["button", "radio", "menuitemradio"].includes(candidate.kind || "") || typeof candidate.label !== "string" || candidate.disabled) return false;
905
910
  if (candidate.kind === "button") {
906
911
  if (/\bexpanded=true\b/.test(String(candidate.line || ""))) return false;
907
- if (options.ignoreCompactTierButtons && /^(?:Instant|Medium|High|Extra High|Pro|Pro Extended)$/i.test(candidate.label)) return false;
912
+ if (options.ignoreCompactTierButtons && /^(?:Instant(?:\s+[\d.]+s?)?|Medium|High|Extra High|Pro(?: Standard| Extended)?)$/i.test(candidate.label)) return false;
908
913
  if (options.ignoreCompactOnlyButtons && /^(?:Medium|High|Extra High)$/i.test(candidate.label)) return false;
909
914
  }
910
915
  if (selection.modelFamily === "pro" && /^Pro(?:\s+Extended)?$/i.test(candidate.label)) return true;
@@ -936,7 +941,7 @@ function composerControlsVisible(snapshot, job = currentJob) {
936
941
  const entries = parseSnapshotEntries(snapshot);
937
942
  const hasComposer = isGrokJob(job)
938
943
  ? entries.some((entry) => !entry.disabled && ((entry.kind === "textbox" && entry.label === labels.composer) || /editable/.test(String(entry.line || ""))))
939
- : entries.some((entry) => entry.kind === "textbox" && entry.label === labels.composer && !entry.disabled);
944
+ : entries.some((entry) => entry.kind === "textbox" && CHATGPT_COMPOSER_LABELS.includes(entry.label) && !entry.disabled);
940
945
  const hasAddFiles = entries.some(
941
946
  (entry) => entry.kind === "button" && entry.label === labels.addFiles && !entry.disabled,
942
947
  );
@@ -1041,8 +1046,7 @@ async function setComposerText(job, text) {
1041
1046
  return;
1042
1047
  }
1043
1048
  const snapshot = await snapshotText(job);
1044
- const labels = labelsForJob(job);
1045
- const entry = findEntry(snapshot, (candidate) => candidate.kind === "textbox" && candidate.label === labels.composer);
1049
+ const entry = findEntry(snapshot, (candidate) => candidate.kind === "textbox" && CHATGPT_COMPOSER_LABELS.includes(candidate.label) && !candidate.disabled);
1046
1050
  if (!entry) throw new Error("Could not find ChatGPT composer textbox");
1047
1051
  await agentBrowser(job, "fill", entry.ref, text);
1048
1052
  }
@@ -1075,7 +1079,11 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
1075
1079
  const onAuthPath = typeof url === "string" && url.includes("/auth/");
1076
1080
  const hasUsableComposer = snapshotHasUsableComposerControls(snapshot);
1077
1081
 
1078
- const probeHasAccountIdentity = probe?.bodyHasId === true || probe?.bodyHasEmail === true;
1082
+ if (probe?.bodyIsAnonymous && (probe?.onAuthPage || probe?.domLoginCta)) {
1083
+ return { state: "login_required", message: "ChatGPT shows login controls and returned an anonymous visitor session, not a signed-in account. Sign in to ChatGPT in the configured source browser profile, then run /oracle-auth." };
1084
+ }
1085
+
1086
+ const probeHasAccountIdentity = !probe?.bodyIsAnonymous && (probe?.bodyHasId === true || probe?.bodyHasEmail === true);
1079
1087
 
1080
1088
  if (probe?.status === 401 || (probe?.status === 403 && (!onAllowedOrigin || !hasUsableComposer))) {
1081
1089
  return { state: "login_required", message: "ChatGPT login is required. Run /oracle-auth." };
@@ -1268,10 +1276,10 @@ function detectResponseFailureText(text) {
1268
1276
 
1269
1277
  function composerSnapshotSlice(snapshot, job = currentJob) {
1270
1278
  const lines = snapshot.split("\n");
1271
- const labels = labelsForJob(job);
1279
+ const composerLabels = isGrokJob(job) ? [GROK_LABELS.composer] : CHATGPT_COMPOSER_LABELS;
1272
1280
  let composerIndex = -1;
1273
1281
  for (let index = lines.length - 1; index >= 0; index -= 1) {
1274
- if (lines[index].includes(`textbox "${labels.composer}"`) || (isGrokJob(job) && lines[index].includes("contenteditable"))) {
1282
+ if (composerLabels.some((label) => lines[index].includes(`textbox "${label}"`)) || (isGrokJob(job) && lines[index].includes("contenteditable"))) {
1275
1283
  composerIndex = index;
1276
1284
  break;
1277
1285
  }
@@ -1526,6 +1534,54 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
1526
1534
  throw new Error(`Could not verify requested model settings after configuration for ${job.selection.modelFamily}`);
1527
1535
  }
1528
1536
 
1537
+ async function configurePowerSlider(job) {
1538
+ // The current picker retains the compact tier semantics, but exposes keyboard
1539
+ // interaction on a Power menuitem rather than accessible tier radio buttons.
1540
+ const tiers = ["Instant", "Medium", "High", "Extra High", "Pro"];
1541
+ const requested = job.selection.modelFamily === "instant" ? "Instant"
1542
+ : job.selection.modelFamily === "pro" ? "Pro"
1543
+ : job.selection.effort === "heavy" ? "Extra High"
1544
+ : job.selection.effort === "extended" ? "High" : "Medium";
1545
+ const target = tiers.indexOf(requested);
1546
+ const selector = '[data-testid="composer-intelligence-picker-content"] [role="menuitem"][aria-label="Power"]';
1547
+ await agentBrowser(job, "focus", selector);
1548
+ const timeoutAt = Date.now() + MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS;
1549
+ let moves = 0;
1550
+ while (Date.now() < timeoutAt && moves < tiers.length) {
1551
+ const state = await evalPage(job, toJsonScript(`
1552
+ const control = document.querySelector(${JSON.stringify(selector)});
1553
+ const slider = control?.querySelector('[role="slider"]');
1554
+ if (!slider || control.closest('[inert]')) return null;
1555
+ const description = (control.getAttribute('aria-describedby') || '').split(/\\s+/)
1556
+ .map(id => document.getElementById(id)?.textContent || '').join(' ');
1557
+ return { min: slider.getAttribute('aria-valuemin'), max: slider.getAttribute('aria-valuemax'),
1558
+ value: slider.getAttribute('aria-valuenow'), description };
1559
+ `));
1560
+ // The menuitem can appear before React mounts its slider, especially headless.
1561
+ if (!state) {
1562
+ await sleep(150);
1563
+ continue;
1564
+ }
1565
+ const value = Number(state.value);
1566
+ if (state.min !== "0" || state.max !== "4" || typeof state.value !== "string" || !Number.isInteger(value) || value < 0 || value > 4) {
1567
+ throw new Error("ChatGPT Power slider has an unrecognized range; refusing to guess model settings");
1568
+ }
1569
+ if (value === target) {
1570
+ if (!state.description.startsWith(`${requested}, ${target + 1} of ${tiers.length}.`)) {
1571
+ throw new Error(`Could not verify ChatGPT Power tier: ${requested}`);
1572
+ }
1573
+ await log(`Verified ChatGPT Power tier ${requested} for family=${job.selection.modelFamily} effort=${job.selection.effort || "(none)"}`);
1574
+ await agentBrowser(job, "press", "Escape");
1575
+ await waitForModelConfigurationToSettle(job, { stronglyVerified: true });
1576
+ return;
1577
+ }
1578
+ await agentBrowser(job, "press", value < target ? "ArrowRight" : "ArrowLeft");
1579
+ moves++;
1580
+ await sleep(150);
1581
+ }
1582
+ throw new Error(`ChatGPT Power slider did not reach requested tier: ${requested}`);
1583
+ }
1584
+
1529
1585
  async function configureModel(job) {
1530
1586
  if (isGrokJob(job)) return configureGrokModel(job);
1531
1587
  const initialSnapshot = await snapshotText(job);
@@ -1536,6 +1592,10 @@ async function configureModel(job) {
1536
1592
 
1537
1593
  await log(`Configuring model family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}`);
1538
1594
  let familySnapshot = await openModelConfiguration(job);
1595
+ if (findEntry(familySnapshot, (entry) => entry.kind === "menuitem" && entry.label === "Power" && !entry.disabled)) {
1596
+ await configurePowerSlider(job);
1597
+ return;
1598
+ }
1539
1599
  let verificationSnapshot = familySnapshot;
1540
1600
 
1541
1601
  const alreadyConfiguredInUi = snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection);
@@ -1582,25 +1642,34 @@ async function configureModel(job) {
1582
1642
  if (effortLabel && !effortSelectionVisible(familySnapshot, effortLabel)) {
1583
1643
  const opened = await openEffortDropdown(job);
1584
1644
  if (!opened) {
1585
- throw new Error(`Could not open effort dropdown for requested effort: ${effortLabel}`);
1586
- }
1587
- await agentBrowser(job, "wait", "300");
1588
- if (job.selection.modelFamily === "pro" && await maybeClickLabeledEntry(job, `Pro ${effortLabel}`, { kind: "menuitemradio" })) {
1589
- // Current ChatGPT exposes Pro effort choices as nested menu radio items.
1645
+ // Current ChatGPT Pro menus sometimes expose only undifferentiated "Pro" with no Standard/Extended rows.
1646
+ const afterOpenAttempt = await snapshotText(job);
1647
+ if (job.selection.modelFamily === "pro" && snapshotStronglyMatchesRequestedModel(afterOpenAttempt, job.selection)) {
1648
+ await log(`Pro effort dropdown unavailable for ${effortLabel}; accepting undifferentiated Pro selection`);
1649
+ verificationSnapshot = afterOpenAttempt;
1650
+ familySnapshot = afterOpenAttempt;
1651
+ } else {
1652
+ throw new Error(`Could not open effort dropdown for requested effort: ${effortLabel}`);
1653
+ }
1590
1654
  } else {
1591
- await clickLabeledEntry(job, effortLabel, { kind: "option" });
1592
- }
1593
- await agentBrowser(job, "wait", "400");
1594
- const effortSnapshot = await snapshotText(job);
1595
- verificationSnapshot = effortSnapshot;
1596
- const selectedEffort = findEntry(
1597
- effortSnapshot,
1598
- (candidate) => candidate.kind === "combobox" && candidate.value === effortLabel && !candidate.disabled,
1599
- );
1600
- if (!selectedEffort && !effortSelectionVisible(effortSnapshot, effortLabel)) {
1601
- throw new Error(`Requested effort did not remain selected: ${effortLabel}`);
1655
+ await agentBrowser(job, "wait", "300");
1656
+ if (job.selection.modelFamily === "pro" && await maybeClickLabeledEntry(job, `Pro ${effortLabel}`, { kind: "menuitemradio" })) {
1657
+ // Current ChatGPT exposes Pro effort choices as nested menu radio items.
1658
+ } else {
1659
+ await clickLabeledEntry(job, effortLabel, { kind: "option" });
1660
+ }
1661
+ await agentBrowser(job, "wait", "400");
1662
+ const effortSnapshot = await snapshotText(job);
1663
+ verificationSnapshot = effortSnapshot;
1664
+ const selectedEffort = findEntry(
1665
+ effortSnapshot,
1666
+ (candidate) => candidate.kind === "combobox" && candidate.value === effortLabel && !candidate.disabled,
1667
+ );
1668
+ if (!selectedEffort && !effortSelectionVisible(effortSnapshot, effortLabel)) {
1669
+ throw new Error(`Requested effort did not remain selected: ${effortLabel}`);
1670
+ }
1671
+ familySnapshot = effortSnapshot;
1602
1672
  }
1603
- familySnapshot = effortSnapshot;
1604
1673
  }
1605
1674
  }
1606
1675
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.7.19",
3
+ "version": "0.7.21",
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",
@@ -37,6 +37,8 @@
37
37
  "scripts/platform-smoke.mjs",
38
38
  "scripts/platform-smoke",
39
39
  "scripts/oracle-real-smoke.mjs",
40
+ "scripts/oracle-auth.test.mjs",
41
+ "scripts/oracle-model.test.mjs",
40
42
  "scripts/oracle-chatgpt-preset-proof.mjs"
41
43
  ],
42
44
  "pi": {
@@ -51,10 +53,11 @@
51
53
  "check:oracle-extension": "node --check extensions/oracle/shared/browser-profile-helpers.mjs && node --check extensions/oracle/shared/process-helpers.mjs && node --check extensions/oracle/shared/state-path-helpers.mjs && node --check extensions/oracle/shared/time-helpers.mjs && node --check extensions/oracle/shared/state-coordination-helpers.mjs && node --check extensions/oracle/shared/job-coordination-helpers.mjs && node --check extensions/oracle/shared/job-lifecycle-helpers.mjs && node --check extensions/oracle/shared/job-observability-helpers.mjs && node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/state-locks.mjs && node --check extensions/oracle/worker/artifact-heuristics.mjs && node --check extensions/oracle/worker/chatgpt-ui-helpers.mjs && node --check extensions/oracle/worker/chatgpt-flow-helpers.mjs && node --check extensions/oracle/worker/auth-flow-helpers.mjs && node --check extensions/oracle/worker/auth-cookie-policy.mjs && node --check extensions/oracle/worker/chromium-cookie-source.mjs && node --check extensions/oracle/worker/auth-bootstrap.mjs && esbuild extensions/oracle/index.ts --bundle --platform=node --format=esm --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent --external:typebox --outfile=/tmp/pi-oracle-extension-check.js",
52
54
  "typecheck": "tsc --noEmit -p tsconfig.json",
53
55
  "typecheck:worker-helpers": "tsc --noEmit -p tsconfig.worker-helpers.json",
54
- "sanity:oracle": "node scripts/oracle-sanity-runner.mjs",
56
+ "sanity:oracle": "node --test scripts/oracle-auth.test.mjs scripts/oracle-model.test.mjs && node scripts/oracle-sanity-runner.mjs",
55
57
  "pack:check": "npm pack --dry-run",
56
58
  "verify:oracle": "npm run check:oracle-extension && npm run check:platform-smoke && npm run check:oracle-real-smoke && npm run check:oracle-release-proof && npm run typecheck && npm run typecheck:worker-helpers && npm run sanity:oracle && npm run pack:check",
57
59
  "test": "npm run verify:oracle",
60
+ "check:compat": "npm run verify:oracle && node --test scripts/oracle-native.test.mjs",
58
61
  "prepublishOnly": "npm run release:check",
59
62
  "check:platform-smoke": "node --check scripts/platform-smoke.mjs && node --check scripts/platform-smoke/assertions.mjs && node --check scripts/platform-smoke/artifacts.mjs && node --check scripts/platform-smoke/crabbox-runner.mjs && node --check scripts/platform-smoke/doctor.mjs && node --check scripts/platform-smoke/targets.mjs && node scripts/platform-smoke/invariants.mjs",
60
63
  "smoke:platform": "node scripts/platform-smoke.mjs",
@@ -71,7 +74,7 @@
71
74
  "release:proof:chatgpt-presets": "node scripts/oracle-chatgpt-preset-proof.mjs check",
72
75
  "smoke:real:packed": "node scripts/oracle-real-smoke.mjs run --mode packed",
73
76
  "smoke:real:source": "node scripts/oracle-real-smoke.mjs run --mode source",
74
- "sanity:oracle:platform": "node scripts/oracle-sanity-runner.mjs --mode platform",
77
+ "sanity:oracle:platform": "node --test scripts/oracle-auth.test.mjs scripts/oracle-model.test.mjs && node scripts/oracle-sanity-runner.mjs --mode platform",
75
78
  "verify:oracle:platform": "npm run check:oracle-extension && npm run check:platform-smoke && npm run check:oracle-real-smoke && npm run check:oracle-release-proof && npm run sanity:oracle:platform && npm run pack:check"
76
79
  },
77
80
  "dependencies": {
@@ -87,8 +90,8 @@
87
90
  "protobufjs": "7.6.1"
88
91
  },
89
92
  "devDependencies": {
90
- "@earendil-works/pi-ai": "^0.80.9",
91
- "@earendil-works/pi-coding-agent": "^0.80.9",
93
+ "@earendil-works/pi-ai": "0.86.1",
94
+ "@earendil-works/pi-coding-agent": "0.86.1",
92
95
  "@types/node": "^22.19.19",
93
96
  "esbuild": "^0.28.0",
94
97
  "tsx": "^4.22.3",
@@ -0,0 +1,129 @@
1
+ // Regression coverage for auth observations interrupted by navigation and
2
+ // ChatGPT's anonymous /backend-api/me responses. No real cookies or browser.
3
+ import assert from "node:assert/strict";
4
+ import { readFile } from "node:fs/promises";
5
+ import vm from "node:vm";
6
+ import test from "node:test";
7
+ import { classifyChatAuthPage, isAuthNavigationError, normalizeLoginProbeResult } from "../extensions/oracle/worker/auth-flow-helpers.mjs";
8
+ import { CHATGPT_COMPOSER_LABELS } from "../extensions/oracle/worker/chatgpt-ui-helpers.mjs";
9
+
10
+ const bootstrap = await readFile(new URL("../extensions/oracle/worker/auth-bootstrap.mjs", import.meta.url), "utf8");
11
+ const worker = await readFile(new URL("../extensions/oracle/worker/run-job.mjs", import.meta.url), "utf8");
12
+ function functionSource(source, name) {
13
+ const start = source.indexOf(`function ${name}(`);
14
+ assert.notEqual(start, -1);
15
+ const end = source.indexOf("\n}", start) + 2;
16
+ return `${source.slice(start - 6, start) === "async " ? "async " : ""}${source.slice(start, end)}`;
17
+ }
18
+ const readySnapshot = '- textbox "Chat with ChatGPT" [ref=e1]\n- button "Add files and more" [ref=e2]';
19
+ const readyProbe = { ok: true, status: 200, bodyHasId: true, bodyHasEmail: true };
20
+ const classify = (observation) => classifyChatAuthPage({ ...observation,
21
+ allowedOrigins: ["https://chatgpt.com"], cookieSourceLabel: "Chrome profile Default",
22
+ runtimeProfileDir: "/fixture/seed", logPath: "/fixture/auth.log" });
23
+ const navigationError = () => new Error("✗ CDP error (Runtime.evaluate): Inspected target navigated or closed");
24
+
25
+ function pollingHarness(overrides = {}) {
26
+ const logs = [];
27
+ let captures = 0;
28
+ const context = {
29
+ Date, Promise, Error, isAuthNavigationError, CHATGPT_COMPOSER_LABELS,
30
+ config: { auth: { bootstrapTimeoutMs: 2000, pollMs: 1 } },
31
+ getUrl: async () => "https://chatgpt.com/", snapshotText: async () => readySnapshot,
32
+ pageText: async () => "", loginProbe: async () => readyProbe,
33
+ writeFile: async () => {}, URL_PATH: "url", SNAPSHOT_PATH: "snapshot", BODY_PATH: "body", LOG_PATH: "log",
34
+ classifyChatPage: classify, log: async (line) => { logs.push(line); },
35
+ ensureBrowserConnected: async () => {},
36
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
37
+ preferredProvider: () => "chatgpt", providerName: () => "ChatGPT",
38
+ CHATGPT_LABELS: { composer: "Chat with ChatGPT", addFiles: "Add files and more" },
39
+ captureDiagnostics: async () => { captures++; }, authConfigRemediation: () => "Refresh source login.",
40
+ // Any reload/click on an anonymous login page is a regression.
41
+ openUrl: async () => assert.fail("unexpected auth navigation"),
42
+ targetCommand: async () => assert.fail("unexpected reload"),
43
+ ...overrides,
44
+ };
45
+ const poll = vm.runInNewContext(`(${functionSource(bootstrap, "waitForImportedAuthReady")})`, context);
46
+ return { poll, logs, get captures() { return captures; } };
47
+ }
48
+
49
+ test("auth polling retries a complete observation after each navigation-interrupted read", async () => {
50
+ for (const read of ["getUrl", "snapshotText", "pageText", "loginProbe"]) {
51
+ let calls = 0;
52
+ const good = { getUrl: "https://chatgpt.com/", snapshotText: readySnapshot, pageText: "", loginProbe: readyProbe };
53
+ const h = pollingHarness({ [read]: async () => { if (++calls === 1) throw navigationError(); return good[read]; } });
54
+ assert.equal((await h.poll()).state, "authenticated_and_ready");
55
+ assert.equal(calls, 2);
56
+ assert(h.logs.some((line) => line.includes("discarding partial reads")));
57
+ }
58
+ });
59
+
60
+ test("auth polling drains concurrent commands before surfacing a fatal failure", async () => {
61
+ let drained = false;
62
+ const failure = new Error("CDP permission denied");
63
+ const h = pollingHarness({ loginProbe: async () => { throw failure; }, pageText: async () => {
64
+ await new Promise((resolve) => setTimeout(resolve, 10)); drained = true; return "";
65
+ } });
66
+ await assert.rejects(h.poll(), (error) => error === failure && drained);
67
+ });
68
+
69
+ test("fatal failures take precedence over concurrent navigation failures", async () => {
70
+ const h = pollingHarness({ loginProbe: async () => { throw navigationError(); }, getUrl: async () => { throw new Error("Browser disconnected"); } });
71
+ await assert.rejects(h.poll(), /Browser disconnected/);
72
+ });
73
+
74
+ test("closed browsers are not relaunched and persistent navigation respects the deadline", async () => {
75
+ const closed = pollingHarness({ loginProbe: async () => { throw navigationError(); },
76
+ ensureBrowserConnected: async () => { throw new Error("The isolated oracle browser was closed"); } });
77
+ await assert.rejects(closed.poll(), /browser was closed/);
78
+ const navigating = pollingHarness({ loginProbe: async () => { throw navigationError(); },
79
+ config: { auth: { bootstrapTimeoutMs: 20, pollMs: 1 } } });
80
+ await assert.rejects(navigating.poll(), /Timed out verifying/);
81
+ assert.equal(navigating.captures, 1);
82
+ });
83
+
84
+ test("only known navigation/context destruction errors are retryable", () => {
85
+ for (const message of ["Execution context was destroyed, most likely because of a navigation", "Cannot find context with specified id", navigationError().message]) {
86
+ assert(isAuthNavigationError(new Error(message)));
87
+ }
88
+ for (const message of ["Target closed", "Browser disconnected", "CDP permission denied", "command timed out"]) {
89
+ assert.equal(isAuthNavigationError(new Error(message)), false);
90
+ }
91
+ });
92
+
93
+ for (const [name, source] of [["bootstrap", bootstrap], ["job worker", worker]]) {
94
+ test(`${name} probe distinguishes anonymous ua-* IDs from account IDs`, async () => {
95
+ const build = vm.runInNewContext(`(${functionSource(source, "buildLoginProbeScript")})`, {
96
+ toAsyncJsonScript: (body) => `(async () => { ${body} })()`,
97
+ });
98
+ for (const id of ["ua-visitor", "user-signed-in"]) {
99
+ const probe = normalizeLoginProbeResult(await vm.runInNewContext(build(50), {
100
+ location: { href: "https://chatgpt.com/auth/login", hostname: "chatgpt.com", pathname: "/auth/login" },
101
+ document: { querySelectorAll: () => [] }, AbortController, setTimeout, clearTimeout,
102
+ fetch: async () => ({ status: 200, headers: { get: () => "application/json" },
103
+ clone: () => ({ json: async () => ({ id, email: "", name: "" }) }) }),
104
+ }));
105
+ assert.equal(probe.bodyIsAnonymous, id.startsWith("ua-"));
106
+ const result = classify({ url: "https://chatgpt.com/auth/login", snapshot: "", body: "Log in or sign up", probe });
107
+ assert.equal(result.state, id.startsWith("ua-") ? "login_required" : "auth_transitioning");
108
+ if (id.startsWith("ua-")) {
109
+ const h = pollingHarness({ loginProbe: async () => probe });
110
+ await assert.rejects(h.poll(), /anonymous visitor session/);
111
+ }
112
+ }
113
+ });
114
+ }
115
+
116
+ test("auth recognizes current and legacy ready composers without trusting anonymous identity", () => {
117
+ // Cookie-only /me probes may run before hydration or without the app's bearer token.
118
+ const probe = { ...readyProbe, bodyIsAnonymous: true, bodyHasEmail: false };
119
+ const observe = (snapshot, overrides = {}) => classify({ url: "https://chatgpt.com/", snapshot, body: "", probe: { ...probe, ...overrides } }).state;
120
+ assert.equal(observe(""), "unknown");
121
+ for (const label of ["Chat with ChatGPT", "Ask ChatGPT"]) {
122
+ const snapshot = `- button "Open profile menu" [ref=e1]\n- button "Add files and more" [ref=e2]\n- textbox "${label}" [ref=e3]\n- button "Select ChatGPT model" [ref=e4]`;
123
+ assert.equal(observe(snapshot), "authenticated_and_ready", `${label} should finish auth verification`);
124
+ assert.equal(observe(snapshot, { domLoginCta: true }), "login_required");
125
+ assert.equal(observe(snapshot.replace('[ref=e3]', '[disabled, ref=e3]')), "unknown");
126
+ assert.equal(observe(snapshot.replace('- button "Add files and more" [ref=e2]', '')), "unknown");
127
+ }
128
+ assert.equal(observe('- textbox "Search" [ref=e1]\n- button "Add files and more" [ref=e2]'), "unknown");
129
+ });
@@ -0,0 +1,83 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import vm from "node:vm";
4
+ import test from "node:test";
5
+ import * as ui from "../extensions/oracle/worker/chatgpt-ui-helpers.mjs";
6
+ import { parseSnapshotEntries } from "../extensions/oracle/worker/artifact-heuristics.mjs";
7
+
8
+ const worker = await readFile(new URL("../extensions/oracle/worker/run-job.mjs", import.meta.url), "utf8");
9
+ const names = ["findEntry", "matchesModelConfigurationOpener", "canUseOpenModelMenuForSelection",
10
+ "openModelConfiguration", "configurePowerSlider", "configureModel", "waitForModelConfigurationToSettle", "toJsonScript"];
11
+ const functions = names.flatMap((name) => {
12
+ const start = worker.indexOf(`function ${name}(`);
13
+ if (start < 0) return []; // Allows the same regression to run against the pre-slider worker.
14
+ const end = worker.indexOf("\n}", start) + 2;
15
+ return `${worker.slice(start - 6, start) === "async " ? "async " : ""}${worker.slice(start, end)}`;
16
+ }).join("\n");
17
+
18
+ function harness({ initial = 3, max = "4", wrongLabel = false, stuck = false, mountingReads = 0 } = {}) {
19
+ const labels = ["Instant", "Medium", "High", "Extra High", "Pro"];
20
+ let value = initial;
21
+ let open = false;
22
+ let verified = false;
23
+ const snapshot = () => open
24
+ ? '- button "Thinking effort" [expanded=true, ref=e1]\n- menu "Thinking effort" [ref=e2]\n- menuitem "Select model" [ref=e3]\n- menuitem "Power" [ref=e4]'
25
+ : `- button "${value === 4 ? "6 Pro" : labels[value]}" [expanded=false, ref=e1]\n- textbox "Ask ChatGPT" [ref=e5]\n- button "Add files and more" [ref=e6]`;
26
+ const control = {
27
+ closest: () => null,
28
+ getAttribute: () => "description",
29
+ querySelector: () => mountingReads-- > 0 ? null : ({ getAttribute: (name) => ({ "aria-valuemin": "0", "aria-valuemax": max, "aria-valuenow": String(value) })[name] }),
30
+ };
31
+ const context = vm.createContext({ ...ui, parseSnapshotEntries, Date, Error, JSON, Number,
32
+ CHATGPT_LABELS: { configure: "Configure..." },
33
+ MODEL_CONFIGURATION_OPEN_TIMEOUT_MS: 30, MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS: 30,
34
+ MODEL_CONFIGURATION_CLOSE_RETRY_MS: 1, MODEL_CONFIGURATION_SETTLE_POLL_MS: 0,
35
+ isGrokJob: () => false, snapshotText: async () => snapshot(),
36
+ throwIfProviderTransientError: () => {}, dismissProFeedbackModal: async () => false,
37
+ clickRef: async () => { open = !open; }, sleep: async () => {},
38
+ composerControlsVisible: ui.snapshotHasUsableComposerControls,
39
+ log: async (line) => { if (line.startsWith("Verified ChatGPT Power tier")) verified = true; },
40
+ agentBrowser: async (_job, command, arg) => {
41
+ if (command === "focus") { assert(open); return; }
42
+ if (command !== "press") return;
43
+ if (arg === "Escape") open = false;
44
+ else if (!stuck) value = Math.max(0, Math.min(4, value + (arg === "ArrowRight" ? 1 : -1)));
45
+ },
46
+ evalPage: async (_job, script) => JSON.parse(vm.runInNewContext(script, { document: {
47
+ querySelector: () => open ? control : null,
48
+ getElementById: () => ({ textContent: `${wrongLabel ? "Unknown" : labels[value]}, ${value + 1} of 5.` }),
49
+ } })),
50
+ });
51
+ vm.runInContext(functions, context);
52
+ return { configure: (selection) => context.configureModel({ selection }),
53
+ get state() { return { value, open, verified }; } };
54
+ }
55
+
56
+ test("worker configures every preset through the current Power picker and closes it", async () => {
57
+ for (const [selection, expected] of [
58
+ [{ modelFamily: "instant", autoSwitchToThinking: false }, 0],
59
+ [{ modelFamily: "instant", autoSwitchToThinking: true }, 0],
60
+ [{ modelFamily: "thinking", effort: "light" }, 1],
61
+ [{ modelFamily: "thinking", effort: "standard" }, 1],
62
+ [{ modelFamily: "thinking", effort: "extended" }, 2],
63
+ [{ modelFamily: "thinking", effort: "heavy" }, 3],
64
+ [{ modelFamily: "pro", effort: "standard" }, 4],
65
+ [{ modelFamily: "pro", effort: "extended" }, 4],
66
+ ]) {
67
+ const h = harness({ initial: expected === 4 ? 0 : 4, mountingReads: 2 });
68
+ await h.configure(selection);
69
+ assert.deepEqual(h.state, { value: expected, open: false, verified: true });
70
+ }
71
+ });
72
+
73
+ test("worker refuses unknown, mislabelled, or unresponsive Power controls", async () => {
74
+ for (const [options, error] of [
75
+ [{ max: "5" }, /unrecognized range/],
76
+ [{ wrongLabel: true }, /Could not verify ChatGPT Power tier/],
77
+ [{ stuck: true }, /did not reach requested tier/],
78
+ ]) {
79
+ const h = harness(options);
80
+ await assert.rejects(h.configure({ modelFamily: "instant", autoSwitchToThinking: false }), error);
81
+ assert.equal(h.state.verified, false);
82
+ }
83
+ });
@@ -11,7 +11,8 @@ import { basename, dirname, join, resolve } from "node:path";
11
11
  const DEFAULT_PROVIDER = "zai";
12
12
  const DEFAULT_MODEL = "glm-5.2";
13
13
  const DEFAULT_TIMEOUT_MS = 180_000;
14
- const EXPECTED_PI_VERSION = "0.80.9";
14
+ const EXPECTED_PI_VERSION = process.env.PI_COMPAT_EXPECTED_VERSION
15
+ ?? JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).devDependencies["@earendil-works/pi-coding-agent"];
15
16
  const PACKAGE_NAME = "pi-oracle";
16
17
 
17
18
  function usage() {
@@ -22,6 +23,8 @@ Modes:
22
23
  source Inner-loop/debug only. Loads this checkout with pi --no-extensions -e extensions/oracle/index.ts.
23
24
 
24
25
  Environment:
26
+ PI_HOST_CLI selected Pi manifest bin entry, invoked with this Node executable (default: pi on PATH)
27
+ PI_COMPAT_EXPECTED_VERSION expected Pi version (default: package development baseline)
25
28
  PI_ORACLE_REAL_TEST_PROVIDER pi provider for the test agent (default: ${DEFAULT_PROVIDER})
26
29
  PI_ORACLE_REAL_TEST_MODEL pi model for the test agent (default: ${DEFAULT_MODEL})
27
30
  PI_ORACLE_REAL_TEST_TIMEOUT_MS per-agent timeout in ms (default: ${DEFAULT_TIMEOUT_MS})
@@ -65,7 +68,10 @@ function positiveTimeoutMs(value, label) {
65
68
 
66
69
  function commandExists(command, args = ["--version"]) {
67
70
  return new Promise((resolvePromise) => {
68
- const child = spawn(command, args, { stdio: "ignore", shell: process.platform === "win32" });
71
+ const selectedCli = command === piCommand() && env("PI_HOST_CLI");
72
+ const child = spawn(selectedCli ? process.execPath : command, selectedCli ? [selectedCli, ...args] : args, {
73
+ stdio: "ignore", shell: !selectedCli && process.platform === "win32",
74
+ });
69
75
  child.on("error", () => resolvePromise(false));
70
76
  child.on("exit", (code) => resolvePromise(code === 0 || code === 1));
71
77
  });
@@ -104,7 +110,7 @@ async function doctor() {
104
110
  const failures = [];
105
111
  const warnings = [];
106
112
  const requiredCommands = [
107
- [process.platform === "win32" ? "pi.cmd" : "pi", ["--version"], "pi CLI"],
113
+ [piCommand(), ["--version"], "pi CLI"],
108
114
  [process.platform === "win32" ? "tar.exe" : "tar", ["--version"], "tar"],
109
115
  ["zstd", ["--version"], "zstd"],
110
116
  [process.platform === "win32" ? "agent-browser.cmd" : "agent-browser", ["--version"], "agent-browser"],
@@ -134,10 +140,11 @@ async function doctor() {
134
140
  function runCommand(command, args, options) {
135
141
  const timeoutMs = positiveTimeoutMs(options.timeoutMs, "command timeout");
136
142
  return new Promise((resolvePromise) => {
137
- const child = spawn(command, args, {
143
+ const selectedCli = command === piCommand() && env("PI_HOST_CLI");
144
+ const child = spawn(selectedCli ? process.execPath : command, selectedCli ? [selectedCli, ...args] : args, {
138
145
  cwd: options.cwd,
139
146
  env: options.env,
140
- shell: process.platform === "win32",
147
+ shell: !selectedCli && process.platform === "win32",
141
148
  stdio: ["ignore", "pipe", "pipe"],
142
149
  });
143
150
  let stdout = "";
@@ -234,7 +241,7 @@ function entryExists(entries, path) {
234
241
  }
235
242
 
236
243
  function piCommand() {
237
- return process.platform === "win32" ? "pi.cmd" : "pi";
244
+ return env("PI_HOST_CLI") ?? (process.platform === "win32" ? "pi.cmd" : "pi");
238
245
  }
239
246
 
240
247
  function npmCommand() {