pi-oracle 0.3.2 → 0.3.4

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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.4 - 2026-04-11
4
+
5
+ ### Changed
6
+ - oracle archive defaults now exclude nested `secrets/` and `.secrets/` directories anywhere in the repo unless they are explicitly requested
7
+ - package metadata now reflects the current runtime floor and platform support (`node >=22`, `darwin`) and local release automation runs `verify:oracle` through `npm test` / `prepublishOnly`
8
+
9
+ ### Fixed
10
+ - model verification now distinguishes `thinking`, `pro`, `instant`, and `instant_auto_switch` more conservatively instead of accepting mismatched presets on partial UI evidence
11
+ - artifact-only assistant responses can now complete without timing out on missing plain-text bodies
12
+ - `/oracle-auth` diagnostics now write into a unique private temp directory per run instead of fixed `/tmp/oracle-auth.*` paths
13
+ - sanity coverage now exercises shared ChatGPT UI helpers directly, verifies nested secret exclusion, and guards the new auth diagnostics path handling
14
+
15
+ ## 0.3.3 - 2026-04-11
16
+
17
+ ### Added
18
+ - `oracle_submit` now accepts canonical preset ids plus matching human-readable preset labels/common hyphen-space variants and normalizes them back to the canonical preset id at submit time
19
+ - lock sweeping now gives in-flight `.tmp-*` lock/state directories a dedicated grace window so concurrent sweep does not delete another process's atomic publish
20
+
21
+ ### Changed
22
+ - oracle submit metadata/docs now point preset discovery at the canonical registry/README while keeping execute-time normalization for flexible caller input
23
+
24
+ ### Fixed
25
+ - closed a concurrent stale-lock sweep race that could reclaim another process's in-flight lock publish before metadata landed
26
+ - oracle sanity coverage now verifies preset alias validation/normalization and the `.tmp-*` grace window behavior end-to-end
27
+
3
28
  ## 0.3.2 - 2026-04-08
4
29
 
5
30
  ### Changed
package/README.md CHANGED
@@ -125,6 +125,8 @@ Notes:
125
125
  | `instant` | Instant |
126
126
  | `instant_auto_switch` | Instant - Auto-switch to Thinking Enabled |
127
127
 
128
+ `oracle_submit` accepts either the canonical preset id or the matching human-readable preset label; common space/hyphen variants are normalized automatically at submit time. Keep `defaults.preset` in config on the canonical preset id.
129
+
128
130
  Other useful settings:
129
131
  - `browser.runMode`
130
132
  - `browser.args`
@@ -145,6 +147,7 @@ Project config should only override safe, non-privileged settings.
145
147
  ## Requirements
146
148
 
147
149
  - macOS
150
+ - Node.js 22 or newer
148
151
  - Google Chrome installed
149
152
  - ChatGPT already signed into a local Chrome profile
150
153
  - `pi` 0.65.0 or newer
@@ -209,10 +212,14 @@ npm run check:oracle-extension
209
212
  npm run typecheck
210
213
  npm run sanity:oracle
211
214
  npm run pack:check
215
+ # conventional local gate
216
+ npm test
212
217
  # or all at once
213
218
  npm run verify:oracle
