Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

`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.

> Status: experimental public beta. Validated primarily on macOS with Google Chrome/Chromium and `pi` 0.65.0+. Normal oracle jobs run in an isolated browser profile, not your active browser window.
> Status: experimental public beta. Validated primarily on macOS with Google Chrome/Chromium and `pi` 0.65.0+. Native Windows support is beta and requires the local prerequisites listed below. Normal oracle jobs run in an isolated browser profile, not your active browser window.

## What a successful run looks like

Expand All @@ -14,7 +14,7 @@ pi-oracle:
2. builds a context-rich `.tar.zst` repo archive
3. starts an isolated provider web runtime in the background
4. uploads the archive and prompt to the selected provider
5. saves the response/artifacts under /tmp/oracle-<job-id>/
5. saves the response/artifacts under the oracle jobs temp directory
6. sends a best-effort wake-up back to the matching pi session

Later: /oracle-read <job-id>
Expand All @@ -37,7 +37,7 @@ Do not use it for:

- short local coding tasks that `pi` can handle directly
- projects that must never be uploaded to ChatGPT.com, Grok, or another configured web provider
- non-macOS environments or machines without the required local browser/tooling
- machines without the required local browser/tooling

## Problem it solves

Expand All @@ -51,7 +51,7 @@ A normal coding-agent turn is the wrong shape for some work: the task may need a
| --- | --- | --- |
| Hard tasks need more context than a quick turn should gather. | `/oracle` prompts the agent to preflight, choose a context-rich archive, and submit it to the selected provider web app. | [`prompts/oracle.md`](prompts/oracle.md), `oracle_submit`, archive tests in `scripts/oracle-sanity-*` |
| Browser automation should not steal focus or mutate your active profile. | Jobs clone an authenticated seed profile into per-job isolated runtime profiles. | [`docs/ORACLE_DESIGN.md`](docs/ORACLE_DESIGN.md), [`extensions/oracle/lib/runtime.ts`](extensions/oracle/lib/runtime.ts) |
| Long jobs need durability. | Job state, responses, logs, and artifacts persist under `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/`. | [`extensions/oracle/lib/jobs.ts`](extensions/oracle/lib/jobs.ts), `/oracle-read`, `/oracle-status` |
| Long jobs need durability. | Job state, responses, logs, and artifacts persist under `PI_ORACLE_JOBS_DIR` or the platform temp directory. | [`extensions/oracle/lib/jobs.ts`](extensions/oracle/lib/jobs.ts), `/oracle-read`, `/oracle-status` |
| Provider auth can expire or drift. | `/oracle-auth [chatgpt|grok]` refreshes the isolated auth seed from a configured local Chromium profile, with recovery guidance. | [`extensions/oracle/lib/auth.ts`](extensions/oracle/lib/auth.ts), [`docs/ORACLE_RECOVERY_DRILL.md`](docs/ORACLE_RECOVERY_DRILL.md) |
| Agents need a simple API, not UI-driving instructions. | The package exposes agent-facing tools: `oracle_preflight`, `oracle_submit`, `oracle_read`, `oracle_auth`, and `oracle_cancel`. | [`extensions/oracle/lib/tools.ts`](extensions/oracle/lib/tools.ts) |

Expand All @@ -75,14 +75,21 @@ pi install https://github.com/fitchmultz/pi-oracle

You need:

- macOS
- macOS or Windows 10/11
- Node.js 22 or newer
- `pi` 0.65.0 or newer
- Google Chrome or another Chromium-family browser
- ChatGPT or Grok already signed in to the configured local browser profile for the provider you plan to use
- `agent-browser`, `tar`, and `zstd` available on the machine
- a normal persisted `pi` session, not `pi --no-session`

Windows notes:

- Install `agent-browser` so the `agent-browser` command is on `PATH`, for example `npm install -g agent-browser`.
- Install `zstd`, for example `winget install Facebook.Zstandard`, and open a new terminal afterward.
- Windows includes `tar` on supported Windows 10/11 installs. If `oracle_preflight` reports it missing, install a tar provider and make sure `tar` is on `PATH`.
- The default Windows Chrome profile is read from `%LOCALAPPDATA%\Google\Chrome\User Data`. If Chrome is somewhere else, set `browser.executablePath` in `%USERPROFILE%\.pi\agent\extensions\oracle.json`.

### 3. Sync provider auth once

```text
Expand All @@ -104,7 +111,7 @@ Expected result:
- `oracle_submit` creates or queues a job.
- If local packing is too large, the prompt treats that as a retryable archive-selection failure and narrows automatically before surfacing the problem.
- The job uploads a repo archive to the selected provider, capped at 250 MiB for ChatGPT or 200 MiB for Grok after default exclusions/pruning.
- The response is saved under `/tmp/oracle-<job-id>/response.md` by default.
- The response is saved under the platform temp oracle job directory by default (`/tmp/oracle-<job-id>/response.md` on macOS, `%TEMP%\\oracle-<job-id>\\response.md` on Windows).
- The matching `pi` session gets one best-effort wake-up when the job finishes.

