pi-oracle 0.7.16 → 0.7.18

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
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.7.18 - 2026-07-14
6
+
7
+ ### Changed
8
+ - refreshed the local Pi/pi-ai development lock, sanity contracts, safe-loader version check, and compatibility guidance for Pi 0.80.7
9
+ - updated the local Pi and pi-ai development baseline to 0.80.6
10
+ - switched provider and mode tool schemas to Pi's Google-compatible `StringEnum` helper
11
+
12
+ ### Fixed
13
+ - made the sanity harness independent of the caller's browser profile layout and installed `agent-browser` sessions
14
+
15
+ ## 0.7.17 - 2026-07-01
16
+
17
+ ### Changed
18
+ - clarified oracle prompt/docs validation around provider upload ceilings, Node release-gate requirements, and follow-up dispatch rules
19
+ - centralized provider normalization, project-trust probing, oracle jobs-dir defaults, and timestamp parsing in narrow source-of-truth modules
20
+
21
+ ### Fixed
22
+ - made sanity-runner mode and real-smoke timeout validation fail through tested behavior paths instead of brittle source-string contracts
23
+ - removed duplicate worker/auth helper logic for snapshot parsing, URL normalization, conversation ids, and login-probe normalization
24
+
25
+ ### Validation
26
+ - ran the full `npm run verify:oracle` local gate before release
27
+
5
28
  ## 0.7.16 - 2026-06-24
6
29
 
7
30
  ### 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.2`; the platform-smoke harness covers macOS, Linux, and Windows native with Chromium-family browsers. Pi `0.80.2+` 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 local validation baseline is pi `0.80.7`; the platform-smoke harness covers macOS, Linux, and Windows native with Chromium-family browsers. Pi `0.80.7+` 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
 
@@ -78,8 +78,8 @@ To update the package later, use `pi update --extensions`, `pi update --all`, or
78
78
  You need:
79
79
 
80
80
  - macOS, Linux, or Windows native
81
- - Node.js 22 or newer
82
- - Suggested tested floor: `pi` 0.80.2 or newer; older pi versions are not blocked by package metadata but are outside the current validation baseline
81
+ - Node.js 22.19.0 or newer for package install/use; platform smoke/release validation currently expects Node 24+ per `platform-smoke.config.mjs`
82
+ - Suggested tested floor: `pi` 0.80.7 or newer; older pi versions are not blocked by package metadata but are outside the current validation baseline
83
83
  - Google Chrome/Chromium or another Chromium-family browser
84
84
  - ChatGPT or Grok already signed in to the configured local browser profile for the provider you plan to use
85
85
  - `agent-browser` and `tar` available on the machine; `zstd` is also required when submitting ChatGPT `.tar.zst` archives
@@ -391,7 +391,8 @@ Use the narrowest validation workflow that proves the change:
391
391
  | Situation | Command(s) |
392
392
  | --- | --- |
393
393
  | Everyday local iteration | `npm run verify:oracle` |
394
- | Platform-sensitive changes | `npm run smoke:platform:doctor`, then a focused `node scripts/platform-smoke.mjs run --target <target> --suite <suite>` |
394
+ | Platform-focused syntax/invariant sanity | `npm run check:platform-smoke`, `npm run sanity:oracle:platform` |
395
+ | Platform-sensitive runtime changes | `npm run smoke:platform:doctor`, then a focused `node scripts/platform-smoke.mjs run --target <target> --suite <suite>` |
395
396
  | Platform matrix proof | `npm run smoke:platform:all` |
396
397
  | ChatGPT preset release proof | `npm run release:proof:chatgpt-presets` |
397
398
  | Publish/release gate | `npm run release:check` |
@@ -7,7 +7,7 @@ Companion doc:
7
7
  - `docs/ORACLE_RECOVERY_DRILL.md` — safe expired-auth recovery validation drill
8
8
 
9
9
  Compatibility target:
10
- - `pi` 0.80.2+ is the suggested tested floor for current project-trust-aware package/runtime validation
10
+ - `pi` 0.80.7+ is the suggested tested floor for current project-trust-aware package/runtime validation
11
11
  - package metadata keeps pi runtime packages as optional wildcard peers, so this suggested floor is not enforced as a hard npm install requirement
12
12
  - current extension lifecycle only; no backward-compatibility shims for removed `session_switch` / `session_fork` events
13
13
 
@@ -115,7 +115,7 @@ It instructs the agent to:
115
115
  2. stop right away if preflight reports the session or local oracle setup is not ready
116
116
  3. understand whether the request is explicitly narrow or genuinely broad
117
117
  4. if auth is missing, stale, or the worker explicitly said to rerun `/oracle-auth`, stop and tell the user to run `/oracle-auth` rather than launching auth automatically
118
- 5. gather enough repo context to submit well and bias toward context-rich archives when they fit within the provider ceiling: 250 MB for ChatGPT and 200 MiB for Grok
118
+ 5. gather enough repo context to submit well and bias toward context-rich archives when they fit within the provider ceiling: 250 MiB for ChatGPT and 200 MiB for Grok
119
119
  6. if the request is narrow, start from the directly relevant area but still include nearby tests, docs, config, and adjacent modules when they may improve answer quality
120
120
  7. if the request is broad/repo-wide, gather broader context and usually archive `.`
121
121
  8. if `oracle_submit` fails before dispatch with an `archive_too_large` / upload-limit error, treat that as retryable: use the reported size summary plus any auto-pruned paths to cut scope and retry automatically with a smaller archive
@@ -639,6 +639,10 @@ Remaining non-blocking hardening work:
639
639
  - keep hardening model-selection verification against future ChatGPT UI variation
640
640
 
641
641
  Recent proof points:
642
+ - Pi 0.80.7 local gate: `npm run verify:oracle` passed on 2026-07-14, including syntax/bundle checks, both typechecks, the isolated sanity harness, and `npm pack --dry-run`
643
+ - Pi 0.80.7 safe loader smokes: `.artifacts/real-smoke/run-1784068526377-67yb2l` passed source loading, and `.artifacts/real-smoke/run-1784068527234-t2a5xm` passed packed-install loading through the real Pi CLI; both executed `/oracle-status` without creating an external oracle job or requiring provider credentials
644
+ - Pi 0.80.6 local gate: `npm run verify:oracle` passed three consecutive runs on 2026-07-11; each run completed syntax/bundle checks, both typechecks, the isolated sanity harness, and `npm pack --dry-run` without an `ENOTEMPTY` cleanup failure
645
+ - Pi 0.80.6 safe loader smokes: `.artifacts/real-smoke/run-1783810473367-cn72at` passed source loading, and `.artifacts/real-smoke/run-1783810475290-hj0pfs` passed packed-install loading through the real Pi CLI; both recorded `pi --version` as 0.80.6 and executed `/oracle-status` without creating an external oracle job or requiring provider credentials
642
646
  - Pi 0.80.2 local gate: `npm run verify:oracle` passed on 2026-06-24 after the JSON command output, prompt-manifest, schema, and lazy Chrome-probe audit fixes
643
647
  - Pi 0.80.2 isolated extension smokes: `.artifacts/real-smoke/run-1782321054924-jnq0x3` passed source proof, and `.artifacts/real-smoke/run-1782321056224-yuq5a2` passed packed-install proof
644
648
  - Pi 0.80.2 JSON command smoke: `pi --no-extensions -e ./extensions/oracle/index.ts --mode json --no-session --no-approve "/oracle-status"` emitted displayed `oracle-command-output` JSON events
@@ -658,6 +662,6 @@ Recent proof points:
658
662
  - expired-auth drill post-repair success: `fa26a2a7-0057-4a21-b3e0-71c1d020facf`
659
663
  - successful multi-artifact completion: `b6b3599c-6b91-4315-adfa-8a83aa5eda9b`
660
664
  - repo-owned sanity harness: `npm run sanity:oracle`
661
- - real installed-extension smoke source of truth: `scripts/oracle-real-smoke.mjs`; required release proof runs packed-install mode (`npm run smoke:real:packed`) and executes installed-package `oracle_submit` deterministically, with optional slower model-agent debugging via `PI_ORACLE_REAL_TEST_MODEL_AGENT=1`; source mode (`npm run smoke:real:source`) is inner-loop/debug only
665
+ - real installed-extension smoke source of truth: `scripts/oracle-real-smoke.mjs`; required release proof runs packed-install mode (`npm run smoke:real:packed`), asserts Pi 0.80.7, and executes `/oracle-status` through Pi's installed-package loader without provider credentials or an external oracle job; optional slower model-agent submission debugging remains behind `PI_ORACLE_REAL_TEST_MODEL_AGENT=1`; source mode (`npm run smoke:real:source`) is inner-loop/debug only
662
666
  - macOS, Ubuntu, and Windows native package/build/runtime smoke source of truth: `docs/platform-smoke.md`; use `npm run verify:oracle` for everyday local iteration, `npm run smoke:platform:doctor` plus a focused target/suite run for platform-sensitive changes, `npm run smoke:platform:all` for doctor-first platform matrix evidence, and `npm run release:check` for the full local-plus-platform release gate