214
219
  ```
215
220
 
221
+ `npm publish` is also guarded locally via `prepublishOnly` and will run `npm run verify:oracle` before publishing.
222
+
216
223
  ## Beta caveats
217
224
 
218
225
  The highest-risk areas to monitor are:
@@ -133,7 +133,7 @@ The authenticated seed profile remains the source of truth for future oracle run
133
133
 
134
134
  ### `oracle_submit`
135
135
 
136
- Agent-facing submissions use **`preset`**; the canonical registry is `ORACLE_SUBMIT_PRESETS` in `extensions/oracle/lib/config.ts`. **`preset` is the only model-selection parameter** on `oracle_submit`. There are no `modelFamily`, `effort`, or `autoSwitchToThinking` fields.
136
+ Agent-facing submissions use **`preset`**; the canonical registry is `ORACLE_SUBMIT_PRESETS` in `extensions/oracle/lib/config.ts`. **`preset` is the only model-selection parameter** on `oracle_submit`. There are no `modelFamily`, `effort`, or `autoSwitchToThinking` fields. Submit-time inputs accept canonical preset ids plus matching human-readable labels/common hyphen-space variants, and the tool normalizes them back to the canonical id before persisting job state.
137
137
 
138
138
  1. resolve the preset (submit-time or config default) into an execution snapshot
139
139
  2. resolve optional `followUpJobId` into a prior `chatUrl` and `conversationId`
@@ -585,7 +585,7 @@ Remaining non-blocking hardening work:
585
585
 
586
586
  Recent proof points:
587
587
  - expired-auth drill fail path: `a2460bc1-7d89-4041-b67d-39680d310325`
588
- - `/oracle-auth` repair evidence: `/tmp/oracle-auth.log`
588
+ - `/oracle-auth` repair evidence: the per-run `/tmp/pi-oracle-auth-*/oracle-auth.log` bundle path printed by `/oracle-auth`
589
589
  - expired-auth drill post-repair success: `fa26a2a7-0057-4a21-b3e0-71c1d020facf`
590
590
  - successful multi-artifact completion: `b6b3599c-6b91-4315-adfa-8a83aa5eda9b`
591
591
  - repo-owned sanity harness: `npm run sanity:oracle`
@@ -105,10 +105,11 @@ For the failed run:
105
105
  - any failure diagnostics under that job dir
106
106
 
107
107
  For the repair:
108
- - `/tmp/oracle-auth.log`
109
- - `/tmp/oracle-auth.url.txt`
110
- - `/tmp/oracle-auth.snapshot.txt`
111
- - `/tmp/oracle-auth.body.txt`
108
+ - the per-run `/tmp/pi-oracle-auth-*/` diagnostics directory printed by `/oracle-auth`
109
+ - `oracle-auth.log`
110
+ - `oracle-auth.url.txt`
111
+ - `oracle-auth.snapshot.txt`
112
+ - `oracle-auth.body.txt`
112
113
 
113
114
  For the successful rerun:
114
115
  - `/tmp/oracle-<job-id>/job.json`
@@ -29,6 +29,117 @@ export type OracleSubmitPresetId = keyof typeof ORACLE_SUBMIT_PRESETS;
29
29
 
30
30
  export type OracleSubmitPreset = typeof ORACLE_SUBMIT_PRESETS[OracleSubmitPresetId];
31
31
 
32
+ export const ORACLE_SUBMIT_PRESET_IDS = Object.freeze(Object.keys(ORACLE_SUBMIT_PRESETS) as OracleSubmitPresetId[]);
33
+
34
+ function normalizeOracleSubmitPresetLookupKey(value: string): string {
35
+ return value
36
+ .trim()
37
+ .toLowerCase()
38
+ .replace(/[_-]+/g, " ")
39
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
40
+ .replace(/\s+/g, " ");
41
+ }
42
+
43
+ function splitOracleSubmitPresetWords(value: string): string[] {
44
+ return value
45
+ .trim()
46
+ .replace(/[_-]+/g, " ")
47
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
48
+ .split(/\s+/)
49
+ .filter(Boolean);
50
+ }
51
+
52
+ function lowercaseWords(words: readonly string[]): string[] {
53
+ return words.map((word) => word.toLowerCase());
54
+ }
55
+
56
+ function titleCaseWords(words: readonly string[]): string[] {
57
+ return words.map((word) => (word ? `${word[0]?.toUpperCase() ?? ""}${word.slice(1)}` : word));
58
+ }
59
+
60
+ function buildOracleSubmitPresetSeparatorVariants(words: readonly string[]): string[] {
61
+ const normalizedWords = words.map((word) => word.trim()).filter(Boolean);
62
+ if (normalizedWords.length === 0) return [];
63
+
64
+ const variants = new Set<string>();
65
+ const build = (index: number, current: string): void => {
66
+ if (index >= normalizedWords.length) {
67
+ variants.add(current);
68
+ return;
69
+ }
70
+ for (const separator of [" ", "-"] as const) {
71
+ build(index + 1, `${current}${separator}${normalizedWords[index]}`);
72
+ }
73
+ };
74
+
75
+ build(1, normalizedWords[0]!);
76
+ return [...variants];
77
+ }
78
+
79
+ function buildOracleSubmitPresetJoinVariants(words: readonly string[]): string[] {
80
+ const normalizedWords = words.map((word) => word.trim()).filter(Boolean);
81
+ if (normalizedWords.length === 0) return [];
82
+
83
+ const lowercase = lowercaseWords(normalizedWords);
84
+ const titleWords = titleCaseWords(lowercase);
85
+ return [
86
+ ...buildOracleSubmitPresetSeparatorVariants(normalizedWords),
87
+ ...buildOracleSubmitPresetSeparatorVariants(lowercase),
88
+ ...buildOracleSubmitPresetSeparatorVariants(titleWords),
89
+ ];
90
+ }
91
+
92
+ function buildOracleSubmitPresetAliases(id: OracleSubmitPresetId, preset: OracleSubmitPreset): string[] {
93
+ const idWords = splitOracleSubmitPresetWords(id);
94
+ const labelWords = splitOracleSubmitPresetWords(preset.label);
95
+ return [
96
+ id,
97
+ ...buildOracleSubmitPresetJoinVariants(idWords),
98
+ preset.label,
99
+ preset.label.toLowerCase(),
100
+ ...buildOracleSubmitPresetJoinVariants(labelWords),
101
+ ].filter(Boolean);
102
+ }
103
+
104
+ function buildOracleSubmitPresetLookupArtifacts(): {
105
+ acceptedInputs: readonly string[];
106
+ lookup: ReadonlyMap<string, OracleSubmitPresetId>;
107
+ } {
108
+ const lookup = new Map<string, OracleSubmitPresetId>();
109
+ const aliases = new Set<string>();
110
+
111
+ for (const [id, preset] of Object.entries(ORACLE_SUBMIT_PRESETS) as [OracleSubmitPresetId, OracleSubmitPreset][]) {
112
+ for (const alias of buildOracleSubmitPresetAliases(id, preset)) {
113
+ const normalized = normalizeOracleSubmitPresetLookupKey(alias);
114
+ if (!normalized) continue;
115
+ const existing = lookup.get(normalized);
116
+ if (existing && existing !== id) {
117
+ throw new Error(`Conflicting oracle_submit preset alias: ${alias} matches both ${existing} and ${id}`);
118
+ }
119
+ lookup.set(normalized, id);
120
+ if (alias !== id) aliases.add(alias);
121
+ }
122
+ }
123
+
124
+ return {
125
+ acceptedInputs: Object.freeze([...ORACLE_SUBMIT_PRESET_IDS, ...[...aliases].sort((left, right) => left.localeCompare(right))]),
126
+ lookup,
127
+ };
128
+ }
129
+
130
+ const ORACLE_SUBMIT_PRESET_LOOKUP_ARTIFACTS = buildOracleSubmitPresetLookupArtifacts();
131
+
132
+ export const ORACLE_SUBMIT_PRESET_ACCEPTED_INPUTS = ORACLE_SUBMIT_PRESET_LOOKUP_ARTIFACTS.acceptedInputs;
133
+
134
+ export function coerceOracleSubmitPresetId(value: string): OracleSubmitPresetId {
135
+ const normalized = normalizeOracleSubmitPresetLookupKey(value);
136
+ const presetId = ORACLE_SUBMIT_PRESET_LOOKUP_ARTIFACTS.lookup.get(normalized);
137
+ if (presetId) return presetId;
138
+ throw new Error(
139
+ `Unknown oracle_submit preset: ${value}. Use one of the canonical ids (${ORACLE_SUBMIT_PRESET_IDS.join(", ")}) or a matching preset label.`,
140
+ );
141
+ }
142
+
32
143
  export function getOracleSubmitPresetById(id: OracleSubmitPresetId): OracleSubmitPreset {
33
144
  const found = ORACLE_SUBMIT_PRESETS[id];
34
145
  if (!found) {
@@ -336,7 +447,7 @@ function normalizeLegacyBrowserConfig(root: Record<string, unknown>): Record<str
336
447
  return root;
337
448
  }
338
449
 
339
- const PRESET_IDS = Object.keys(ORACLE_SUBMIT_PRESETS) as unknown as readonly OracleSubmitPresetId[];
450
+ const PRESET_IDS = ORACLE_SUBMIT_PRESET_IDS;
340
451
 
341
452
  function validateOracleConfig(value: unknown): OracleConfig {
342
453
  const root = normalizeLegacyBrowserConfig(expectObject(value, "root"));
@@ -12,6 +12,8 @@ const LEASES_DIR = join(ORACLE_STATE_DIR, "leases");
12
12
  const DEFAULT_WAIT_MS = 30_000;
13
13
  const POLL_MS = 200;
14
14
  export const ORACLE_METADATA_WRITE_GRACE_MS = 1_000;
15
+ /** Incomplete `.tmp-*` dirs are in-flight atomic creates; a 1s grace is too short under multi-process sweep + slow FS. */
16
+ export const ORACLE_TMP_STATE_DIR_GRACE_MS = 60_000;
15
17
 
16
18
  export interface OracleLockHandle {
17
19
  path: string;
@@ -90,7 +92,8 @@ function isIncompleteStateDirStale(path: string, now = Date.now()): boolean {
90
92
  try {
91
93
  const stats = statSync(path);
92
94
  const baselineMs = Math.max(stats.mtimeMs, stats.ctimeMs);
93
- return now - baselineMs >= ORACLE_METADATA_WRITE_GRACE_MS;
95
+ const graceMs = basename(path).startsWith(".tmp-") ? ORACLE_TMP_STATE_DIR_GRACE_MS : ORACLE_METADATA_WRITE_GRACE_MS;
96
+ return now - baselineMs >= graceMs;
94
97
  } catch {
95
98
  return false;
96
99
  }
@@ -138,13 +141,13 @@ async function maybeReclaimStaleLock(path: string, now = Date.now()): Promise<bo
138
141
  return true;
139
142
  }
140
143
 
141
- export async function sweepStaleLocks(): Promise<string[]> {
144
+ export async function sweepStaleLocks(now = Date.now()): Promise<string[]> {
142
145
  const dir = getLocksDir();
143
146
  const removed: string[] = [];
144
147
 
145
148
  for (const name of readdirSync(dir)) {
146
149
  const path = join(dir, name);
147
- if (await maybeReclaimStaleLock(path)) {
150
+ if (await maybeReclaimStaleLock(path, now)) {
148
151
  removed.push(path);
149
152
  }
150
153
  }
@@ -1,3 +1,8 @@
1
+ // Purpose: Register oracle extension tools and implement submit/read/cancel behavior.
2
+ // Responsibilities: Validate tool parameters, create archives, enqueue or dispatch jobs, and surface job state.
3
+ // Scope: Tool-facing orchestration only; durable job storage, locks, runtime leases, and config live in sibling modules.
4
+ // Usage: Imported by the oracle extension entrypoint and sanity tests to register tools against the pi API.
5
+ // Invariants/Assumptions: The pi runtime validates TypeBox schemas before execute, while execute owns semantic normalization.
1
6
  import { randomUUID } from "node:crypto";
2
7
  import { lstat, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
8
  import { tmpdir } from "node:os";
@@ -6,10 +11,9 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
11
  import { Type } from "@sinclair/typebox";
7
12
  import { isLockTimeoutError, withGlobalReconcileLock, withLock } from "./locks.js";
8
13
  import {
14
+ coerceOracleSubmitPresetId,
9
15
  loadOracleConfig,
10
- ORACLE_SUBMIT_PRESETS,
11
16
  resolveOracleSubmitPreset,
12
- type OracleSubmitPresetId,
13
17
  } from "./config.js";
14
18
  import {
15
19
  appendCleanupWarnings,
@@ -48,10 +52,6 @@ import {
48
52
  tryAcquireRuntimeLease,
49
53
  } from "./runtime.js";
50
54
 
51
- function stringEnum(values: readonly string[], description: string) {
52
- return Type.Union(values.map((value) => Type.Literal(value)), { description });
53
- }
54
-
55
55
  const ORACLE_SUBMIT_PARAMS = Type.Object({
56
56
  prompt: Type.String({ description: "Prompt text to send to ChatGPT web." }),
57
57
  files: Type.Array(Type.String({ description: "Project-relative file or directory path to include in the archive." }), {
@@ -59,10 +59,10 @@ const ORACLE_SUBMIT_PARAMS = Type.Object({
59
59
  minItems: 1,
60
60
  }),
61
61
  preset: Type.Optional(
62
- stringEnum(
63
- [...Object.keys(ORACLE_SUBMIT_PRESETS)] as const,
64
- "ChatGPT model preset. Omit to use the configured default preset.",
65
- ),
62
+ Type.String({
63
+ description:
64
+ "ChatGPT model preset. Omit to use the configured default preset. Canonical ids are preferred; matching human-readable preset labels and common hyphen/space variants are normalized automatically.",
65
+ }),
66
66
  ),
67
67
  followUpJobId: Type.Optional(Type.String({ description: "Earlier oracle job id whose chat thread should be continued." })),
68
68
  });
@@ -107,8 +107,10 @@ const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
107
107
  ".pnpm-store",
108
108
  ".serverless",
109
109
  ".aws-sam",
110
+ "secrets",
111
+ ".secrets",
110
112
  ]);
111
- const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT = new Set(["coverage", "htmlcov", "tmp", "temp", ".tmp", "dist", "build", "out", "secrets", ".secrets"]);
113
+ const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT = new Set(["coverage", "htmlcov", "tmp", "temp", ".tmp", "dist", "build", "out"]);
112
114
  const DEFAULT_ARCHIVE_EXCLUDED_FILES = new Set([
113
115
  ".coverage",
114
116
  ".DS_Store",
@@ -579,11 +581,11 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
579
581
  label: "Oracle Submit",
580
582
  description:
581
583
  "Dispatch a background ChatGPT web oracle job after gathering context. Always pass a prompt and exact project-relative archive inputs. " +
582
- "Optional ChatGPT model: set parameter `preset`, or omit it for configured defaults (see `preset` field for allowed ids).",
584
+ "Optional ChatGPT model: set parameter `preset`, or omit it for configured defaults; canonical preset ids are listed in the README and ORACLE_SUBMIT_PRESETS registry, and matching labels are normalized at submit time.",
583
585
  promptSnippet: "Dispatch a background ChatGPT web oracle job after gathering repo context.",
584
586
  promptGuidelines: [
585
587
  "Gather context before calling oracle_submit.",
586
- "By default, archive the whole repo 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 root secrets directories.",
588
+ "By default, archive the whole repo 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.",
587
589
  "Only narrow file selection when the user explicitly asks, the task is clearly scoped smaller, or privacy/sensitivity requires it.",
588
590
  "For very targeted asks like a single function or stack trace, a smaller archive is preferable.",
589
591
  "When files='.' and the post-exclusion archive is still too large, submit automatically prunes the largest nested directories matching generic generated-output names like build/, dist/, out/, coverage/, and tmp/ outside obvious source roots like src/ and lib/ until the archive fits or no candidate remains; successful submissions report what was pruned.",
@@ -591,7 +593,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
591
593
  "If oracle_submit itself fails because the local archive still exceeds the upload limit after default exclusions and automatic generic generated-output-dir pruning, or for any other submit-time error, stop and report the error instead of retrying automatically.",
592
594
  "If oracle_submit returns a queued job instead of an immediately dispatched one, treat that as success and stop exactly the same way.",
593
595
  "Stop after dispatching oracle_submit; do not continue the task while the oracle job is running.",
594
- "Use `preset` as the only model-selection parameter on oracle_submit. Allowed values come from the tool schema enum. Omit preset to use the configured default.",
596
+ "Use `preset` as the only model-selection parameter on oracle_submit. Canonical ids are preferred, and matching human-readable preset labels are normalized automatically. Omit preset to use the configured default.",
595
597
  ],
596
598
  parameters: ORACLE_SUBMIT_PARAMS,
597
599
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -599,7 +601,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
599
601
  const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
600
602
  const projectId = getProjectId(ctx.cwd);
601
603
  const sessionId = getSessionId(originSessionFile, projectId);
602
- const presetId = (params.preset as OracleSubmitPresetId | undefined) ?? config.defaults.preset;
604
+ const presetId = typeof params.preset === "string" ? coerceOracleSubmitPresetId(params.preset) : config.defaults.preset;
603
605
  const selection = resolveOracleSubmitPreset(presetId);
604
606
  const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
605
607
  try {
@@ -1,11 +1,12 @@
1
1
  import { withLock } from "./state-locks.mjs";
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
- import { appendFile, chmod, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
5
- import { homedir } from "node:os";
4
+ import { appendFile, chmod, lstat, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { homedir, tmpdir } from "node:os";
6
6
  import { basename, dirname, join, resolve } from "node:path";
7
7
  import { getCookies } from "@steipete/sweet-cookie";
8
8
  import { ensureAccountCookie, filterImportableAuthCookies } from "./auth-cookie-policy.mjs";
9
+ import { buildAllowedChatGptOrigins } from "./chatgpt-ui-helpers.mjs";
9
10
 
10
11
  const rawConfig = process.argv[2];
11
12
  if (!rawConfig) {
@@ -27,11 +28,12 @@ const CHATGPT_COOKIE_ORIGINS = [
27
28
  "https://sentinel.openai.com",
28
29
  "https://ws.chatgpt.com",
29
30
  ];
30
- const LOG_PATH = "/tmp/oracle-auth.log";
31
- const URL_PATH = "/tmp/oracle-auth.url.txt";
32
- const SNAPSHOT_PATH = "/tmp/oracle-auth.snapshot.txt";
33
- const BODY_PATH = "/tmp/oracle-auth.body.txt";
34
- const SCREENSHOT_PATH = "/tmp/oracle-auth.png";
31
+ let DIAGNOSTICS_DIR;
32
+ let LOG_PATH = "(oracle-auth log path unavailable)";
33
+ let URL_PATH = "(oracle-auth url path unavailable)";
34
+ let SNAPSHOT_PATH = "(oracle-auth snapshot path unavailable)";
35
+ let BODY_PATH = "(oracle-auth body path unavailable)";
36
+ let SCREENSHOT_PATH = "(oracle-auth screenshot path unavailable)";
35
37
  const REAL_CHROME_USER_DATA_DIR = resolve(homedir(), "Library", "Application Support", "Google", "Chrome");
36
38
  const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
37
39
  const ORACLE_STATE_DIR = process.env.PI_ORACLE_STATE_DIR?.trim() || DEFAULT_ORACLE_STATE_DIR;
@@ -50,12 +52,25 @@ function sleep(ms) {
50
52
  return new Promise((resolve) => setTimeout(resolve, ms));
51
53
  }
52
54
 
55
+ async function initDiagnosticsBundle() {
56
+ if (DIAGNOSTICS_DIR) return;
57
+ DIAGNOSTICS_DIR = await mkdtemp(join(tmpdir(), "pi-oracle-auth-"));
58
+ await chmod(DIAGNOSTICS_DIR, 0o700).catch(() => undefined);
59
+ LOG_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.log");
60
+ URL_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.url.txt");
61
+ SNAPSHOT_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.snapshot.txt");
62
+ BODY_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.body.txt");
63
+ SCREENSHOT_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.png");
64
+ }
65
+
53
66
  async function initLog() {
67
+ await initDiagnosticsBundle();
54
68
  await writeFile(LOG_PATH, "", { mode: 0o600 });
55
69
  await chmod(LOG_PATH, 0o600).catch(() => undefined);
56
70
  }
57
71
 
58
72
  async function log(message) {
73
+ await initDiagnosticsBundle();
59
74
  const line = `[${new Date().toISOString()}] ${message}\n`;
60
75
  await appendFile(LOG_PATH, line, { encoding: "utf8", mode: 0o600 });
61
76
  await chmod(LOG_PATH, 0o600).catch(() => undefined);
@@ -566,7 +581,7 @@ async function captureDiagnostics(reason) {
566
581
 
567
582
  function classifyChatPage({ url, snapshot, body, probe }) {
568
583
  const text = `${snapshot}\n${body}`;
569
- const allowedOrigins = [new URL(config.browser.chatUrl).origin, new URL(config.browser.authUrl).origin, "https://auth.openai.com"];
584
+ const allowedOrigins = buildAllowedChatGptOrigins(config.browser.chatUrl, config.browser.authUrl);
570
585
 
571
586
  const challengePatterns = [
572
587
  /just a moment/i,
@@ -804,7 +819,7 @@ async function run() {
804
819
  await writeFile(join(profilePlan.targetDir, ".oracle-seed-generation"), `${generation}\n`, { encoding: "utf8", mode: 0o600 });
805
820
  committedProfile = true;
806
821
  process.stdout.write(
807
- `${classification.message} Synced ${appliedCount} cookies into ${profilePlan.targetDir}`,
822
+ `${classification.message} Synced ${appliedCount} cookies into ${profilePlan.targetDir}. Diagnostics: ${DIAGNOSTICS_DIR}`,
808
823
  );
809
824
  } catch (error) {
810
825
  shouldPreserveBrowser = Boolean(error && typeof error === "object" && error.preserveBrowser === true);
@@ -822,7 +837,7 @@ async function run() {
822
837
 
823
838
  run().catch((error) => {
824
839
  process.stderr.write(
825
- `${error instanceof Error ? error.message : String(error)}\nSee ${LOG_PATH} and diagnostics in /tmp/oracle-auth.*\nIf needed, ensure the configured real Chrome profile is already logged into ChatGPT and grant macOS Keychain access when prompted.`,
840
+ `${error instanceof Error ? error.message : String(error)}\nSee ${LOG_PATH} and diagnostics in ${DIAGNOSTICS_DIR || "(oracle-auth diagnostics dir unavailable)"}\nIf needed, ensure the configured real Chrome profile is already logged into ChatGPT and grant macOS Keychain access when prompted.`,
826
841
  );
827
842
  process.exit(1);
828
843
  });
@@ -0,0 +1,33 @@
1
+ export type OracleUiModelFamily = "instant" | "thinking" | "pro";
2
+ export type OracleUiEffort = "light" | "standard" | "extended" | "heavy";
3
+
4
+ export interface OracleUiSelection {
5
+ modelFamily: OracleUiModelFamily;
6
+ effort?: OracleUiEffort;
7
+ autoSwitchToThinking?: boolean;
8
+ }
9
+
10
+ export declare const CHATGPT_CANONICAL_APP_ORIGINS: readonly string[];
11
+
12
+ export declare function buildAllowedChatGptOrigins(chatUrl: string, authUrl?: string): string[];
13
+ export declare function matchesModelFamilyLabel(label: string | undefined, family: OracleUiModelFamily): boolean;
14
+ export declare function requestedEffortLabel(selection: OracleUiSelection): string | undefined;
15
+ export declare function effortSelectionVisible(snapshot: string, effortLabel: string | undefined): boolean;
16
+ export declare function thinkingChipVisible(snapshot: string): boolean;
17
+ export declare function snapshotHasModelConfigurationUi(snapshot: string): boolean;
18
+ export declare function autoSwitchToThinkingSelectionVisible(snapshot: string): boolean | undefined;
19
+ export declare function snapshotCanSafelySkipModelConfiguration(snapshot: string, selection: OracleUiSelection): boolean;
20
+ export declare function snapshotStronglyMatchesRequestedModel(snapshot: string, selection: OracleUiSelection): boolean;
21
+ export declare function snapshotWeaklyMatchesRequestedModel(snapshot: string, selection: OracleUiSelection): boolean;
22
+ export declare function buildAssistantCompletionSignature(args: {
23
+ responseText: string;
24
+ artifactLabels?: string[];
25
+ suspiciousArtifactLabels?: string[];
26
+ }): string | undefined;
27
+ export declare function deriveAssistantCompletionSignature(args: {
28
+ hasStopStreaming: boolean;
29
+ hasTargetCopyResponse: boolean;
30
+ responseText: string;
31
+ artifactLabels?: string[];
32
+ suspiciousArtifactLabels?: string[];
33
+ }): string | undefined;
@@ -0,0 +1,208 @@
1
+ import { parseSnapshotEntries } from "./artifact-heuristics.mjs";
2
+
3
+ export const CHATGPT_CANONICAL_APP_ORIGINS = Object.freeze([
4
+ "https://chatgpt.com",
5
+ "https://chat.openai.com",
6
+ ]);
7
+
8
+ const MODEL_FAMILY_PREFIX = {
9
+ instant: "Instant ",
10
+ thinking: "Thinking ",
11
+ pro: "Pro ",
12
+ };
13
+
14
+ const AUTO_SWITCH_LABEL = "Auto-switch to Thinking";
15
+
16
+ function originFromUrl(url) {
17
+ if (typeof url !== "string" || !url.trim()) return undefined;
18
+ try {
19
+ return new URL(url).origin;
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
24
+
25
+ function uniqueStrings(values) {
26
+ return [...new Set(values.filter((value) => typeof value === "string" && value))];
27
+ }
28
+
29
+ function titleCase(value) {
30
+ return value ? `${value[0].toUpperCase()}${value.slice(1)}` : value;
31
+ }
32
+
33
+ function normalizeText(value) {
34
+ return String(value || "").replace(/\s+/g, " ").trim();
35
+ }
36
+
37
+ export function buildAllowedChatGptOrigins(chatUrl, authUrl) {
38
+ return uniqueStrings([
39
+ ...CHATGPT_CANONICAL_APP_ORIGINS,
40
+ originFromUrl(chatUrl),
41
+ originFromUrl(authUrl),
42
+ "https://auth.openai.com",
43
+ ]);
44
+ }
45
+
46
+ export function matchesModelFamilyLabel(label, family) {
47
+ const normalized = String(label || "");
48
+ const prefix = MODEL_FAMILY_PREFIX[family];
49
+ const exact = prefix.trim();
50
+ return normalized === exact || normalized.startsWith(prefix) || normalized.startsWith(`${exact},`);
51
+ }
52
+
53
+ export function requestedEffortLabel(selection) {
54
+ return selection?.effort ? titleCase(selection.effort) : undefined;
55
+ }
56
+
57
+ export function effortSelectionVisible(snapshot, effortLabel) {
58
+ if (!effortLabel) return true;
59
+ const entries = parseSnapshotEntries(snapshot);
60
+ return entries.some((entry) => {
61
+ if (entry.disabled) return false;
62
+ if (entry.kind === "combobox" && entry.value === effortLabel) return true;
63
+ if (entry.kind !== "button") return false;
64
+ const label = String(entry.label || "").toLowerCase();
65
+ const normalizedEffort = effortLabel.toLowerCase();
66
+ return (
67
+ label === normalizedEffort ||
68
+ label === `${normalizedEffort} thinking` ||
69
+ label === `${normalizedEffort}, click to remove` ||
70
+ label === `${normalizedEffort} thinking, click to remove`
71
+ );
72
+ });
73
+ }
74
+
75
+ export function thinkingChipVisible(snapshot) {
76
+ return /button "(?:Light|Standard|Extended|Heavy)(?: thinking)?(?:, click to remove)?"/i.test(snapshot);
77
+ }
78
+
79
+ export function snapshotHasModelConfigurationUi(snapshot) {
80
+ const entries = parseSnapshotEntries(snapshot);
81
+ const visibleFamilies = new Set(
82
+ entries
83
+ .filter((entry) => entry.kind === "button" && typeof entry.label === "string")
84
+ .flatMap((entry) =>
85
+ Object.keys(MODEL_FAMILY_PREFIX)
86
+ .filter((family) => matchesModelFamilyLabel(entry.label, family))
87
+ .map((family) => family),
88
+ ),
89
+ );
90
+ const hasCloseButton = entries.some((entry) => entry.kind === "button" && entry.label === "Close" && !entry.disabled);
91
+ const hasEffortCombobox = entries.some(
92
+ (entry) => entry.kind === "combobox" && ["Light", "Standard", "Extended", "Heavy"].includes(entry.value || "") && !entry.disabled,
93
+ );
94
+ return visibleFamilies.size >= 2 || hasCloseButton || hasEffortCombobox;
95
+ }
96
+
97
+ export function autoSwitchToThinkingSelectionVisible(snapshot) {
98
+ const entries = parseSnapshotEntries(snapshot);
99
+ let foundControl = false;
100
+
101
+ for (const entry of entries) {
102
+ const controlText = normalizeText([entry.label, entry.value, entry.line].filter(Boolean).join(" "));
103
+ if (!controlText.toLowerCase().includes(AUTO_SWITCH_LABEL.toLowerCase())) continue;
104
+ foundControl = true;
105
+
106
+ if (/\b(?:checked|selected|enabled|on|active)\b/i.test(controlText)) return true;
107
+ if (/\b(?:unchecked|not checked|disabled|off)\b/i.test(controlText)) return false;
108
+ if (typeof entry.label === "string" && /click to remove/i.test(entry.label)) return true;
109
+ }
110
+
111
+ return foundControl ? false : undefined;
112
+ }
113
+
114
+ export function snapshotCanSafelySkipModelConfiguration(snapshot, selection) {
115
+ if (!snapshotStronglyMatchesRequestedModel(snapshot, selection)) return false;
116
+
117
+ if (selection.modelFamily === "thinking" || selection.modelFamily === "pro") {
118
+ const effortLabel = requestedEffortLabel(selection);
119
+ if (effortLabel && !effortSelectionVisible(snapshot, effortLabel)) return false;
120
+ }
121
+
122
+ if (selection.modelFamily === "instant" && selection.autoSwitchToThinking) {
123
+ return autoSwitchToThinkingSelectionVisible(snapshot) === true;
124
+ }
125
+
126
+ return true;
127
+ }
128
+
129
+ export function snapshotStronglyMatchesRequestedModel(snapshot, selection) {
130
+ const entries = parseSnapshotEntries(snapshot);
131
+ const familyMatched = entries.some((entry) => {
132
+ return !entry.disabled && matchesModelFamilyLabel(entry.label, selection.modelFamily);
133
+ });
134
+ if (!familyMatched) return false;
135
+
136
+ const configurationUiVisible = snapshotHasModelConfigurationUi(snapshot);
137
+ const effortLabel = requestedEffortLabel(selection);
138
+
139
+ if (selection.modelFamily === "thinking" || selection.modelFamily === "pro") {
140
+ if (!effortLabel) return true;
141
+ if (effortSelectionVisible(snapshot, effortLabel)) return true;
142
+ return !configurationUiVisible;
143
+ }
144
+
145
+ if (selection.modelFamily === "instant") {
146
+ const autoSwitchState = autoSwitchToThinkingSelectionVisible(snapshot);
147
+ if (selection.autoSwitchToThinking) {
148
+ return autoSwitchState === true || (!configurationUiVisible && autoSwitchState === undefined);
149
+ }
150
+ return autoSwitchState !== true;
151
+ }
152
+
153
+ return false;
154
+ }
155
+
156
+ export function snapshotWeaklyMatchesRequestedModel(snapshot, selection) {
157
+ const entries = parseSnapshotEntries(snapshot);
158
+ const familyMatched = entries.some((entry) => {
159
+ return !entry.disabled && matchesModelFamilyLabel(entry.label, selection.modelFamily);
160
+ });
161
+
162
+ if (selection.modelFamily === "thinking") {
163
+ return familyMatched || effortSelectionVisible(snapshot, requestedEffortLabel(selection));
164
+ }
165
+
166
+ if (!familyMatched) return false;
167
+
168
+ if (selection.modelFamily === "pro") {
169
+ return !thinkingChipVisible(snapshot);
170
+ }
171
+
172
+ if (selection.modelFamily === "instant") {
173
+ const autoSwitchState = autoSwitchToThinkingSelectionVisible(snapshot);
174
+ return selection.autoSwitchToThinking ? autoSwitchState !== false : autoSwitchState !== true;
175
+ }
176
+
177
+ return false;
178
+ }
179
+
180
+ export function buildAssistantCompletionSignature({ responseText, artifactLabels = [], suspiciousArtifactLabels = [] }) {
181
+ const normalizedResponse = normalizeText(responseText);
182
+ if (normalizedResponse) return `text:${normalizedResponse}`;
183
+
184
+ const labels = uniqueStrings([...artifactLabels, ...suspiciousArtifactLabels].map((value) => normalizeText(value))).sort((left, right) => left.localeCompare(right));
185
+ if (labels.length > 0) return `artifacts:${labels.join("|")}`;
186
+
187
+ return undefined;
188
+ }
189
+
190
+ export function deriveAssistantCompletionSignature({
191
+ hasStopStreaming,
192
+ hasTargetCopyResponse,
193
+ responseText,
194
+ artifactLabels = [],
195
+ suspiciousArtifactLabels = [],
196
+ }) {
197
+ if (hasStopStreaming) return undefined;
198
+
199
+ if (hasTargetCopyResponse && normalizeText(responseText)) {
200
+ return buildAssistantCompletionSignature({ responseText });
201
+ }
202
+
203
+ if (!normalizeText(responseText)) {
204
+ return buildAssistantCompletionSignature({ responseText, artifactLabels, suspiciousArtifactLabels });
205
+ }
206
+
207
+ return undefined;
208
+ }
@@ -5,6 +5,18 @@ import { basename, dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { spawn, execFileSync } from "node:child_process";
7
7
  import { extractArtifactLabels, FILE_LABEL_PATTERN_SOURCE, GENERIC_ARTIFACT_LABELS, parseSnapshotEntries, partitionStructuralArtifactCandidates } from "./artifact-heuristics.mjs";
8
+ import {
9
+ buildAllowedChatGptOrigins,
10
+ deriveAssistantCompletionSignature,
11
+ matchesModelFamilyLabel,
12
+ requestedEffortLabel,
13
+ effortSelectionVisible,
14
+ snapshotCanSafelySkipModelConfiguration,
15
+ snapshotHasModelConfigurationUi,
16
+ snapshotStronglyMatchesRequestedModel,
17
+ snapshotWeaklyMatchesRequestedModel,
18
+ autoSwitchToThinkingSelectionVisible,
19
+ } from "./chatgpt-ui-helpers.mjs";
8
20
  import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withLock } from "./state-locks.mjs";
9
21
 
10
22
  const jobId = process.argv[2];
@@ -25,12 +37,6 @@ const CHATGPT_LABELS = {
25
37
  autoSwitchToThinking: "Auto-switch to Thinking",
26
38
  configure: "Configure...",
27
39
  };
28
- const MODEL_FAMILY_PREFIX = {
29
- instant: "Instant ",
30
- thinking: "Thinking ",
31
- pro: "Pro ",
32
- };
33
-
34
40
  const WORKER_SCRIPT_PATH = fileURLToPath(import.meta.url);
35
41
  const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
36
42
  const ORACLE_STATE_DIR = process.env.PI_ORACLE_STATE_DIR?.trim() || DEFAULT_ORACLE_STATE_DIR;
@@ -791,83 +797,10 @@ function findLastEntry(snapshot, predicate) {
791
797
  return undefined;
792
798
  }
793
799
 
794
- function titleCase(value) {
795
- return value ? `${value[0].toUpperCase()}${value.slice(1)}` : value;
796
- }
797
-
798
- function matchesModelFamilyLabel(label, family) {
799
- const normalized = String(label || "");
800
- const prefix = MODEL_FAMILY_PREFIX[family];
801
- const exact = prefix.trim();
802
- return normalized === exact || normalized.startsWith(prefix) || normalized.startsWith(`${exact},`);
803
- }
804
-
805
800
  function matchesModelFamilyButton(candidate, family) {
806
801
  return candidate.kind === "button" && typeof candidate.label === "string" && matchesModelFamilyLabel(candidate.label, family) && !candidate.disabled;
807
802
  }
808
803
 
809
- function requestedEffortLabel(job) {
810
- return job.selection?.effort ? titleCase(job.selection.effort) : undefined;
811
- }
812
-
813
- function effortSelectionVisible(snapshot, effortLabel) {
814
- if (!effortLabel) return true;
815
- const entries = parseSnapshotEntries(snapshot);
816
- return entries.some((entry) => {
817
- if (entry.disabled) return false;
818
- if (entry.kind === "combobox" && entry.value === effortLabel) return true;
819
- if (entry.kind !== "button") return false;
820
- const label = String(entry.label || "").toLowerCase();
821
- const normalizedEffort = effortLabel.toLowerCase();
822
- return (
823
- label === normalizedEffort ||
824
- label === `${normalizedEffort} thinking` ||
825
- label === `${normalizedEffort}, click to remove` ||
826
- label === `${normalizedEffort} thinking, click to remove`
827
- );
828
- });
829
- }
830
-
831
- function thinkingChipVisible(snapshot) {
832
- return /button "(?:Light|Standard|Extended|Heavy)(?: thinking)?(?:, click to remove)?"/i.test(snapshot);
833
- }
834
-
835
- function snapshotHasModelConfigurationUi(snapshot) {
836
- const entries = parseSnapshotEntries(snapshot);
837
- const visibleFamilies = new Set(
838
- entries
839
- .filter((entry) => entry.kind === "button" && typeof entry.label === "string")
840
- .flatMap((entry) =>
841
- Object.keys(MODEL_FAMILY_PREFIX)
842
- .filter((family) => matchesModelFamilyLabel(entry.label, family))
843
- .map((family) => family),
844
- ),
845
- );
846
- const hasCloseButton = entries.some((entry) => entry.kind === "button" && entry.label === CHATGPT_LABELS.close && !entry.disabled);
847
- const hasEffortCombobox = entries.some(
848
- (entry) => entry.kind === "combobox" && ["Light", "Standard", "Extended", "Heavy"].includes(entry.value || "") && !entry.disabled,
849
- );
850
- return visibleFamilies.size >= 2 || hasCloseButton || hasEffortCombobox;
851
- }
852
-
853
- function snapshotStronglyMatchesRequestedModel(snapshot, job) {
854
- const entries = parseSnapshotEntries(snapshot);
855
- const familyMatched = entries.some((entry) => matchesModelFamilyButton(entry, job.selection.modelFamily));
856
- const effortLabel = requestedEffortLabel(job);
857
- if (job.selection.modelFamily === "thinking") {
858
- return familyMatched || effortSelectionVisible(snapshot, effortLabel);
859
- }
860
- if (job.selection.modelFamily === "pro") {
861
- return effortLabel ? familyMatched && effortSelectionVisible(snapshot, effortLabel) : familyMatched;
862
- }
863
- return familyMatched;
864
- }
865
-
866
- function thinkingSelectionVisible(snapshot) {
867
- const entries = parseSnapshotEntries(snapshot);
868
- return entries.some((entry) => !entry.disabled && entry.kind === "button" && matchesModelFamilyLabel(entry.label, "thinking"));
869
- }
870
-
871
804
  function composerControlsVisible(snapshot) {
872
805
  const entries = parseSnapshotEntries(snapshot);
873
806
  const hasComposer = entries.some(
@@ -879,17 +812,15 @@ function composerControlsVisible(snapshot) {
879
812
  return hasComposer && hasAddFiles;
880
813
  }
881
814
 
882
- function snapshotWeaklyMatchesRequestedModel(snapshot, job) {
883
- if (job.selection.modelFamily === "thinking") {
884
- return effortSelectionVisible(snapshot, requestedEffortLabel(job)) || thinkingSelectionVisible(snapshot);
885
- }
886
- if (job.selection.modelFamily === "pro") {
887
- return !thinkingChipVisible(snapshot);
888
- }
889
- if (job.selection.modelFamily === "instant") {
890
- return !thinkingChipVisible(snapshot);
891
- }
892
- return false;
815
+ async function clickAutoSwitchToThinkingControl(job) {
816
+ const snapshot = await snapshotText(job);
817
+ const entry = findEntry(
818
+ snapshot,
819
+ (candidate) => candidate.kind === "button" && typeof candidate.label === "string" && candidate.label.startsWith(CHATGPT_LABELS.autoSwitchToThinking) && !candidate.disabled,
820
+ );
821
+ if (!entry) throw new Error(`Could not find ${CHATGPT_LABELS.autoSwitchToThinking} control`);
822
+ await clickRef(job, entry.ref);
823
+ return entry;
893
824
  }
894
825
 
895
826
  async function clickRef(job, ref) {
@@ -962,7 +893,7 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
962
893
  return { state: "transient_outage_error", message: "ChatGPT is showing a transient outage/error page" };
963
894
  }
964
895
 
965
- const allowedOrigins = [new URL(job.config.browser.chatUrl).origin, "https://auth.openai.com"];
896
+ const allowedOrigins = buildAllowedChatGptOrigins(job.config.browser.chatUrl, job.config.browser.authUrl);
966
897
  const onAllowedOrigin = typeof url === "string" && allowedOrigins.some((origin) => url.startsWith(origin));
967
898
  const onAuthPath = typeof url === "string" && url.includes("/auth/");
968
899
  const hasComposer = snapshot.includes(`textbox "${CHATGPT_LABELS.composer}"`);
@@ -1210,7 +1141,7 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
1210
1141
  const configurationUiVisible = snapshotHasModelConfigurationUi(snapshot);
1211
1142
 
1212
1143
  if (!configurationUiVisible) {
1213
- if (snapshotWeaklyMatchesRequestedModel(snapshot, job)) return;
1144
+ if (snapshotWeaklyMatchesRequestedModel(snapshot, job.selection)) return;
1214
1145
  if (options.stronglyVerified) {
1215
1146
  if (!fallbackLogged) {
1216
1147
  fallbackLogged = true;
@@ -1248,7 +1179,7 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
1248
1179
 
1249
1180
  async function configureModel(job) {
1250
1181
  const initialSnapshot = await snapshotText(job);
1251
- if (snapshotStronglyMatchesRequestedModel(initialSnapshot, job)) {
1182
+ if (snapshotCanSafelySkipModelConfiguration(initialSnapshot, job.selection)) {
1252
1183
  await log(`Model already appears configured for family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}; skipping reconfiguration`);
1253
1184
  return;
1254
1185
  }
@@ -1257,23 +1188,27 @@ async function configureModel(job) {
1257
1188
  let familySnapshot = await openModelConfiguration(job);
1258
1189
  let verificationSnapshot = familySnapshot;
1259
1190
 
1191
+ const alreadyConfiguredInUi = snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection);
1260
1192
  let familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyButton(candidate, job.selection.modelFamily));
1261
- if (!familyEntry && snapshotStronglyMatchesRequestedModel(familySnapshot, job)) {
1193
+ if (alreadyConfiguredInUi) {
1262
1194
  await log("Model configuration UI opened with requested settings already selected");
1263
- }
1264
- if (!familyEntry && !snapshotStronglyMatchesRequestedModel(familySnapshot, job)) {
1195
+ } else if (!familyEntry) {
1265
1196
  throw new Error(`Could not find model family button for ${job.selection.modelFamily}`);
1266
1197
  }
1267
1198
 
1268
- if (familyEntry) {
1199
+ if (!alreadyConfiguredInUi && familyEntry) {
1269
1200
  await clickRef(job, familyEntry.ref);
1270
1201
  await agentBrowser(job, "wait", "800");
1271
1202
  familySnapshot = await snapshotText(job);
1272
1203
  verificationSnapshot = familySnapshot;
1204
+ familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyButton(candidate, job.selection.modelFamily));
1205
+ if (!familyEntry && !snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection)) {
1206
+ throw new Error(`Requested model family did not remain selected: ${job.selection.modelFamily}`);
1207
+ }
1273
1208
  }
1274
1209
 
1275
1210
  if (job.selection.modelFamily === "thinking" || job.selection.modelFamily === "pro") {
1276
- const effortLabel = requestedEffortLabel(job);
1211
+ const effortLabel = requestedEffortLabel(job.selection);
1277
1212
  if (effortLabel && !effortSelectionVisible(familySnapshot, effortLabel)) {
1278
1213
  const opened = await openEffortDropdown(job);
1279
1214
  if (!opened) {
@@ -1291,15 +1226,22 @@ async function configureModel(job) {
1291
1226
  if (!selectedEffort && !effortSelectionVisible(effortSnapshot, effortLabel)) {
1292
1227
  throw new Error(`Requested effort did not remain selected: ${effortLabel}`);
1293
1228
  }
1229
+ familySnapshot = effortSnapshot;
1294
1230
  }
1295
1231
  }
1296
1232
 
1297
- if (job.selection.modelFamily === "instant" && job.selection.autoSwitchToThinking) {
1298
- await maybeClickLabeledEntry(job, CHATGPT_LABELS.autoSwitchToThinking);
1299
- verificationSnapshot = await snapshotText(job);
1233
+ if (job.selection.modelFamily === "instant") {
1234
+ const desiredAutoSwitchState = job.selection.autoSwitchToThinking === true;
1235
+ const currentAutoSwitchState = autoSwitchToThinkingSelectionVisible(familySnapshot);
1236
+ if (currentAutoSwitchState !== desiredAutoSwitchState && (desiredAutoSwitchState || currentAutoSwitchState === true)) {
1237
+ await clickAutoSwitchToThinkingControl(job);
1238
+ await agentBrowser(job, "wait", "400");
1239
+ verificationSnapshot = await snapshotText(job);
1240
+ familySnapshot = verificationSnapshot;
1241
+ }
1300
1242
  }
1301
1243
 
1302
- const stronglyVerified = snapshotStronglyMatchesRequestedModel(verificationSnapshot, job);
1244
+ const stronglyVerified = snapshotStronglyMatchesRequestedModel(verificationSnapshot, job.selection);
1303
1245
  if (!stronglyVerified) {
1304
1246
  throw new Error(`Could not verify requested model settings in configuration UI for ${job.selection.modelFamily}`);
1305
1247
  }
@@ -1427,7 +1369,7 @@ async function waitForStableChatUrl(job, previousChatUrl) {
1427
1369
 
1428
1370
  async function waitForChatCompletion(job, baselineAssistantCount) {
1429
1371
  const timeoutAt = Date.now() + job.config.worker.completionTimeoutMs;
1430
- let lastText = "";
1372
+ let lastCompletionSignature = "";
1431
1373
  let stableCount = 0;
1432
1374
  let retriedAfterFailure = false;
1433
1375
 
@@ -1451,7 +1393,7 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1451
1393
  );
1452
1394
  if (retryEntry) {
1453
1395
  retriedAfterFailure = true;
1454
- lastText = "";
1396
+ lastCompletionSignature = "";
1455
1397
  stableCount = 0;
1456
1398
  await log(`Response delivery failed (${responseFailureText}); clicking Retry once`);
1457
1399
  await clickRef(job, retryEntry.ref);
@@ -1462,13 +1404,34 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1462
1404
  throw new Error(`ChatGPT response failed: ${responseFailureText}`);
1463
1405
  }
1464
1406
 
1407
+ let completionSignature;
1465
1408
  if (!hasStopStreaming && hasTargetCopyResponse && targetText) {
1466
- if (targetText === lastText) stableCount += 1;
1409
+ completionSignature = deriveAssistantCompletionSignature({
1410
+ hasStopStreaming,
1411
+ hasTargetCopyResponse,
1412
+ responseText: targetText,
1413
+ });
1414
+ } else if (!hasStopStreaming && !targetText) {
1415
+ const artifactSignals = await collectArtifactCandidates(job, baselineAssistantCount, targetText).catch(() => ({ candidates: [], suspiciousLabels: [] }));
1416
+ completionSignature = deriveAssistantCompletionSignature({
1417
+ hasStopStreaming,
1418
+ hasTargetCopyResponse,
1419
+ responseText: targetText,
1420
+ artifactLabels: artifactSignals.candidates.map((candidate) => candidate.label),
1421
+ suspiciousArtifactLabels: artifactSignals.suspiciousLabels,
1422
+ });
1423
+ }
1424
+
1425
+ if (completionSignature) {
1426
+ if (completionSignature === lastCompletionSignature) stableCount += 1;
1467
1427
  else stableCount = 1;
1468
- lastText = targetText;
1428
+ lastCompletionSignature = completionSignature;
1469
1429
  if (stableCount >= 2) {
1470
1430
  return { responseIndex: baselineAssistantCount, responseText: targetText };
1471
1431
  }
1432
+ } else {
1433
+ lastCompletionSignature = "";
1434
+ stableCount = 0;
1472
1435
  }
1473
1436
 
1474
1437
  await sleep(job.config.worker.pollMs);
@@ -1,4 +1,5 @@
1
1
  export const ORACLE_METADATA_WRITE_GRACE_MS: number;
2
+ export const ORACLE_TMP_STATE_DIR_GRACE_MS: number;
2
3
 
3
4
  export function acquireLock(
4
5
  stateDir: string,
@@ -6,6 +6,7 @@ import { basename, join } from "node:path";
6
6
  const DEFAULT_WAIT_MS = 30_000;
7
7
  const POLL_MS = 200;
8
8
  export const ORACLE_METADATA_WRITE_GRACE_MS = 1_000;
9
+ export const ORACLE_TMP_STATE_DIR_GRACE_MS = 60_000;
9
10
 
10
11
  async function sleep(ms) {
11
12
  await new Promise((resolve) => setTimeout(resolve, ms));
@@ -76,7 +77,8 @@ function isIncompleteStateDirStale(path, now = Date.now()) {
76
77
  try {
77
78
  const stats = statSync(path);
78
79
  const baselineMs = Math.max(stats.mtimeMs, stats.ctimeMs);
79
- return now - baselineMs >= ORACLE_METADATA_WRITE_GRACE_MS;
80
+ const graceMs = basename(path).startsWith(".tmp-") ? ORACLE_TMP_STATE_DIR_GRACE_MS : ORACLE_METADATA_WRITE_GRACE_MS;
81
+ return now - baselineMs >= graceMs;
80
82
  } catch {
81
83
  return false;
82
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "ChatGPT web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -42,16 +42,21 @@
42
42
  ]
43
43
  },
44
44
  "scripts": {
45
- "check:oracle-extension": "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/auth-cookie-policy.mjs && node --check extensions/oracle/worker/auth-bootstrap.mjs && esbuild extensions/oracle/index.ts --bundle --platform=node --format=esm --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-ai --external:@sinclair/typebox --outfile=/tmp/pi-oracle-extension-check.js",
45
+ "check:oracle-extension": "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/auth-cookie-policy.mjs && node --check extensions/oracle/worker/auth-bootstrap.mjs && esbuild extensions/oracle/index.ts --bundle --platform=node --format=esm --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-ai --external:@sinclair/typebox --outfile=/tmp/pi-oracle-extension-check.js",
46
46
  "typecheck": "tsc --noEmit -p tsconfig.json",
47
47
  "sanity:oracle": "node scripts/oracle-sanity-runner.mjs",
48
48
  "pack:check": "npm pack --dry-run",
49
- "verify:oracle": "npm run check:oracle-extension && npm run typecheck && npm run sanity:oracle && npm run pack:check"
49
+ "verify:oracle": "npm run check:oracle-extension && npm run typecheck && npm run sanity:oracle && npm run pack:check",
50
+ "test": "npm run verify:oracle",
51
+ "prepublishOnly": "npm run verify:oracle"
50
52
  },
51
53
  "dependencies": {
52
54
  "@sinclair/typebox": "^0.34.49",
53
55
  "@steipete/sweet-cookie": "^0.2.0"
54
56
  },
57
+ "overrides": {
58
+ "basic-ftp": "^5.2.2"
59
+ },
55
60
  "devDependencies": {
56
61
  "@mariozechner/pi-ai": "^0.65.2",
57
62
  "@mariozechner/pi-coding-agent": "^0.65.2",
@@ -61,6 +66,9 @@
61
66
  "typescript": "^5.9.3"
62
67
  },
63
68
  "engines": {
64
- "node": ">=20"
65
- }
69
+ "node": ">=22"
70
+ },
71
+ "os": [
72
+ "darwin"
73
+ ]
66
74
  }
package/prompts/oracle.md CHANGED
@@ -14,20 +14,21 @@ Required workflow:
14
14
  6. Stop immediately after dispatching the oracle job.
15
15
 
16
16
  Oracle model (`oracle_submit`):
17
- - To choose a specific ChatGPT model, pass **`preset`** with one of the allowed ids from the tool schema enum / canonical preset registry.
17
+ - To choose a specific ChatGPT model, pass **`preset`** with one of the allowed ids from the canonical preset registry.
18
+ - Matching human-readable preset labels and common hyphen/space variants are also accepted and normalized automatically, but prefer canonical ids when readily available.
18
19
  - **Or** omit **`preset`** entirely to use the configured default model (from oracle config).
19
20
  - **`preset`** is the only model-selection parameter on `oracle_submit`. Do not pass `modelFamily`, `effort`, or `autoSwitchToThinking`.
20
21
  - If unsure which preset fits the task, ask the user.
21
22
 
22
23
  Rules:
23
24
  - Always include an archive. Do not submit without context files.
24
- - By default, 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 root `secrets/` directories.
25
+ - By default, 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.
25
26
  - Only limit file selection if the user explicitly requests it, if the task is clearly scoped to a smaller area, or if privacy/sensitivity requires it.
26
27
  - For very targeted asks like reviewing one function or explaining one stack trace, a smaller archive is preferable.
27
28
  - If the request depends on git state or pending changes (for example code review, ship readiness, or release approval), create a tracked diff bundle file inside the repo (for example under `.pi/`) containing `git status` plus `git diff` output, include that file in the archive, and tell the oracle to use it because the `.git` directory is not included in oracle exports.
28
29
  - When `files=["."]` and the post-exclusion archive is still too large, submit automatically prunes the largest nested directories matching generic generated-output names like `build/`, `dist/`, `out/`, `coverage/`, and `tmp/` outside obvious source roots like `src/` and `lib/` until the archive fits or no candidate remains. Successful submissions report what was pruned.
29
30
  - If a submitted oracle job later fails because upload is rejected, retry with a smaller archive in this order: (1) remove the largest obviously irrelevant/generated content, (2) if still too large, include modified files plus adjacent files plus directly relevant subtrees, (3) if still too large, explain the cut or ask the user.
30
- - Prefer the configured default (omit **`preset`**) unless the task clearly needs a different model; then choose a **`preset`** id from the tool schema enum.
31
+ - Prefer the configured default (omit **`preset`**) unless the task clearly needs a different model; then choose a canonical **`preset`** id.
31
32
  - If `oracle_submit` itself fails because the local archive still exceeds the upload limit after default exclusions and automatic generic generated-output-dir pruning, or for any other submit-time error, stop and report the error. Do not retry automatically.
32
33
  - If `oracle_submit` returns a queued job instead of an immediately dispatched one, treat that as success and end your turn exactly the same way.
33
34
  - After oracle_submit returns, end your turn. Do not keep working while the oracle runs.