If the wake-up does not arrive, run:
Expand Down Expand Up @@ -204,7 +211,7 @@ Notes:

### Custom Chromium cookie sources

Use this only for a Chromium-family browser that the default cookie importer cannot read.
Use this only on macOS for a Chromium-family browser that the default cookie importer cannot read. The default Google Chrome importer handles normal Chrome profiles on Windows without `auth.chromiumKeychain`.

Before running `/oracle-auth` with this path:

Expand Down Expand Up @@ -261,7 +268,7 @@ For ChatGPT, `oracle_submit` accepts canonical preset ids or a matching human-re

## Outputs and cleanup

- Jobs persist response text, metadata, logs, and artifacts under `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/` by default.
- Jobs persist response text, metadata, logs, and artifacts under `PI_ORACLE_JOBS_DIR` or the platform temp directory by default (`/tmp` on macOS, `%TEMP%` on Windows).
- Jobs can queue automatically when runtime capacity is full.
- Completion delivery into `pi` is one-time best-effort wake-up based.
- `/oracle-read [job-id]` and `oracle_read({ jobId })` inspect saved output later.
Expand All @@ -279,7 +286,7 @@ Review the code and design docs before using it with private or regulated materi

## Current limits

- Experimental public beta, validated primarily on macOS.
- Experimental public beta, validated primarily on macOS; Windows support is beta.
- Provider UI, auth, model controls, and artifact download behavior can drift.
- Archive uploads are capped at 250 MiB for ChatGPT and 200 MiB for Grok after default exclusions and automatic whole-repo pruning.
- A real ChatGPT or Grok web session is required for the provider you use.
Expand Down Expand Up @@ -342,7 +349,7 @@ Install the missing local dependency and rerun the command.

### You want more details about a failed run

Inspect the job directory under `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/`. The worker log and captured diagnostics are stored there.
Inspect the job directory under `PI_ORACLE_JOBS_DIR` or the platform temp directory (`/tmp/oracle-<job-id>/` on macOS, `%TEMP%\\oracle-<job-id>\\` on Windows). The worker log and captured diagnostics are stored there.

## Verification