663
667
  - release gate: `npm run release:check`, also used by `prepublishOnly`, combines static verification, fresh loaded-extension ChatGPT preset proof via `npm run release:proof:chatgpt-presets`, and all required Crabbox platform smokes
@@ -43,7 +43,7 @@ mkdir -p "$SEED"
43
43
  chmod 700 "$SEED"
44
44
  ```
45
45
 
46
- This simulates a seed profile with no usable ChatGPT session.
46
+ This simulates a seed profile with no usable provider session.
47
47
 
48
48
  ## Validation steps
49
49
 
@@ -72,7 +72,7 @@ Run:
72
72
  ```
73
73
 
74
74
  Expected result:
75
- - ChatGPT cookies are re-synced into the seed profile
75
+ - provider cookies are re-synced into the seed profile
76
76
  - no real Chrome profile is mutated
77
77
  - command reports success
78
78
 
@@ -11,6 +11,7 @@ import { loadOracleConfig } from "./lib/config.js";
11
11
  import { registerOracleCommands } from "./lib/commands.js";
12
12
  import { getSessionFile, pruneTerminalOracleJobs, reconcileStaleOracleJobs } from "./lib/jobs.js";
13
13
  import { isLockTimeoutError, withGlobalReconcileLock } from "./lib/locks.js";
14
+ import { isOracleProjectTrusted } from "./lib/trust.js";
14
15
  import { refreshOracleStatus, setOracleReadiness, startPoller, stopPoller } from "./lib/poller.js";
15
16
  import { promoteQueuedJobs } from "./lib/queue.js";
16
17
  import { assertOracleSubmitPrerequisites, hasPersistedSessionFile } from "./lib/runtime.js";
@@ -29,10 +30,6 @@ function oracleReadinessFromError(error: unknown): "auth_needed" | "config_error
29
30
  return /auth seed profile/i.test(message) ? "auth_needed" : "config_error";
30
31
  }
31
32
 
