Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added

- MCP: start long local consults with `waitForCompletion:false` and await their durable session state with the new non-cancelling `wait` tool. Fixes #429.

### Fixed

- Browser: attach to running Chrome when `DevToolsActivePort` metadata is absent, with IPv6 support and bounded endpoint retries that include response-body reads. Fixes #414. Thanks @devYRPauli!
Expand Down
82 changes: 27 additions & 55 deletions bin/oracle-cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/usr/bin/env node
import "dotenv/config";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { Command, Option } from "commander";
import type { OptionValues } from "commander";
Expand Down Expand Up @@ -52,6 +51,7 @@ import {
import { copyToClipboard } from "../src/cli/clipboard.js";
import { buildMarkdownBundle } from "../src/cli/markdownBundle.js";
import { shouldDetachSession, stopDetachedWorker } from "../src/cli/detach.js";
import { launchDetachedSession } from "../src/cli/detachedSession.js";
import { applyHiddenAliases } from "../src/cli/hiddenAliases.js";
import type { BrowserSessionRunnerDeps } from "../src/browser/sessionRunner.js";
import { isMediaFile } from "../src/browser/prompt.js";
Expand Down Expand Up @@ -2392,14 +2392,19 @@ async function runRootCommand(options: CliOptions): Promise<void> {
});
const workerPid = !detachAllowed
? undefined
: await launchDetachedSession(sessionMeta.id, async (pid) => {
lifecycle = buildSessionLifecycle({
engine,
detached: true,
workerPid: pid,
reattachCommand: `oracle session ${sessionMeta.id}`,
});
await sessionStore.updateSession(sessionMeta.id, { lifecycle });
: await launchDetachedSession({
sessionId: sessionMeta.id,
cliEntrypoint: CLI_ENTRYPOINT,
env: buildDetachedPerfTraceEnv(process.env, perfTraceArgs.value, sessionMeta.id),
prepare: async (pid) => {
lifecycle = buildSessionLifecycle({
engine,
detached: true,
workerPid: pid,
reattachCommand: `oracle session ${sessionMeta.id}`,
});
await sessionStore.updateSession(sessionMeta.id, { lifecycle });
},
}).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
console.log(
Expand Down Expand Up @@ -2515,44 +2520,6 @@ async function runInteractiveSession(
}
}

async function launchDetachedSession(
sessionId: string,
prepare: (pid: number) => Promise<void>,
): Promise<number> {
return new Promise((resolve, reject) => {
try {
const args = ["--", CLI_ENTRYPOINT, "--exec-session", sessionId];
const env = {
...buildDetachedPerfTraceEnv(process.env, perfTraceArgs.value, sessionId),
ORACLE_DETACHED_START_GATE: "1",
};
const child = spawn(process.execPath, args, {
detached: true,
stdio: ["pipe", "ignore", "ignore"],
env,
});
child.once("error", reject);
child.once("spawn", async () => {
if (child.pid === undefined) {
reject(new Error("Detached session worker started without a process ID."));
return;
}
try {
await prepare(child.pid);
child.stdin.end("ready\n");
child.unref();
resolve(child.pid);
} catch (error) {
child.kill();
reject(error);
}
});
} catch (error) {
reject(error);
}
});
}

async function waitForDetachedStartGate(): Promise<void> {
if (process.env.ORACLE_DETACHED_START_GATE !== "1") {
return;
Expand Down Expand Up @@ -2746,14 +2713,19 @@ async function restartSession(sessionId: string, options: RestartCommandOptions)
});
const workerPid = !detachAllowed
? undefined
: await launchDetachedSession(sessionMeta.id, async (pid) => {
lifecycle = buildSessionLifecycle({
engine,
detached: true,
workerPid: pid,
reattachCommand: `oracle session ${sessionMeta.id}`,
});
await sessionStore.updateSession(sessionMeta.id, { lifecycle });
: await launchDetachedSession({
sessionId: sessionMeta.id,
cliEntrypoint: CLI_ENTRYPOINT,
env: buildDetachedPerfTraceEnv(process.env, perfTraceArgs.value, sessionMeta.id),
prepare: async (pid) => {
lifecycle = buildSessionLifecycle({
engine,
detached: true,
workerPid: pid,
reattachCommand: `oracle session ${sessionMeta.id}`,
});
await sessionStore.updateSession(sessionMeta.id, { lifecycle });
},
}).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
console.log(
Expand Down
30 changes: 26 additions & 4 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ Claude Code can call `oracle-mcp` and ask a subscription-backed ChatGPT browser

### `consult`

- Inputs: `prompt` (required), `files?: string[]` (globs), `model?: string` (defaults to CLI), `engine?: "api" | "browser"` (optional; Oracle follows CLI defaults: `ORACLE_ENGINE` and the effective config first, then API when `OPENAI_API_KEY` is set, otherwise browser), `slug?: string`.
- Inputs: `prompt` (required), `files?: string[]` (globs), `model?: string` (defaults to CLI), `engine?: "api" | "browser"` (optional; Oracle follows CLI defaults: `ORACLE_ENGINE` and the effective config first, then API when `OPENAI_API_KEY` is set, otherwise browser), `waitForCompletion?: boolean`, `slug?: string`.
- Presets: `preset?: "chatgpt-pro-heavy"` applies browser mode + current Pro model alias + extended thinking, unless the request overrides those fields.
- Browser-only extras: `browserAttachments?: "auto"|"never"|"always"`, `browserBundleFiles?: boolean`, `browserBundleFormat?: "auto"|"text"|"zip"`, `browserThinkingTime?: "light"|"standard"|"extended"|"extra-high"|"pro"|"heavy"`, `browserResearchMode?: "deep"`, `browserFollowUps?: string[]`, `browserArchive?: "auto"|"always"|"never"`, `browserKeepBrowser?: boolean`, `browserModelLabel?: string`, `browserModelStrategy?: "select"|"current"|"ignore"`, `generateImage?: string`, `outputPath?: string`.
- Dry runs: set `dryRun: true` to preview the resolved request without creating a session or touching the browser.
- Behavior: starts a session, runs it with the chosen engine, returns final output + metadata. Background/foreground follows the CLI (e.g., GPT‑5 Pro detaches by default). If API mode fails because `OPENAI_API_KEY` is missing and you have ChatGPT Pro, retry with `engine: "browser"` or `preset: "chatgpt-pro-heavy"` to use your signed-in ChatGPT session instead of an API key.
- Behavior: starts a session and runs it with the chosen engine. The compatibility default is `waitForCompletion:true`, which returns final output + metadata in the same call. Set `waitForCompletion:false` to launch a local detached worker and return a durable `sessionId` immediately. If API mode fails because `OPENAI_API_KEY` is missing and you have ChatGPT Pro, retry with `engine: "browser"` or `preset: "chatgpt-pro-heavy"` to use your signed-in ChatGPT session instead of an API key.
- Logging: emits MCP logs (`info` per line, `debug` for streamed chunks with byte sizes). If browser prerequisites are missing, returns an error payload instead of running.
- Research mode: set `browserResearchMode:"deep"` for broad public-web research and cited reports. Use normal browser runs with `gpt-5.5-pro` + `browserThinkingTime:"extended"` for legacy Pro Extended code review, `gpt-5.6-sol` + `browserThinkingTime:"extra-high"` for Extra High, or `gpt-5.6-sol` + `browserThinkingTime:"pro"` when you explicitly want the current Pro effort tier.
- Multi-turn consults: set `browserFollowUps:["Challenge your recommendation", "Give the final decision"]` to keep one ChatGPT browser conversation open and ask sequential follow-up prompts. Use one-shot calls for narrow bugs and exact file-set reviews; use multi-turn for ambiguous architecture/product decisions where a challenge pass and final recommendation are useful; use Deep Research for broad public-web work with citations. Oracle never invents follow-ups automatically.
Expand All @@ -39,7 +39,22 @@ Claude Code can call `oracle-mcp` and ask a subscription-backed ChatGPT browser

#### Long browser consults from agents

Browser-backed GPT-5.5 Pro consults can legitimately run for many minutes. Some MCP clients show little progress while a tool call is active, so agents should treat a long Oracle call as a running browser job, not as a failed step. Start with `dryRun:true` when configuring a new agent, prefer `preset:"chatgpt-pro-heavy"` or `engine:"browser"` explicitly, and use the shared session store (`sessions`, `oracle status`, or `oracle session <id>`) before retrying a prompt. If the browser control plan says Oracle will launch visible Chrome, use attach/remote Chrome when the operator is actively using the computer.
Browser-backed GPT-5.5 Pro and Deep Research consults can legitimately run for many minutes. Start them with `waitForCompletion:false`, then call `wait` with the returned `sessionId`; this keeps the run alive independently of either MCP request and avoids agent-side polling. Start with `dryRun:true` when configuring a new agent, prefer `preset:"chatgpt-pro-heavy"` or `engine:"browser"` explicitly, and inspect the shared session store before retrying a prompt. Detached consult launch currently requires local execution; remote browser-service callers should keep `waitForCompletion:true`. If the browser control plan says Oracle will launch visible Chrome, use attach/remote Chrome when the operator is actively using the computer.

```json
{
"prompt": "Review this architecture",
"files": ["src/**"],
"preset": "chatgpt-pro-heavy",
"waitForCompletion": false
}
```

Then wait without polling:

```json
{ "id": "<sessionId from consult>", "timeoutMs": 900000 }
```

#### ChatGPT images from agents

Expand All @@ -61,6 +76,12 @@ The MCP response includes `structuredContent.images[]` with the saved file path,
- Inputs: `{id?, hours?, limit?, includeAll?, detail?}` mirroring `oracle status` / `oracle session`.
- Behavior: without `id`, returns a bounded list of recent sessions. With `id`/slug, returns a summary row; set `detail: true` to fetch full metadata, log, and stored request body.

### `wait`

- Inputs: `id` (required session id or slug), `timeoutMs?: number`.
- Behavior: blocks until the durable session status becomes `completed`, `partial`, `error`, or `cancelled`, then returns the final log tail and artifact/model/image summaries. It uses filesystem notifications with a low-frequency fallback and rereads session metadata after every wakeup.
- Timeout semantics: omit `timeoutMs` to wait indefinitely, set a positive value to bound only this MCP call, or set `0` for an immediate snapshot. A timeout returns `waitStatus:"timed_out"`; caller cancellation, transport closure, host-imposed request deadlines, or timeout never cancels the Oracle worker. Call `wait` again with the same `id` to continue.

### `project_sources`

- Inputs: `operation: "list"|"add"`, `chatgptUrl?: string`, `files?: string[]`, `dryRun?: boolean`, `confirmMutation?: boolean`, `browserKeepBrowser?: boolean`.
Expand All @@ -74,7 +95,8 @@ The MCP response includes `structuredContent.images[]` with the saved file path,

## Background / detach behavior

- Same as the CLI: heavy models (e.g., GPT‑5 Pro) detach by default; reattach via `oracle session <id>` / `oracle status`. MCP does not expose extra background flags.
- `consult` remains synchronous by default for compatibility. Set `waitForCompletion:false` to detach any local API or browser run explicitly, then use `wait` to attach a bounded or unbounded waiter to its durable session state.
- The detached worker owns the run. Ending or timing out a `wait` call only releases that waiter; it does not stop the worker. CLI inspection and reattachment remain available through `oracle session <id>` / `oracle status`.

## Launching & usage

Expand Down
2 changes: 2 additions & 0 deletions docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ oracle --wait --model gpt-5.5-pro -p "Long architecture review" --file "src/**"

For API runs, `--wait` executes the request in the foreground. Local Pro browser runs use a detached worker even with `--wait`, while the original CLI stays attached to the session log. This lets the browser worker capture and save the answer if the foreground CLI exits unexpectedly. Pressing Ctrl-C still cancels the worker and exits with code 130.

MCP callers can make the same ownership split explicit for any local run: call `consult` with `waitForCompletion:false`, then call `wait` with the returned session id. `wait.timeoutMs` bounds only the caller's wait; timeout, request cancellation, or MCP transport closure does not cancel the detached worker. Omit the timeout to wait until a terminal status, or use `0` for an immediate snapshot.

For browser runs, ChatGPT sometimes redirects mid-page-load. The auto-reattach flags poll the existing tab without manual intervention:

```bash
Expand Down
1 change: 1 addition & 0 deletions docs/windows-work.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Read this file whenever you're working from Windows and add new findings so the
- browser-tools binary: not built in `agent-scripts/bin` on Windows; `pnpm tsx scripts/browser-tools.ts` also fails there (no package manifest). Use a macOS-built binary or run from macOS if you need it.
- Prefer PowerShell + pnpm directly; watch for CRLF warnings when touching tracked files.
- WSL browser launch host detection: a systemd-resolved stub such as `nameserver 127.0.0.53` is guest loopback, not the Windows host. Keep resolver-derived non-loopback hosts for Windows Chrome compatibility, but route resolver-derived `127/8` values to the standard local Chrome launcher.
- Detached session workers launched by either CLI or MCP must use the shared launcher with `windowsHide: true`; a bounded MCP `wait` releases only the waiter and leaves that hidden worker running.

Future Windows gotchas belong here. Update this doc when you learn something new.

Expand Down
90 changes: 90 additions & 0 deletions src/cli/detachedSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { spawn } from "node:child_process";
import type { ChildProcess, SpawnOptions } from "node:child_process";
import { fileURLToPath } from "node:url";

export interface DetachedSessionSpawnSpec {
command: string;
args: string[];
options: SpawnOptions;
}

export interface LaunchDetachedSessionOptions {
sessionId: string;
cliEntrypoint?: string;
env?: NodeJS.ProcessEnv;
nodeExecutable?: string;
prepare: (pid: number) => Promise<void>;
spawnProcess?: (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
}

export function resolveOracleCliEntrypoint(moduleUrl: string = import.meta.url): string {
const extension = fileURLToPath(moduleUrl).endsWith(".ts") ? "ts" : "js";
return fileURLToPath(new URL(`../../bin/oracle-cli.${extension}`, moduleUrl));
}

export function buildDetachedSessionSpawnSpec({
sessionId,
cliEntrypoint = resolveOracleCliEntrypoint(),
env = process.env,
nodeExecutable = process.execPath,
}: Omit<LaunchDetachedSessionOptions, "prepare" | "spawnProcess">): DetachedSessionSpawnSpec {
return {
command: nodeExecutable,
args: ["--", cliEntrypoint, "--exec-session", sessionId],
options: {
detached: true,
stdio: ["pipe", "ignore", "ignore"],
env: {
...env,
ORACLE_DETACHED_START_GATE: "1",
},
windowsHide: true,
},
};
}

export function launchDetachedSession({
sessionId,
cliEntrypoint,
env,
nodeExecutable,
prepare,
spawnProcess = spawn,
}: LaunchDetachedSessionOptions): Promise<number> {
return new Promise((resolve, reject) => {
let child: ChildProcess;
try {
const spec = buildDetachedSessionSpawnSpec({
sessionId,
cliEntrypoint,
env,
nodeExecutable,
});
child = spawnProcess(spec.command, spec.args, spec.options);
} catch (error) {
reject(error);
return;
}

child.once("error", reject);
child.once("spawn", async () => {
if (child.pid === undefined) {
child.kill();
reject(new Error("Detached session worker started without a process ID."));
return;
}
try {
await prepare(child.pid);
if (!child.stdin) {
throw new Error("Detached session worker started without a writable start gate.");
}
child.stdin.end("ready\n");
child.unref();
resolve(child.pid);
} catch (error) {
child.kill();
reject(error);
}
});
});
}
2 changes: 2 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { registerChatGptImageTool } from "./tools/chatgptImage.js";
import { registerConsultTool } from "./tools/consult.js";
import { registerProjectSourcesTool } from "./tools/projectSources.js";
import { registerSessionsTool } from "./tools/sessions.js";
import { registerWaitTool } from "./tools/wait.js";
import { registerSessionResources } from "./tools/sessionResources.js";

export async function startMcpServer(): Promise<void> {
Expand All @@ -28,6 +29,7 @@ export async function startMcpServer(): Promise<void> {
registerChatGptImageTool(server);
registerProjectSourcesTool(server);
registerSessionsTool(server);
registerWaitTool(server);
registerSessionResources(server);

const transport = new StdioServerTransport();
Expand Down
Loading