Expand Down
6 changes: 4 additions & 2 deletions docs/ORACLE_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ Browser/auth settings are global-only because they control local privileged brow
}
```

`browser.cloneStrategy` defaults to `apfs-clone` on macOS and `copy` on Windows. The `auth.chromiumKeychain` path is macOS-only; normal Google Chrome profiles on Windows use the default cookie importer.

`auth.chromiumKeychain` is an opt-in alternate cookie source for Chromium-family browsers that are not handled by the default `@steipete/sweet-cookie` Chrome-compatible importer. It must be configured with `auth.chromeCookiePath`; partial config is rejected so `/oracle-auth` cannot silently fall back to a different browser profile.

When both `auth.chromeCookiePath` and `auth.chromiumKeychain` are present, auth bootstrap:
Expand Down Expand Up @@ -326,10 +328,10 @@ Cleanup warnings are treated as diagnostics, not silent no-ops:

## Job layout under the configured jobs dir

Default location: `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/`
Default location: `${PI_ORACLE_JOBS_DIR}` when set, otherwise `/tmp/oracle-<job-id>/` on macOS or `%TEMP%\\oracle-<job-id>\\` on Windows.

```text
${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/
<oracle-jobs-dir>/oracle-<job-id>/
job.json
prompt.md
context-<job-id>.tar.zst
Expand Down
57 changes: 49 additions & 8 deletions extensions/oracle/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { isAbsolute, join, normalize } from "node:path";
import { isAbsolute, join, normalize, parse, sep } from "node:path";
import { getProjectId } from "./runtime.js";

export const ORACLE_PROVIDERS = ["chatgpt", "grok"] as const;
Expand Down Expand Up @@ -226,6 +226,14 @@ const ALLOWED_CHATGPT_ORIGINS = new Set(["https://chatgpt.com", "https://chat.op
const PROJECT_OVERRIDE_KEYS = new Set(["defaults", "worker", "poller", "artifacts", "cleanup"]);
const DEFAULT_MAC_CHROME_EXECUTABLE = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const DEFAULT_MAC_CHROME_USER_DATA_DIR = join(homedir(), "Library", "Application Support", "Google", "Chrome");
const DEFAULT_WINDOWS_CHROME_USER_DATA_DIR = process.env.LOCALAPPDATA
? join(process.env.LOCALAPPDATA, "Google", "Chrome", "User Data")
: undefined;
const DEFAULT_WINDOWS_CHROME_EXECUTABLE_CANDIDATES = [
process.env.PROGRAMFILES ? join(process.env.PROGRAMFILES, "Google", "Chrome", "Application", "chrome.exe") : undefined,
process.env["PROGRAMFILES(X86)"] ? join(process.env["PROGRAMFILES(X86)"], "Google", "Chrome", "Application", "chrome.exe") : undefined,
process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe") : undefined,
].filter((candidate): candidate is string => Boolean(candidate));

export interface OracleConfig {
defaults: {
Expand Down Expand Up @@ -273,8 +281,20 @@ export interface OracleConfig {
};
}

function getDefaultChromeUserDataDir(): string | undefined {
if (process.platform === "darwin") return DEFAULT_MAC_CHROME_USER_DATA_DIR;
if (process.platform === "win32") return DEFAULT_WINDOWS_CHROME_USER_DATA_DIR;
return undefined;
}

function detectDefaultChromeExecutablePath(): string | undefined {
return existsSync(DEFAULT_MAC_CHROME_EXECUTABLE) ? DEFAULT_MAC_CHROME_EXECUTABLE : undefined;
if (process.platform === "darwin") {
return existsSync(DEFAULT_MAC_CHROME_EXECUTABLE) ? DEFAULT_MAC_CHROME_EXECUTABLE : undefined;
}
if (process.platform === "win32") {
return DEFAULT_WINDOWS_CHROME_EXECUTABLE_CANDIDATES.find((candidate) => existsSync(candidate));
}
return undefined;
}

function detectDefaultChromeUserAgent(executablePath: string | undefined): string | undefined {
Expand All @@ -283,14 +303,17 @@ function detectDefaultChromeUserAgent(executablePath: string | undefined): strin
const versionOutput = execFileSync(executablePath, ["--version"], { encoding: "utf8" }).trim();
const versionMatch = versionOutput.match(/(\d+\.\d+\.\d+\.\d+)/);
if (!versionMatch) return undefined;
return `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${versionMatch[1]} Safari/537.36`;
const platformToken = process.platform === "win32" ? "Windows NT 10.0; Win64; x64" : "Macintosh; Intel Mac OS X 10_15_7";
return `Mozilla/5.0 (${platformToken}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${versionMatch[1]} Safari/537.36`;
} catch {
return undefined;
}
}

function detectDefaultChromeProfileName(): string {
const localStatePath = join(DEFAULT_MAC_CHROME_USER_DATA_DIR, "Local State");
const userDataDir = getDefaultChromeUserDataDir();
if (!userDataDir) return "Default";
const localStatePath = join(userDataDir, "Local State");
if (!existsSync(localStatePath)) return "Default";
try {
const localState = JSON.parse(readFileSync(localStatePath, "utf8")) as { profile?: { last_used?: string } };
Expand Down Expand Up @@ -367,7 +390,7 @@ export const DEFAULT_CONFIG: OracleConfig = {
authSeedProfileDir: join(agentExtensionsDir, "oracle-auth-seed-profile"),
runtimeProfilesDir: join(agentExtensionsDir, "oracle-runtime-profiles"),
maxConcurrentJobs: 2,
cloneStrategy: "apfs-clone",
cloneStrategy: process.platform === "darwin" ? "apfs-clone" : "copy",
chatUrl: "https://chatgpt.com/",
authUrl: "https://chatgpt.com/auth/login",
runMode: "headless",
Expand Down Expand Up @@ -452,11 +475,29 @@ function expectAbsoluteNormalizedPath(value: unknown, path: string): string {
return normalize(expanded);
}

function comparablePath(value: string): string {
const normalized = normalize(value);
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}

function pathEqualsOrIsInside(pathValue: string, parentPath: string): boolean {
const child = comparablePath(pathValue);
const parent = comparablePath(parentPath);
const parentWithSeparator = parent.endsWith(sep) ? parent : `${parent}${sep}`;
return child === parent || child.startsWith(parentWithSeparator);
}

function isUnsafeProfileRoot(pathValue: string): boolean {
const normalized = comparablePath(pathValue);
return normalized === comparablePath(parse(pathValue).root) || normalized === comparablePath(homedir());
}

function expectSafeProfilePath(pathValue: string, path: string): string {
if (pathValue === "/" || pathValue === homedir()) {
if (isUnsafeProfileRoot(pathValue)) {
throw new Error(`Invalid oracle config: ${path} points to an unsafe directory`);
}
if (pathValue === DEFAULT_MAC_CHROME_USER_DATA_DIR || pathValue.startsWith(`${DEFAULT_MAC_CHROME_USER_DATA_DIR}/`)) {
const defaultChromeUserDataDir = getDefaultChromeUserDataDir();
if (defaultChromeUserDataDir && pathEqualsOrIsInside(pathValue, defaultChromeUserDataDir)) {
throw new Error(`Invalid oracle config: ${path} must not point into the real Chrome user-data directory`);
}
return pathValue;
Expand Down Expand Up @@ -586,7 +627,7 @@ function validateOracleConfig(value: unknown): OracleConfig {

const authSeedProfileDir = expectSafeProfileDir(browser.authSeedProfileDir, "browser.authSeedProfileDir");
const runtimeProfilesDir = expectSafeProfileDir(browser.runtimeProfilesDir, "browser.runtimeProfilesDir");
if (runtimeProfilesDir === authSeedProfileDir || runtimeProfilesDir.startsWith(`${authSeedProfileDir}/`)) {
if (pathEqualsOrIsInside(runtimeProfilesDir, authSeedProfileDir)) {
throw new Error("Invalid oracle config: browser.runtimeProfilesDir must be separate from browser.authSeedProfileDir");
}

Expand Down
3 changes: 2 additions & 1 deletion extensions/oracle/lib/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { createHash, randomUUID } from "node:crypto";
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative as relativePath, resolve, sep } from "node:path";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import {
Expand Down Expand Up @@ -47,7 +48,7 @@ const ORACLE_JOB_DIR_RM_MAX_RETRIES = 5;
const ORACLE_JOB_DIR_RM_RETRY_DELAY_MS = 50;
const ORACLE_COMPLETE_JOB_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
const ORACLE_FAILED_JOB_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
export const DEFAULT_ORACLE_JOBS_DIR = "/tmp";
export const DEFAULT_ORACLE_JOBS_DIR = process.platform === "win32" ? tmpdir() : "/tmp";
export const ORACLE_JOBS_DIR_ENV = "PI_ORACLE_JOBS_DIR";
const ORACLE_JOBS_DIR = process.env[ORACLE_JOBS_DIR_ENV]?.trim() || DEFAULT_ORACLE_JOBS_DIR;

Expand Down
4 changes: 3 additions & 1 deletion extensions/oracle/lib/locks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// Invariants/Assumptions: All lock/lease paths live under the single configured oracle state directory for this machine.

import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
acquireStateLock,
createStateLease,
Expand All @@ -21,7 +23,7 @@ import {
writeStateLeaseMetadata,
} from "../shared/state-coordination-helpers.mjs";

export const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
export const DEFAULT_ORACLE_STATE_DIR = process.platform === "win32" ? join(tmpdir(), "pi-oracle-state") : "/tmp/pi-oracle-state";
export const ORACLE_STATE_DIR_ENV = "PI_ORACLE_STATE_DIR";
const ORACLE_STATE_DIR = process.env[ORACLE_STATE_DIR_ENV]?.trim() || DEFAULT_ORACLE_STATE_DIR;

Expand Down
Loading