32
- function isProjectTrusted(ctx: ExtensionContext): boolean {
33
- return (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted?.() ?? true;
34
- }
35
-
36
33
  function expandOraclePromptTemplate(source: string, args: string): string {
37
34
  return source.replaceAll("$@", args).replaceAll("$ARGUMENTS", args);
38
35
  }
@@ -94,7 +91,7 @@ export default function oracleExtension(pi: ExtensionAPI) {
94
91
  return;
95
92
  }
96
93
 
97
- const config = loadOracleConfig(ctx.cwd, { projectConfigTrusted: isProjectTrusted(ctx) });
94
+ const config = loadOracleConfig(ctx.cwd, { projectConfigTrusted: isOracleProjectTrusted(ctx) });
98
95
  setOracleReadiness(ctx, "loaded");
99
96
  void assertOracleSubmitPrerequisites(config)
100
97
  .then(() => setOracleReadiness(ctx, "ready"))
@@ -8,7 +8,8 @@ import { readFile } from "node:fs/promises";
8
8
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
9
  import { formatOracleCancelOutcome, formatOracleJobSummary } from "../shared/job-observability-helpers.mjs";
10
10
  import { runOracleAuthBootstrap } from "./auth.js";
11
- import type { OracleProvider } from "./config.js";
11
+ import { normalizeOracleProviderAlias, type OracleProvider } from "./config.js";
12
+ import { isOracleProjectTrusted } from "./trust.js";
12
13
  import {
13
14
  cancelOracleJob,
14
15
  getJobDir,
@@ -27,11 +28,6 @@ import { refreshOracleStatus } from "./poller.js";
27
28
  import { isLockTimeoutError, withGlobalReconcileLock } from "./locks.js";
28
29
  import { getProjectId } from "./runtime.js";
29
30
 
30
- export interface OracleCommandPromptTemplates {
31
- oracle?: string;
32
- oracleFollowup?: string;
33
- }
34
-
35
31
  async function summarizeJob(jobId: string, options?: { responsePreview?: boolean }): Promise<string> {
36
32
  const job = readJob(jobId);
37
33
  if (!job) return `Oracle job ${jobId} not found.`;
@@ -72,17 +68,13 @@ function readScopedJob(jobId: string, cwd: string) {
72
68
  }
73
69
 
74
70
  function parseOracleAuthProvider(args: string): OracleProvider | undefined {
75
- const value = args.trim().toLowerCase();
71
+ const value = args.trim();
76
72
  if (!value) return undefined;
77
- if (value === "chatgpt" || value === "chat-gpt" || value === "openai") return "chatgpt";
78
- if (value === "grok" || value === "xai" || value === "x.ai") return "grok";
73
+ const provider = normalizeOracleProviderAlias(value);
74
+ if (provider) return provider;
79
75
  throw new Error("Usage: /oracle-auth [chatgpt|grok]");
80
76
  }
81
77
 
82
- function isProjectTrusted(ctx: ExtensionCommandContext): boolean {
83
- return (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted?.() ?? true;
84
- }
85
-
86
78
  function emitCommandOutput(pi: ExtensionAPI, ctx: ExtensionCommandContext, message: string, level: "info" | "warning" | "error" = "info"): void {
87
79
  if (ctx.mode === "print") {
88
80
  process.stdout.write(`${message}\n`);
@@ -100,7 +92,7 @@ function emitCommandOutput(pi: ExtensionAPI, ctx: ExtensionCommandContext, messa
100
92
  });
101
93
  }
102
94
 
103
- export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string, workerPath: string, _promptTemplates: OracleCommandPromptTemplates = {}): void {
95
+ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string, workerPath: string): void {
104
96
  pi.registerCommand("oracle-auth", {
105
97
  description: "Sync ChatGPT or Grok cookies from the configured local browser profile into the provider auth seed profile",
106
98
  handler: async (args, ctx) => {
@@ -108,7 +100,7 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string,
108
100
  const provider = parseOracleAuthProvider(args);
109
101
  const providerLabel = provider === "grok" ? "Grok" : provider === "chatgpt" ? "ChatGPT" : "configured provider";
110
102
  emitCommandOutput(pi, ctx, `Syncing ${providerLabel} cookies from the configured local browser profile into the oracle auth seed profile…`, "info");
111
- const result = await runOracleAuthBootstrap(authWorkerPath, ctx.cwd, provider, { projectConfigTrusted: isProjectTrusted(ctx) });
103
+ const result = await runOracleAuthBootstrap(authWorkerPath, ctx.cwd, provider, { projectConfigTrusted: isOracleProjectTrusted(ctx) });
112
104
  emitCommandOutput(pi, ctx, result, "info");
113
105
  } catch (error) {
114
106
  emitCommandOutput(pi, ctx, error instanceof Error ? error.message : String(error), "warning");
@@ -22,6 +22,21 @@ import { getProjectId } from "./runtime.js";
22
22
  export const ORACLE_PROVIDERS = ["chatgpt", "grok"] as const;
23
23
  export type OracleProvider = (typeof ORACLE_PROVIDERS)[number];
24
24
 
25
+ export function normalizeOracleProviderAlias(value: string): OracleProvider | undefined {
26
+ const normalized = value.trim().toLowerCase();
27
+ if (normalized === "chatgpt" || normalized === "chat-gpt" || normalized === "openai") return "chatgpt";
28
+ if (normalized === "grok" || normalized === "xai" || normalized === "x.ai") return "grok";
29
+ return undefined;
30
+ }
31
+
32
+ export function normalizeOracleProvider(value: unknown, fallback: OracleProvider, toolName = "oracle_submit"): OracleProvider {
33
+ if (value === undefined) return fallback;
34
+ if (typeof value !== "string") throw new Error(`${toolName} provider must be a string`);
35
+ const provider = normalizeOracleProviderAlias(value);
36
+ if (provider) return provider;
37
+ throw new Error(`Unknown ${toolName} provider: ${value}. Use chatgpt or grok.`);
38
+ }
39
+
25
40
  export { resolveOracleArchiveFormat, resolveOracleProviderArchivePlan } from "./provider-capabilities.js";
26
41
  export type { OracleArchiveFormat, OracleProviderArchivePlan } from "./provider-capabilities.js";
27
42
 
@@ -30,6 +30,8 @@ import type { OracleJobLifecycleEvent as SharedOracleJobLifecycleEvent, OracleJo
30
30
  import { hasDurableWorkerHandoff as sharedHasDurableWorkerHandoff } from "../shared/job-coordination-helpers.mjs";
31
31
  import { isTrackedProcessAlive, readProcessStartedAt, spawnDetachedNodeProcess, terminateTrackedProcess } from "../shared/process-helpers.mjs";
32
32
  import type { OracleConfig, OracleResolvedSelection } from "./config.js";
33
+ import { getOracleJobsDir } from "../shared/state-path-helpers.mjs";
34
+ import { parseTimestamp } from "../shared/time-helpers.mjs";
33
35
  import { resolveOracleProviderArchivePlan } from "./provider-capabilities.js";
34
36
  import { withJobLock, withLock } from "./locks.js";
35
37
  import { cleanupRuntimeArtifacts, getProjectId, getSessionId, parseConversationId, requirePersistedSessionFile, type OracleCleanupReport } from "./runtime.js";
@@ -50,9 +52,8 @@ const ORACLE_JOB_DIR_RM_MAX_RETRIES = 5;
50
52
  const ORACLE_JOB_DIR_RM_RETRY_DELAY_MS = 50;
51
53
  const ORACLE_COMPLETE_JOB_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
52
54
  const ORACLE_FAILED_JOB_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
53
- export const DEFAULT_ORACLE_JOBS_DIR = "/tmp";
54
- export const ORACLE_JOBS_DIR_ENV = "PI_ORACLE_JOBS_DIR";
55
- const ORACLE_JOBS_DIR = process.env[ORACLE_JOBS_DIR_ENV]?.trim() || DEFAULT_ORACLE_JOBS_DIR;
55
+ export { DEFAULT_ORACLE_JOBS_DIR, ORACLE_JOBS_DIR_ENV, getOracleJobsDir } from "../shared/state-path-helpers.mjs";
56
+ const ORACLE_JOBS_DIR = getOracleJobsDir();
56
57
 
57
58
  export function isActiveOracleJob(job: Pick<OracleJob, "status">): boolean {
58
59
  return ACTIVE_ORACLE_JOB_STATUSES.includes(job.status);
@@ -217,10 +218,6 @@ export function getSessionFile(ctx: ExtensionContext): string | undefined {
217
218
  return hasSessionFileAccessor(ctx.sessionManager) ? ctx.sessionManager.getSessionFile() : undefined;
218
219
  }
219
220
 
220
- export function getOracleJobsDir(): string {
221
- return ORACLE_JOBS_DIR;
222
- }
223
-
224
221
  export function getJobDir(id: string): string {
225
222
  return join(ORACLE_JOBS_DIR, `oracle-${id}`);
226
223
  }
@@ -297,12 +294,6 @@ export async function clearCleanupPending(jobId: string, at = new Date().toISOSt
297
294
  }
298
295
  }
299
296
 
300
- function parseTimestamp(value: string | undefined): number | undefined {
301
- if (!value) return undefined;
302
- const parsed = Date.parse(value);
303
- return Number.isFinite(parsed) ? parsed : undefined;
304
- }
305
-
306
297
  function notificationClaimIsOwnedBy(job: Pick<OracleJob, "notifyClaimedAt" | "notifyClaimedBy">, claimedBy: string, now = Date.now()): boolean {
307
298
  if (job.notifyClaimedBy !== claimedBy) return false;
308
299
  const claimedAtMs = parseTimestamp(job.notifyClaimedAt);
@@ -100,14 +100,6 @@ export async function withGlobalReconcileLock<T>(
100
100
  return withLock("reconcile", "global", metadata, fn, { timeoutMs: options?.timeoutMs ?? 30_000 });
101
101
  }
102
102
 
103
- export async function withGlobalScanLock<T>(
104
- metadata: unknown,
105
- fn: () => Promise<T>,
106
- options?: { timeoutMs?: number },
107
- ): Promise<T> {
108
- return withLock("scan", "global", metadata, fn, { timeoutMs: options?.timeoutMs ?? 5_000 });
109
- }
110
-
111
103
  export async function withJobLock<T>(jobId: string, metadata: unknown, fn: () => Promise<T>): Promise<T> {
112
104
  return withLock("job", jobId, metadata, fn, { timeoutMs: 10_000 });
113
105
  }
@@ -7,6 +7,7 @@ import { existsSync } from "node:fs";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import { buildOracleStatusText, buildOracleWakeupNotificationContent, type OracleReadinessStatus } from "../shared/job-observability-helpers.mjs";
9
9
  import { isProcessAlive, readProcessStartedAt } from "../shared/process-helpers.mjs";
10
+ import { parseTimestamp } from "../shared/time-helpers.mjs";
10
11
  import { isLockTimeoutError, listLeaseMetadata, releaseLease, withGlobalReconcileLock, writeLeaseMetadata } from "./locks.js";
11
12
  import {
12
13
  getJobDir,
@@ -90,12 +91,6 @@ function jobMatchesContext(job: { projectId: string; sessionId: string }, sessio
90
91
  return job.projectId === projectId && job.sessionId === sessionId;
91
92
  }
92
93
 
93
- function parseTimestamp(value: string | undefined): number | undefined {
94
- if (!value) return undefined;
95
- const parsed = Date.parse(value);
96
- return Number.isFinite(parsed) ? parsed : undefined;
97
- }
98
-
99
94
  function getWakeupTargetLeaseKey(sessionKey: string, processPid = process.pid, processStartedAt = readProcessStartedAt(process.pid) || "unknown"): string {
100
95
  return `${sessionKey}::${processPid}::${processStartedAt}`;
101
96
  }
@@ -14,7 +14,7 @@ import {
14
14
  import { transitionOracleJobPhase } from "../shared/job-lifecycle-helpers.mjs";
15
15
  import { loadOracleConfig } from "./config.js";
16
16
  import { withLock } from "./locks.js";
17
- import { appendCleanupWarnings, createJob, isTerminalOracleJob, listOracleJobDirs, readJob, spawnWorker, terminateWorkerPid, updateJob, type OracleJob } from "./jobs.js";
17
+ import { appendCleanupWarnings, isTerminalOracleJob, listOracleJobDirs, readJob, spawnWorker, terminateWorkerPid, updateJob, type OracleJob } from "./jobs.js";
18
18
  import { cleanupRuntimeArtifacts, releaseRuntimeLease, tryAcquireConversationLease, tryAcquireRuntimeLease } from "./runtime.js";
19
19
 
20
20
  export interface OracleQueuePosition {
@@ -144,14 +144,3 @@ export async function promoteQueuedJobs(options: PromoteQueuedJobsOptions): Prom
144
144
  return promoteQueuedJobsWithinAdmissionLock(options);
145
145
  }, { timeoutMs: options.lockTimeoutMs });
146
146
  }
147
-
148
- export async function createQueuedJob(
149
- id: string,
150
- input: Parameters<typeof createJob>[1],
151
- cwd: string,
152
- originSessionFile: string | undefined,
153
- config: Parameters<typeof createJob>[4],
154
- runtime: Parameters<typeof createJob>[5],
155
- ): Promise<OracleJob> {
156
- return createJob(id, input, cwd, originSessionFile, config, runtime, { initialState: "queued" });
157
- }
@@ -13,12 +13,11 @@ import { assertNotKnownBrowserUserDataPath, sweetCookieSafeStoragePasswordScrubb
13
13
  import { jobBlocksAdmission } from "../shared/job-coordination-helpers.mjs";
14
14
  import { isTrackedProcessAlive } from "../shared/process-helpers.mjs";
15
15
  import type { OracleConfig, OracleProvider } from "./config.js";
16
+ import { getOracleJobsDir } from "../shared/state-path-helpers.mjs";
16
17
  import { resolveOracleProviderArchivePlan } from "./provider-capabilities.js";
17
18
  import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withAuthLock } from "./locks.js";
18
19
 
19
20
  const SEED_GENERATION_FILE = ".oracle-seed-generation";
20
- const DEFAULT_ORACLE_JOBS_DIR = "/tmp";
21
- const ORACLE_JOBS_DIR = process.env.PI_ORACLE_JOBS_DIR?.trim() || DEFAULT_ORACLE_JOBS_DIR;
22
21
  const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
23
22
  (candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
24
23
  ) || "agent-browser";
@@ -338,7 +337,7 @@ export async function assertOracleSubmitPrerequisites(config: OracleConfig, prov
338
337
  await assertRequiredLocalDependencyReady(dependency.name, dependency.command);
339
338
  }
340
339
  await assertWritableDirectory(config.browser.runtimeProfilesDir, "runtime profiles");
341
- await assertWritableDirectory(ORACLE_JOBS_DIR, "jobs");
340
+ await assertWritableDirectory(getOracleJobsDir(), "jobs");
342
341
  }
343
342
 
344
343
  export function getSeedGeneration(config: OracleConfig): string | undefined {
@@ -359,7 +358,7 @@ export async function writeSeedGeneration(config: OracleConfig, value = new Date
359
358
  }
360
359
 
361
360
  function activeJobExists(jobId: string): boolean {
362
- const path = join(ORACLE_JOBS_DIR, `oracle-${jobId}`, "job.json");
361
+ const path = join(getOracleJobsDir(), `oracle-${jobId}`, "job.json");
363
362
  if (!existsSync(path)) return false;
364
363
  try {
365
364
  const job = JSON.parse(readFileSync(path, "utf8")) as {
@@ -9,6 +9,7 @@ import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import { createArchive, type ArchiveCreationResult, type ArchiveSizeBreakdownRow } from "./archive.js";
11
11
  import { runOracleAuthBootstrap } from "./auth.js";
12
+ import { StringEnum } from "@earendil-works/pi-ai";
12
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
14
  import { Type, type Static } from "typebox";
14
15
  import { formatOracleCancelOutcome, formatOracleJobSummary, formatOracleSubmitResponse } from "../shared/job-observability-helpers.mjs";
@@ -17,6 +18,7 @@ import { isLockTimeoutError, withGlobalReconcileLock, withLock } from "./locks.j
17
18
  import {
18
19
  coerceOracleSubmitPresetId,
19
20
  loadOracleConfig,
21
+ normalizeOracleProvider,
20
22
  GROK_MODES,
21
23
  ORACLE_PROVIDERS,
22
24
  ORACLE_SUBMIT_PRESET_IDS,
@@ -49,6 +51,7 @@ import {
49
51
  updateJob,
50
52
  type OracleJob,
51
53
  } from "./jobs.js";
54
+ import { isOracleProjectTrusted } from "./trust.js";
52
55
  import { getQueuePosition, promoteQueuedJobs, promoteQueuedJobsWithinAdmissionLock } from "./queue.js";
53
56
  import { resolveOracleProviderArchivePlan } from "./provider-capabilities.js";
54
57
  import { refreshOracleStatus } from "./poller.js";
@@ -66,13 +69,11 @@ import {
66
69
  } from "./runtime.js";
67
70
 
68
71
  const ORACLE_PROVIDER_PARAM_DESCRIPTION = `Oracle web provider. Omit to use the configured default provider. Supported providers: ${ORACLE_PROVIDERS.join(", ")}.`;
69
- const ORACLE_PROVIDER_PARAM = Type.Optional(Type.String({
72
+ const ORACLE_PROVIDER_PARAM = Type.Optional(StringEnum(ORACLE_PROVIDERS, {
70
73
  description: ORACLE_PROVIDER_PARAM_DESCRIPTION,
71
- enum: [...ORACLE_PROVIDERS],
72
74
  }));
73
- const ORACLE_GROK_MODE_PARAM = Type.Optional(Type.String({
75
+ const ORACLE_GROK_MODE_PARAM = Type.Optional(StringEnum(GROK_MODES, {
74
76
  description: "Provider mode. For Grok, only heavy is currently supported. Omit to use the configured default mode.",
75
- enum: [...GROK_MODES],
76
77
  }));
77
78
 
78
79
  const ORACLE_SUBMIT_PARAMS = Type.Object({
@@ -126,15 +127,6 @@ const ORACLE_CANCEL_PARAMS = Type.Object({
126
127
 
127
128
  const MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME = 1;
128
129
  const MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME = resolveOracleProviderArchivePlan("chatgpt").maxArchiveBytes;
129
- function normalizeOracleProvider(value: unknown, fallback: OracleProvider, toolName = "oracle_submit"): OracleProvider {
130
- if (value === undefined) return fallback;
131
- if (typeof value !== "string") throw new Error(`${toolName} provider must be a string`);
132
- const normalized = value.trim().toLowerCase();
133
- if (normalized === "chatgpt" || normalized === "chat-gpt" || normalized === "openai") return "chatgpt";
134
- if (normalized === "grok" || normalized === "xai" || normalized === "x.ai") return "grok";
135
- throw new Error(`Unknown ${toolName} provider: ${value}. Use chatgpt or grok.`);
136
- }
137
-
138
130
  function prepareOracleProviderAliases<T>(args: unknown, toolName: string): T {
139
131
  const record = asRecord(args);
140
132
  if (!record) return args as T;
@@ -712,10 +704,6 @@ function formatOraclePreflightResponse(details: OraclePreflightDetails): string
712
704
  ].filter(Boolean).join("\n");
713
705
  }
714
706
 
715
- function isProjectTrusted(ctx: ExtensionContext): boolean {
716
- return (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted?.() ?? true;
717
- }
718
-
719
707
  async function runOraclePreflight(ctx: ExtensionContext, params: { provider?: unknown; followUpJobId?: unknown; chatGptConversationId?: unknown } = {}): Promise<OraclePreflightDetails> {
720
708
  const sessionFile = getSessionFile(ctx);
721
709
  if (!hasPersistedSessionFile(sessionFile)) {
@@ -743,7 +731,7 @@ async function runOraclePreflight(ctx: ExtensionContext, params: { provider?: un
743
731
  if (chatGptConversationId !== undefined && typeof chatGptConversationId !== "string") {
744
732
  throw new Error("oracle_preflight chatGptConversationId must be a string");
745
733
  }
746
- const baseConfig = loadOracleConfig(ctx.cwd, { projectConfigTrusted: isProjectTrusted(ctx) });
734
+ const baseConfig = loadOracleConfig(ctx.cwd, { projectConfigTrusted: isOracleProjectTrusted(ctx) });
747
735
  const target = resolveConversationTarget({ followUpJobId, chatGptConversationId, cwd: ctx.cwd, config: baseConfig });
748
736
  provider = normalizeOracleProvider(params.provider, target.provider ?? baseConfig.defaults.provider, "oracle_preflight");
749
737
  if (target.provider && provider !== target.provider) {
@@ -835,9 +823,9 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
835
823
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
836
824
  try {
837
825
  const projectCwd = getProjectId(ctx.cwd);
838
- const baseConfig = loadOracleConfig(projectCwd, { projectConfigTrustCwd: ctx.cwd, projectConfigTrusted: isProjectTrusted(ctx) });
826
+ const baseConfig = loadOracleConfig(projectCwd, { projectConfigTrustCwd: ctx.cwd, projectConfigTrusted: isOracleProjectTrusted(ctx) });
839
827
  const provider = normalizeOracleProvider(params.provider, baseConfig.defaults.provider, "oracle_auth");
840
- const message = await runOracleAuthBootstrap(authWorkerPath, projectCwd, provider, { projectConfigTrustCwd: ctx.cwd, projectConfigTrusted: isProjectTrusted(ctx) });
828
+ const message = await runOracleAuthBootstrap(authWorkerPath, projectCwd, provider, { projectConfigTrustCwd: ctx.cwd, projectConfigTrusted: isOracleProjectTrusted(ctx) });
841
829
  return {
842
830
  content: [{ type: "text" as const, text: message }],
843
831
  details: {
@@ -874,7 +862,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
874
862
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
875
863
  try {
876
864
  const projectCwd = getProjectId(ctx.cwd);
877
- const baseConfig = loadOracleConfig(projectCwd, { projectConfigTrustCwd: ctx.cwd, projectConfigTrusted: isProjectTrusted(ctx) });
865
+ const baseConfig = loadOracleConfig(projectCwd, { projectConfigTrustCwd: ctx.cwd, projectConfigTrusted: isOracleProjectTrusted(ctx) });
878
866
  const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
879
867
  const projectId = getProjectId(projectCwd);
880
868
  const sessionId = getSessionId(originSessionFile, projectId);
@@ -0,0 +1,5 @@
1
+ // Purpose: Keep pi project-trust probing behind one typed extension-side helper.
2
+
3
+ export function isOracleProjectTrusted(ctx: { isProjectTrusted?: () => boolean }): boolean {
4
+ return ctx.isProjectTrusted?.() ?? true;
5
+ }
@@ -5,6 +5,7 @@
5
5
  // Invariants/Assumptions: Job summaries read from durable job state, and lifecycle event trails are bounded and already normalized by shared lifecycle helpers.
6
6
 
7
7
  import { getLatestOracleJobLifecycleEvent, getLatestOracleTerminalLifecycleEvent } from "./job-lifecycle-helpers.mjs";
8
+ import { parseTimestamp } from "./time-helpers.mjs";
8
9
 
9
10
  /** @typedef {import("./job-observability-helpers.d.mts").OracleJobSummaryLike} OracleJobSummaryLike */
10
11
  /** @typedef {import("./job-observability-helpers.d.mts").OracleJobSummaryOptions} OracleJobSummaryOptions */
@@ -68,16 +69,6 @@ const ACTIVE_SUMMARY_STATUSES = new Set(["preparing", "submitted", "waiting"]);
68
69
  const DEFAULT_ORACLE_HEARTBEAT_STALE_MS = 3 * 60 * 1000;
69
70
  const DEFAULT_ACTIVE_JOB_POLL_HINT_SECONDS = 15;
70
71
 
71
- /**
72
- * @param {string | undefined} value
73
- * @returns {number | undefined}
74
- */
75
- function parseTimestamp(value) {
76
- if (!value) return undefined;
77
- const ms = Date.parse(value);
78
- return Number.isNaN(ms) ? undefined : ms;
79
- }
80
-
81
72
  /**
82
73
  * @param {number} elapsedMs
83
74
  * @returns {string}
@@ -0,0 +1,3 @@
1
+ export declare const DEFAULT_ORACLE_JOBS_DIR = "/tmp";
2
+ export declare const ORACLE_JOBS_DIR_ENV = "PI_ORACLE_JOBS_DIR";
3
+ export declare function getOracleJobsDir(env?: Record<string, string | undefined>): string;
@@ -0,0 +1,9 @@
1
+ // Purpose: Share oracle state path defaults across extension and detached workers.
2
+ // Scope: Path/env defaults only; no filesystem mutation.
3
+
4
+ export const DEFAULT_ORACLE_JOBS_DIR = "/tmp";
5
+ export const ORACLE_JOBS_DIR_ENV = "PI_ORACLE_JOBS_DIR";
6
+
7
+ export function getOracleJobsDir(env = process.env) {
8
+ return env[ORACLE_JOBS_DIR_ENV]?.trim() || DEFAULT_ORACLE_JOBS_DIR;
9
+ }
@@ -0,0 +1 @@
1
+ export declare function parseTimestamp(value: string | undefined): number | undefined;
@@ -0,0 +1,7 @@
1
+ // Purpose: Tiny shared time parsing helper for job timestamps.
2
+
3
+ export function parseTimestamp(value) {
4
+ if (!value) return undefined;
5
+ const parsed = Date.parse(value);
6
+ return Number.isFinite(parsed) ? parsed : undefined;
7
+ }
@@ -16,7 +16,9 @@ import {
16
16
  } from "../shared/browser-profile-helpers.mjs";
17
17
  import { ensureAccountCookie, filterImportableAuthCookies } from "./auth-cookie-policy.mjs";
18
18
  import { getCookiesFromConfiguredChromiumSource } from "./chromium-cookie-source.mjs";
19
+ import { parseSnapshotEntries } from "./artifact-heuristics.mjs";
19
20
  import { buildAllowedChatGptOrigins } from "./chatgpt-ui-helpers.mjs";
21
+ import { stripUrlQueryAndHash } from "./chatgpt-flow-helpers.mjs";
20
22
  import { buildAccountChooserCandidateLabels, classifyChatAuthPage, normalizeLoginProbeResult } from "./auth-flow-helpers.mjs";
21
23
 
22
24
  const rawConfig = process.argv[2];
@@ -429,27 +431,6 @@ async function pageText() {
429
431
  return stdout || "";
430
432
  }
431
433
 
432
- function parseSnapshotEntries(snapshot) {
433
- return snapshot
434
- .split("\n")
435
- .map((line) => {
436
- const refMatch = line.match(/\bref=(e\d+)\b/);
437
- if (!refMatch) return undefined;
438
- const kindMatch = line.match(/^\s*-\s*([^\s]+)/);
439
- const quotedMatch = line.match(/"([^"]*)"/);
440
- const valueMatch = line.match(/:\s*(.+)$/);
441
- return {
442
- line,
443
- ref: `@${refMatch[1]}`,
444
- kind: kindMatch ? kindMatch[1] : undefined,
445
- label: quotedMatch ? quotedMatch[1] : undefined,
446
- value: valueMatch ? valueMatch[1].trim() : undefined,
447
- disabled: /\bdisabled\b/.test(line),
448
- };
449
- })
450
- .filter(Boolean);
451
- }
452
-
453
434
  function findEntry(snapshot, predicate) {
454
435
  return parseSnapshotEntries(snapshot).find(predicate);
455
436
  }
@@ -466,17 +447,6 @@ async function clickRef(ref, logLabel = `click ${ref}`) {
466
447
  await targetCommand("click", ref, { logLabel });
467
448
  }
468
449
 
469
- function stripQuery(url) {
470
- try {
471
- const parsed = new URL(url);
472
- parsed.hash = "";
473
- parsed.search = "";
474
- return parsed.toString();
475
- } catch {
476
- return url;
477
- }
478
- }
479
-
480
450
  function preferredProvider() {
481
451
  return config?.defaults?.provider === "grok" ? "grok" : "chatgpt";
482
452
  }
@@ -491,7 +461,7 @@ function providerName() {
491
461
 
492
462
  function cookieOrigins() {
493
463
  const providerOrigins = preferredProvider() === "grok" ? GROK_COOKIE_ORIGINS : CHATGPT_COOKIE_ORIGINS;
494
- return Array.from(new Set([stripQuery(providerChatUrl()), ...providerOrigins]));
464
+ return Array.from(new Set([stripUrlQueryAndHash(providerChatUrl()), ...providerOrigins]));
495
465
  }
496
466
 
497
467
  function cookieSource() {
@@ -19,6 +19,7 @@ import {
19
19
  } from "../shared/job-coordination-helpers.mjs";
20
20
  import { applyOracleJobCleanupWarnings, clearOracleJobCleanupState, transitionOracleJobPhase } from "../shared/job-lifecycle-helpers.mjs";
21
21
  import { spawnDetachedNodeProcess, terminateTrackedProcess } from "../shared/process-helpers.mjs";
22
+ import { getOracleJobsDir } from "../shared/state-path-helpers.mjs";
22
23
  import { extractArtifactLabels, FILE_LABEL_PATTERN_SOURCE, GENERIC_ARTIFACT_LABELS, parseSnapshotEntries, partitionStructuralArtifactCandidates } from "./artifact-heuristics.mjs";
23
24
  import {
24
25
  buildAllowedChatGptOrigins,
@@ -39,7 +40,8 @@ import {
39
40
  autoSwitchToThinkingSelectionVisible,
40
41
  stripChatGptResponseChrome,
41
42
  } from "./chatgpt-ui-helpers.mjs";
42
- import { assistantSnapshotSlice, nextStableValueState, providerSendAccepted, resolveStableConversationUrlCandidate, stripUrlQueryAndHash } from "./chatgpt-flow-helpers.mjs";
43
+ import { assistantSnapshotSlice, conversationIdFromUrl, nextStableValueState, providerSendAccepted, resolveStableConversationUrlCandidate, stripUrlQueryAndHash } from "./chatgpt-flow-helpers.mjs";
44
+ import { normalizeLoginProbeResult } from "./auth-flow-helpers.mjs";
43
45
  import { assertNotKnownBrowserUserDataPath, scrubSweetCookieSafeStoragePasswordEnv, sweetCookieSafeStoragePasswordScrubbedEnv } from "../shared/browser-profile-helpers.mjs";
44
46
  import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withLock } from "./state-locks.mjs";
45
47
 
@@ -49,9 +51,7 @@ if (!jobId) {
49
51
  process.exit(1);
50
52
  }
51
53
 
52
- const DEFAULT_ORACLE_JOBS_DIR = "/tmp";
53
- const ORACLE_JOBS_DIR = process.env.PI_ORACLE_JOBS_DIR?.trim() || DEFAULT_ORACLE_JOBS_DIR;
54
- const jobDir = join(ORACLE_JOBS_DIR, `oracle-${jobId}`);
54
+ const jobDir = join(getOracleJobsDir(), `oracle-${jobId}`);
55
55
  const jobPath = `${jobDir}/job.json`;
56
56
  const CHATGPT_LABELS = {
57
57
  composer: "Chat with ChatGPT",
@@ -294,17 +294,6 @@ async function cpSupportsApfsClone() {
294
294
  return cpSupportsApfsCloneFlag;
295
295
  }
296
296
 
297
- function parseConversationId(chatUrl) {
298
- if (!chatUrl) return undefined;
299
- try {
300
- const parsed = new URL(chatUrl);
301
- const match = parsed.pathname.match(/\/(?:c|chat)\/([^/?#]+)/i);
302
- return match?.[1];
303
- } catch {
304
- return undefined;
305
- }
306
- }
307
-
308
297
  async function removeChromiumProcessSingletonArtifacts(profileDir) {
309
298
  await Promise.all([
310
299
  rm(join(profileDir, "SingletonLock"), { force: true }),
@@ -763,21 +752,7 @@ async function evalPage(job, script) {
763
752
  }
764
753
 
765
754
  async function loginProbe(job) {
766
- const result = await evalPage(job, buildLoginProbeScript(5_000));
767
- if (!result || typeof result !== "object") {
768
- return { ok: false, status: 0, error: "invalid-probe-result" };
769
- }
770
- return {
771
- ok: result.ok === true,
772
- status: typeof result.status === "number" ? result.status : 0,
773
- pageUrl: typeof result.pageUrl === "string" ? result.pageUrl : undefined,
774
- domLoginCta: result.domLoginCta === true,
775
- onAuthPage: result.onAuthPage === true,
776
- error: typeof result.error === "string" ? result.error : undefined,
777
- bodyKeys: Array.isArray(result.bodyKeys) ? result.bodyKeys : [],
778
- bodyHasId: result.bodyHasId === true,
779
- bodyHasEmail: result.bodyHasEmail === true,
780
- };
755
+ return normalizeLoginProbeResult(await evalPage(job, buildLoginProbeScript(5_000)));
781
756
  }
782
757
 
783
758
  async function currentUrl(job) {
@@ -785,10 +760,6 @@ async function currentUrl(job) {
785
760
  return stdout;
786
761
  }
787
762
 
788
- function stripQuery(url) {
789
- return stripUrlQueryAndHash(url);
790
- }
791
-
792
763
  async function snapshotText(job) {
793
764
  const { stdout } = await agentBrowser(job, "snapshot", "-i");
794
765
  return stdout;
@@ -1829,7 +1800,7 @@ async function waitForStableChatUrl(job, previousChatUrl) {
1829
1800
  await sleep(1000);
1830
1801
  }
1831
1802
 
1832
- return previousChatUrl || stripQuery(await currentUrl(job));
1803
+ return previousChatUrl || stripUrlQueryAndHash(await currentUrl(job));
1833
1804
  }
1834
1805
 
1835
1806
  async function waitForChatCompletion(job, baselineAssistantCount) {
@@ -2198,7 +2169,7 @@ async function waitForStableArtifactCandidates(job, responseIndex, responseText
2198
2169
  }
2199
2170
 
2200
2171
  async function reopenConversationForArtifacts(job, responseIndex, responseText, reason) {
2201
- const targetUrl = job.chatUrl || stripQuery(await currentUrl(job));
2172
+ const targetUrl = job.chatUrl || stripUrlQueryAndHash(await currentUrl(job));
2202
2173
  await log(`Reopening conversation before artifact capture (${reason}): ${targetUrl}`);
2203
2174
  await agentBrowser(job, "open", targetUrl);
2204
2175
  await agentBrowser(job, "wait", "1500");
@@ -2397,9 +2368,9 @@ async function run() {
2397
2368
  await sleep(POST_SEND_SETTLE_MS);
2398
2369
 
2399
2370
  const observedChatUrl = isGrokJob(currentJob)
2400
- ? stripQuery(await currentUrl(currentJob))
2371
+ ? stripUrlQueryAndHash(await currentUrl(currentJob))
2401
2372
  : await waitForStableChatUrl(currentJob, currentJob.chatUrl);
2402
- const observedConversationId = parseConversationId(observedChatUrl) || currentJob.conversationId;
2373
+ const observedConversationId = conversationIdFromUrl(observedChatUrl) || currentJob.conversationId;
2403
2374
  const awaitingResponsePatch = {
2404
2375
  heartbeatAt: new Date().toISOString(),
2405
2376
  ...(observedConversationId ? { chatUrl: observedChatUrl, conversationId: observedConversationId } : {}),
@@ -2414,7 +2385,7 @@ async function run() {
2414
2385
  const completion = await waitForChatCompletion(currentJob, baselineAssistantCount);
2415
2386
  if (isGrokJob(currentJob) && !currentJob.conversationId) {
2416
2387
  const stableGrokChatUrl = await waitForStableChatUrl(currentJob, undefined);
2417
- const stableGrokConversationId = parseConversationId(stableGrokChatUrl);
2388
+ const stableGrokConversationId = conversationIdFromUrl(stableGrokChatUrl);
2418
2389
  if (!stableGrokConversationId) {
2419
2390
  throw new Error(`Grok response completed but the conversation URL did not stabilize; current URL: ${stableGrokChatUrl || "(unknown)"}`);
2420
2391
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.7.16",
3
+ "version": "0.7.18",
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",
@@ -48,7 +48,7 @@
48
48
  ]
49
49
  },
50
50
  "scripts": {
51
- "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-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-coding-agent --external:typebox --outfile=/tmp/pi-oracle-extension-check.js",
51
+ "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
52
  "typecheck": "tsc --noEmit -p tsconfig.json",
53
53
  "typecheck:worker-helpers": "tsc --noEmit -p tsconfig.worker-helpers.json",
54
54
  "sanity:oracle": "node scripts/oracle-sanity-runner.mjs",
@@ -66,7 +66,7 @@
66
66
  "smoke:real": "npm run smoke:real:packed",
67
67
  "smoke:real:doctor": "node scripts/oracle-real-smoke.mjs doctor",
68
68
  "release:check": "npm run verify:oracle && npm run release:proof:chatgpt-presets && npm run smoke:platform:all",
69
- "check:oracle-real-smoke": "node --check scripts/oracle-real-smoke.mjs",
69
+ "check:oracle-real-smoke": "node --check scripts/oracle-real-smoke.mjs && node --check scripts/oracle-sanity-runner.mjs",
70
70
  "check:oracle-release-proof": "node --check scripts/oracle-chatgpt-preset-proof.mjs",
71
71
  "release:proof:chatgpt-presets": "node scripts/oracle-chatgpt-preset-proof.mjs check",
72
72
  "smoke:real:packed": "node scripts/oracle-real-smoke.mjs run --mode packed",
@@ -78,6 +78,7 @@
78
78
  "@steipete/sweet-cookie": "^0.3.0"
79
79
  },
80
80
  "peerDependencies": {
81
+ "@earendil-works/pi-ai": "*",
81
82
  "@earendil-works/pi-coding-agent": "*",
82
83
  "typebox": "*"
83
84
  },
@@ -86,8 +87,8 @@
86
87
  "protobufjs": "7.6.1"
87
88
  },
88
89
  "devDependencies": {
89
- "@earendil-works/pi-ai": "^0.80.2",
90
- "@earendil-works/pi-coding-agent": "^0.80.2",
90
+ "@earendil-works/pi-ai": "^0.80.7",
91
+ "@earendil-works/pi-coding-agent": "^0.80.7",
91
92
  "@types/node": "^22.19.19",
92
93
  "esbuild": "^0.28.0",
93
94
  "tsx": "^4.22.3",
@@ -104,6 +105,9 @@
104
105
  ],
105
106
  "packageManager": "npm@11.16.0",
106
107
  "peerDependenciesMeta": {
108
+ "@earendil-works/pi-ai": {
109
+ "optional": true
110
+ },
107
111
  "@earendil-works/pi-coding-agent": {
108
112
  "optional": true
109
113
  },
@@ -6,6 +6,12 @@ You are preparing an `/oracle-followup` job.
6
6
 
7
7
  Do not answer the user's request directly yet.
8
8
 
9
+ Hard requirements:
10
+ - Do not plan instead of submitting. The point of `/oracle-followup` is dispatch.
11
+ - Do not claim preflight, auth, archive prep, or submission happened unless the matching tool call actually happened in this turn.
12
+ - If a required tool call is unavailable or fails, stop and report that exact blocker instead of fabricating progress.
13
+ - After a successful or queued `oracle_submit`, your final answer must be only a terse dispatch summary with the job id and response path. Do not ask questions, offer to watch/poll/read, list next steps, or continue working.
14
+
9
15
  Required workflow:
10
16
  1. Parse the user request as `<job-id> <follow-up request>`.
11
17
  2. If the request does not include both a prior oracle job id and a follow-up request, stop and report: `Usage: /oracle-followup <job-id> <request>. Find the job id in the earlier oracle response or via /oracle-status.`
@@ -14,7 +20,7 @@ Required workflow:
14
20
  5. If `oracle_preflight` reports `ready: false`, stop before any expensive prep. Do not read files, search the codebase, prepare archive inputs, or call `oracle_auth` automatically. Report the blocking issue plus the suggested next step.
15
21
  6. Treat the parsed job id as `followUpJobId` for `oracle_submit`.
16
22
  7. Understand whether the follow-up request is explicitly narrow or genuinely broad.
17
- 8. Gather enough repo context to choose archive inputs and write a strong oracle prompt. Bias toward context-rich submissions when they fit within the provider archive ceiling: 250 MB for ChatGPT, 200 MiB for Grok.
23
+ 8. Gather enough repo context to choose archive inputs and write a strong oracle prompt. Bias toward context-rich submissions when they fit within the provider archive ceiling: 250 MiB for ChatGPT, 200 MiB for Grok.
18
24
  9. If the follow-up request is explicit and narrow, start from the directly relevant area but still include nearby files, tests, docs, configs, and adjacent modules when they may improve answer quality. Keep the archive tightly minimal only when the user explicitly asks for that, privacy/sensitivity requires it, or size pressure forces it.
19
25
  10. If the follow-up request is broad, architectural, release-oriented, or otherwise repo-wide, gather broader context and usually archive `.`.
20
26
  11. Choose archive inputs for the follow-up oracle job.
@@ -35,7 +41,7 @@ Rules:
35
41
  - If the immediately preceding oracle run for this follow-up failed because ChatGPT/Grok login is required, the worker said to rerun `/oracle-auth`, or stale auth clearly blocked execution, stop and tell the user to run `/oracle-auth` (or `/oracle-auth grok` for Grok). Do not call `oracle_auth` automatically from `/oracle-followup`.
36
42
  - This prompt exists so normal users can continue the same provider thread without manually constructing `followUpJobId` tool calls.
37
43
  - Always include an archive. Do not submit without context files.
38
- - By default, prefer context-rich archives up to the provider ceiling because more relevant context is usually better than less. The ceiling is 250 MB for ChatGPT and 200 MiB for Grok. For broad or unclear follow-up requests, include the whole repository by passing `.`. Default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like `.env` files, key material, credential dotfiles, local database files, and nested `secrets/` directories anywhere in the repo.
44
+ - By default, prefer context-rich archives up to the provider ceiling because more relevant context is usually better than less. The ceiling is 250 MiB for ChatGPT and 200 MiB for Grok. For broad or unclear follow-up requests, include the whole repository by passing `.`. Default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like `.env` files, key material, credential dotfiles, local database files, and nested `secrets/` directories anywhere in the repo.
39
45
  - Only limit file selection if the user explicitly requests a tight archive, if privacy/sensitivity requires it, or if the archive would otherwise exceed the size limit after exclusions/pruning.
40
46
  - For targeted follow-ups, still include directly related surrounding files, tests, docs, configs, and adjacent modules when they may improve answer quality. Do not default to a one-file archive just because the user mentioned one file, one function, or one stack trace.
41
47
  - Do not keep exploring once you already have enough context to submit well.
package/prompts/oracle.md CHANGED
@@ -17,7 +17,7 @@ Required workflow:
17
17
  1. Call `oracle_preflight` immediately. If the user says to use Grok, pass `provider: "grok"` to `oracle_preflight`. If the user says to use ChatGPT, pass `provider: "chatgpt"`. If the user says to use ChatGPT Instant, pass `provider: "chatgpt"` and later call `oracle_submit` with `preset: "instant"`. If the user explicitly provides an existing ChatGPT conversation id or `https://chatgpt.com/c/...` URL, pass it as `chatGptConversationId` to `oracle_preflight` and force `provider: "chatgpt"`. Omit `chatGptConversationId` unless the user explicitly asks to continue an existing browser-created ChatGPT thread.
18
18
  2. If `oracle_preflight` reports `ready: false`, stop before any expensive prep. Do not read files, search the codebase, prepare archive inputs, or call `oracle_auth` automatically. Report the blocking issue plus the suggested next step.
19
19
  3. Understand the request and decide whether it is explicitly narrow or genuinely broad.
20
- 4. Gather enough repo context to choose archive inputs and write a strong oracle prompt. Bias toward context-rich submissions when they fit within the provider archive ceiling: 250 MB for ChatGPT, 200 MiB for Grok.
20
+ 4. Gather enough repo context to choose archive inputs and write a strong oracle prompt. Bias toward context-rich submissions when they fit within the provider archive ceiling: 250 MiB for ChatGPT, 200 MiB for Grok.
21
21
  5. If the user scope is explicit and narrow, start from the directly relevant area but still include nearby files, tests, docs, configs, and adjacent modules when they may improve answer quality. Keep the archive tightly minimal only when the user explicitly asks for that, privacy/sensitivity requires it, or size pressure forces it.
22
22
  6. If the request is broad, architectural, release-oriented, or otherwise repo-wide, gather broader context and usually archive `.`.
23
23
  7. Choose archive inputs for the oracle job.
@@ -40,7 +40,7 @@ Rules:
40
40
  - Use `oracle_preflight` before any expensive `/oracle` preparation so missing persisted-session or local auth/config blockers fail fast.
41
41
  - If ChatGPT/Grok login is required, the worker said to rerun `/oracle-auth`, or stale auth clearly blocked execution, stop and tell the user to run `/oracle-auth` (or `/oracle-auth grok` for Grok). Do not call `oracle_auth` automatically from `/oracle`.
42
42
  - Always include an archive. Do not submit without context files.
43
- - By default, prefer context-rich archives up to the provider ceiling because more relevant context is usually better than less. The ceiling is 250 MB for ChatGPT and 200 MiB for Grok. For broad or unclear requests, include the whole repository by passing `.`. Default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like `.env` files, key material, credential dotfiles, local database files, and nested `secrets/` directories anywhere in the repo.
43
+ - By default, prefer context-rich archives up to the provider ceiling because more relevant context is usually better than less. The ceiling is 250 MiB for ChatGPT and 200 MiB for Grok. For broad or unclear requests, include the whole repository by passing `.`. Default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like `.env` files, key material, credential dotfiles, local database files, and nested `secrets/` directories anywhere in the repo.
44
44
  - Only limit file selection if the user explicitly requests a tight archive, if privacy/sensitivity requires it, or if the archive would otherwise exceed the size limit after exclusions/pruning.
45
45
  - For targeted asks, still include directly related surrounding files, tests, docs, configs, and adjacent modules when they may improve answer quality. Do not default to a one-file archive just because the user mentioned one file, one function, or one stack trace.
46
46
  - Do not keep exploring once you already have enough context to submit well.
@@ -5,17 +5,13 @@
5
5
  import { spawn } from "node:child_process";
6
6
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
7
7
  import { mkdtempSync } from "node:fs";
8
- import { createRequire } from "node:module";
9
8
  import { tmpdir } from "node:os";
10
9
  import { basename, dirname, join, resolve } from "node:path";
11
- import { pathToFileURL } from "node:url";
12
-
13
- const require = createRequire(import.meta.url);
14
- const tsxCli = require.resolve("tsx/cli");
15
10
 
16
11
  const DEFAULT_PROVIDER = "zai";
17
12
  const DEFAULT_MODEL = "glm-5.2";
18
13
  const DEFAULT_TIMEOUT_MS = 180_000;
14
+ const EXPECTED_PI_VERSION = "0.80.7";
19
15
  const PACKAGE_NAME = "pi-oracle";
20
16
 
21
17
  function usage() {
@@ -31,7 +27,7 @@ Environment:
31
27
  PI_ORACLE_REAL_TEST_TIMEOUT_MS per-agent timeout in ms (default: ${DEFAULT_TIMEOUT_MS})
32
28
  PI_ORACLE_REAL_TEST_ARTIFACT_ROOT artifact root (default: .artifacts/real-smoke)
33
29
  PI_ORACLE_REAL_TEST_KEEP_TMP keep temporary fixture directory when set to 1/true/yes
34
- PI_ORACLE_REAL_TEST_MODEL_AGENT run oracle_submit through a model-agent turn instead of direct installed-tool execution (off by default)
30
+ PI_ORACLE_REAL_TEST_MODEL_AGENT run oracle_submit through a credentialed model-agent turn instead of the safe loader command (off by default)
35
31
  PI_ORACLE_REAL_TEST_NEGATIVE_SYMLINK run optional second-agent symlink rejection check (off by default; covered by sanity:oracle)
36
32
  `);
37
33
  }
@@ -61,6 +57,12 @@ function truthy(value) {
61
57
  return ["1", "true", "yes", "on"].includes(String(value ?? "").toLowerCase());
62
58
  }
63
59
 
60
+ function positiveTimeoutMs(value, label) {
61
+ const timeoutMs = Number(value ?? DEFAULT_TIMEOUT_MS);
62
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error(`${label} must be a finite positive millisecond value`);
63
+ return timeoutMs;
64
+ }
65
+
64
66
  function commandExists(command, args = ["--version"]) {
65
67
  return new Promise((resolvePromise) => {
66
68
  const child = spawn(command, args, { stdio: "ignore", shell: process.platform === "win32" });
@@ -130,7 +132,7 @@ async function doctor() {
130
132
  }
131
133
 
132
134
  function runCommand(command, args, options) {
133
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
135
+ const timeoutMs = positiveTimeoutMs(options.timeoutMs, "command timeout");
134
136
  return new Promise((resolvePromise) => {
135
137
  const child = spawn(command, args, {
136
138
  cwd: options.cwd,
@@ -293,39 +295,14 @@ function hasCreatedJobArchive(jobsDir) {
293
295
  return Boolean(jobDir && archivePath && existsSync(archivePath));
294
296
  }
295
297
 
296
- async function runDirectOracleSubmit({ prepared, agentDir, sessionDir, jobsDir, outDir, timeoutMs }) {
298
+ async function runPiLoaderStatus({ prepared, agentDir, sessionDir, jobsDir, outDir, timeoutMs }) {
297
299
  mkdirSync(outDir, { recursive: true });
298
- const sessionFile = join(sessionDir, "real-smoke-session.jsonl");
299
- const fakeWorkerPath = join(outDir, "fake-worker.mjs");
300
- const scriptPath = join(outDir, "direct-submit.mjs");
301
- const packageRoot = prepared.mode === "packed" ? join(prepared.cwd, "node_modules", PACKAGE_NAME) : process.cwd();
302
- const toolsUrl = pathToFileURL(join(packageRoot, "extensions", "oracle", "lib", "tools.ts")).href;
303
300
  mkdirSync(sessionDir, { recursive: true });
304
- writeFileSync(sessionFile, "");
305
- writeFileSync(fakeWorkerPath, "process.exit(0);\n");
306
- writeFileSync(scriptPath, `
307
- import { registerOracleTools } from ${JSON.stringify(toolsUrl)};
308
- const tools = new Map();
309
- const pi = {
310
- on() { return undefined; },
311
- registerTool(tool) { tools.set(tool.name, tool); },
312
- };
313
- registerOracleTools(pi, ${JSON.stringify(fakeWorkerPath)});
314
- const submit = tools.get("oracle_submit");
315
- if (!submit) throw new Error("oracle_submit was not registered by the installed package");
316
- const ctx = {
317
- cwd: process.cwd(),
318
- sessionManager: { getSessionFile() { return ${JSON.stringify(sessionFile)}; } },
319
- };
320
- const result = await submit.execute("real-smoke", {
321
- prompt: "Real smoke archive test. Reply OK if this reaches the provider.",
322
- files: ["."],
323
- preset: "instant",
324
- }, new AbortController().signal, () => undefined, ctx);
325
- console.log(JSON.stringify(result, null, 2));
326
- `);
327
- writeFileSync(join(outDir, "command.json"), `${JSON.stringify({ command: process.execPath, args: [tsxCli, scriptPath], cwd: prepared.cwd, mode: prepared.mode, extensionPath: prepared.extensionPath }, null, 2)}\n`);
328
- const result = await runCommand(process.execPath, [tsxCli, scriptPath], {
301
+ const args = ["--mode", "json", "--no-session", prepared.mode === "packed" ? "--approve" : "--no-approve", "--session-dir", sessionDir];
302
+ if (prepared.mode === "source") args.push("--no-extensions", "-e", prepared.extensionPath);
303
+ args.push("/oracle-status");
304
+ writeFileSync(join(outDir, "command.json"), `${JSON.stringify({ command: piCommand(), args, cwd: prepared.cwd, mode: prepared.mode, extensionPath: prepared.extensionPath }, null, 2)}\n`);
305
+ const result = await runCommand(piCommand(), args, {
329
306
  cwd: prepared.cwd,
330
307
  env: {
331
308
  ...process.env,
@@ -335,9 +312,9 @@ console.log(JSON.stringify(result, null, 2));
335
312
  },
336
313
  timeoutMs,
337
314
  });
338
- writeRunResult(outDir, "direct-submit", result);
339
- if (result.timedOut) throw new Error(`direct oracle_submit timed out after ${timeoutMs}ms`);
340
- if (result.code !== 0) throw new Error(`direct oracle_submit exited ${result.code}; see ${join(outDir, "direct-submit.stderr.txt")}`);
315
+ writeRunResult(outDir, "pi-loader", result);
316
+ if (result.timedOut) throw new Error(`Pi loader smoke timed out after ${timeoutMs}ms`);
317
+ if (result.code !== 0) throw new Error(`Pi loader smoke exited ${result.code}; see ${join(outDir, "pi-loader.stderr.txt")}`);
341
318
  return result;
342
319
  }
343
320
 
@@ -375,14 +352,16 @@ async function runPiAgent({ prepared, agentDir, sessionDir, jobsDir, prompt, out
375
352
  async function run(mode = "packed") {
376
353
  const provider = env("PI_ORACLE_REAL_TEST_PROVIDER") ?? DEFAULT_PROVIDER;
377
354
  const model = env("PI_ORACLE_REAL_TEST_MODEL") ?? DEFAULT_MODEL;
378
- const timeoutMs = Number(env("PI_ORACLE_REAL_TEST_TIMEOUT_MS") ?? DEFAULT_TIMEOUT_MS);
355
+ const timeoutMs = positiveTimeoutMs(env("PI_ORACLE_REAL_TEST_TIMEOUT_MS"), "PI_ORACLE_REAL_TEST_TIMEOUT_MS");
379
356
  const artifactRoot = resolve(env("PI_ORACLE_REAL_TEST_ARTIFACT_ROOT") ?? ".artifacts/real-smoke");
380
357
  const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
381
358
  const runDir = join(artifactRoot, runId);
382
- const tmpRoot = mkdtempSync(join(tmpdir(), "pi-oracle-real-smoke-"));
383
359
  const assertions = [];
384
360
 
385
361
  mkdirSync(runDir, { recursive: true });
362
+ const piVersion = await mustRun(runDir, "pi-version", piCommand(), ["--version"], { cwd: process.cwd(), env: process.env, timeoutMs: 30_000 });
363
+ if (piVersion.stdout.trim() !== EXPECTED_PI_VERSION) throw new Error(`real smoke requires Pi ${EXPECTED_PI_VERSION}; found ${piVersion.stdout.trim() || "unknown"}`);
364
+ const tmpRoot = mkdtempSync(join(tmpdir(), "pi-oracle-real-smoke-"));
386
365
  const prepared = mode === "packed"
387
366
  ? await preparePackedProject({ runDir, provider, model, timeoutMs })
388
367
  : prepareSourceProject({ provider, model });
@@ -405,7 +384,8 @@ async function run(mode = "packed") {
405
384
  mkdirSync(sessions1, { recursive: true });
406
385
  mkdirSync(jobs1, { recursive: true });
407
386
 
408
- const submitResult = truthy(env("PI_ORACLE_REAL_TEST_MODEL_AGENT"))
387
+ const modelAgent = truthy(env("PI_ORACLE_REAL_TEST_MODEL_AGENT"));
388
+ const submitResult = modelAgent
409
389
  ? await runPiAgent({
410
390
  prepared,
411
391
  agentDir: agent1,
@@ -417,7 +397,7 @@ async function run(mode = "packed") {
417
397
  stopAfterJobArchive: true,
418
398
  prompt: 'Call oracle_submit directly with prompt "Real smoke archive test. Reply OK if this reaches the provider." files ["."] and preset "instant". Do not use bash, read, edit, write, or any tool except oracle_submit.',
419
399
  })
420
- : await runDirectOracleSubmit({
400
+ : await runPiLoaderStatus({
421
401
  prepared,
422
402
  agentDir: agent1,
423
403
  sessionDir: sessions1,
@@ -427,16 +407,21 @@ async function run(mode = "packed") {
427
407
  });
428
408
 
429
409
  const jobDir1 = latestJobDir(jobs1);
430
- const archivePath = parseJobArchivePath(jobDir1);
431
- writeFileSync(join(test1, "job-dir.txt"), `${jobDir1 ?? ""}\n`);
432
- writeFileSync(join(test1, "archive-path.txt"), `${archivePath ?? ""}\n`);
433
- assert("whole-repo-job-created", Boolean(jobDir1), `whole-repo submit did not create an oracle job; stdout=${submitResult.stdout.slice(-1000)} stderr=${submitResult.stderr.slice(-1000)}`);
434
- assert("whole-repo-archive-created", Boolean(archivePath && existsSync(archivePath)), `whole-repo submit did not create a readable archive; jobDir=${jobDir1 ?? "<none>"} stdout=${submitResult.stdout.slice(-1000)} stderr=${submitResult.stderr.slice(-1000)}`);
435
- const entries = await tarList(archivePath);
436
- writeFileSync(join(test1, "archive-list.txt"), `${entries.join("\n")}\n`);
437
- assert("archive-includes-readme", entryExists(entries, "README.md"), "archive should include README.md");
438
- for (const excluded of [".pi", ".oracle-context", ".scratchpad.md", ".artifacts", ".crabbox", ".debug"]) {
439
- assert(`archive-excludes-${excluded.replace(/[^a-z0-9]+/gi, "-")}`, !entryExists(entries, excluded), `archive should exclude ${excluded}`);
410
+ if (modelAgent) {
411
+ const archivePath = parseJobArchivePath(jobDir1);
412
+ writeFileSync(join(test1, "job-dir.txt"), `${jobDir1 ?? ""}\n`);
413
+ writeFileSync(join(test1, "archive-path.txt"), `${archivePath ?? ""}\n`);
414
+ assert("whole-repo-job-created", Boolean(jobDir1), `whole-repo submit did not create an oracle job; stdout=${submitResult.stdout.slice(-1000)} stderr=${submitResult.stderr.slice(-1000)}`);
415
+ assert("whole-repo-archive-created", Boolean(archivePath && existsSync(archivePath)), `whole-repo submit did not create a readable archive; jobDir=${jobDir1 ?? "<none>"} stdout=${submitResult.stdout.slice(-1000)} stderr=${submitResult.stderr.slice(-1000)}`);
416
+ const entries = await tarList(archivePath);
417
+ writeFileSync(join(test1, "archive-list.txt"), `${entries.join("\n")}\n`);
418
+ assert("archive-includes-readme", entryExists(entries, "README.md"), "archive should include README.md");
419
+ for (const excluded of [".pi", ".oracle-context", ".scratchpad.md", ".artifacts", ".crabbox", ".debug"]) {
420
+ assert(`archive-excludes-${excluded.replace(/[^a-z0-9]+/gi, "-")}`, !entryExists(entries, excluded), `archive should exclude ${excluded}`);
421
+ }
422
+ } else {
423
+ assert("pi-loader-command-output", submitResult.stdout.includes('"customType":"oracle-command-output"'), "Pi loader smoke should execute the oracle command through the loaded extension");
424
+ assert("pi-loader-no-external-job", !jobDir1, "safe Pi loader smoke should not create an oracle job");
440
425
  }
441
426
  if (prepared.mode === "packed") {
442
427
  assert("packed-mode-no-source-extension", !readFileSync(join(test1, "command.json"), "utf8").includes("extensions/oracle/index.ts"), "packed real smoke must not use source extension path");
@@ -101,7 +101,8 @@ function testCanonicalWorkflowConfig() {
101
101
 
102
102
  function testRealSmokeExpensiveAgentPathsAreOptIn() {
103
103
  const source = readFileSync(new URL("../oracle-real-smoke.mjs", import.meta.url), "utf8");
104
- assert.match(source, /runDirectOracleSubmit/, "default real smoke should use deterministic installed-tool execution");
104
+ assert.match(source, /runPiLoaderStatus/, "default real smoke should execute a deterministic command through Pi's extension loader");
105
+ assert.doesNotMatch(source, /tsx\/cli|runDirectOracleSubmit/, "default real smoke must not bypass Pi's loader through checkout tsx");
105
106
  assert.match(source, /PI_ORACLE_REAL_TEST_MODEL_AGENT/, "real smoke should expose the optional model-agent toggle");
106
107
  const targetsSource = readFileSync(new URL("./targets.mjs", import.meta.url), "utf8");
107
108
  assert.match(targetsSource, /PI_ORACLE_REAL_TEST_MODEL_AGENT/, "platform real-extension command should forward the optional model-agent toggle");