diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f5d02677..94a4bf11c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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! diff --git a/bin/oracle-cli.ts b/bin/oracle-cli.ts index ab53c6c51..e4170e754 100755 --- a/bin/oracle-cli.ts +++ b/bin/oracle-cli.ts @@ -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"; @@ -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"; @@ -2392,14 +2392,19 @@ async function runRootCommand(options: CliOptions): Promise { }); 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( @@ -2515,44 +2520,6 @@ async function runInteractiveSession( } } -async function launchDetachedSession( - sessionId: string, - prepare: (pid: number) => Promise, -): Promise { - 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 { if (process.env.ORACLE_DETACHED_START_GATE !== "1") { return; @@ -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( diff --git a/docs/mcp.md b/docs/mcp.md index 4fd63bb28..df994286d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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. @@ -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 `) 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": "", "timeoutMs": 900000 } +``` #### ChatGPT images from agents @@ -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`. @@ -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 ` / `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 ` / `oracle status`. ## Launching & usage diff --git a/docs/sessions.md b/docs/sessions.md index 75c994ab0..afb7d861c 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -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 diff --git a/docs/windows-work.md b/docs/windows-work.md index 5f9125d9a..2de67a9e5 100644 --- a/docs/windows-work.md +++ b/docs/windows-work.md @@ -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. diff --git a/src/cli/detachedSession.ts b/src/cli/detachedSession.ts new file mode 100644 index 000000000..a6adaf2d9 --- /dev/null +++ b/src/cli/detachedSession.ts @@ -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; + 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): 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 { + 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); + } + }); + }); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2a3431258..00ae3e9b6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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 { @@ -28,6 +29,7 @@ export async function startMcpServer(): Promise { registerChatGptImageTool(server); registerProjectSourcesTool(server); registerSessionsTool(server); + registerWaitTool(server); registerSessionResources(server); const transport = new StdioServerTransport(); diff --git a/src/mcp/tools/consult.ts b/src/mcp/tools/consult.ts index 96f43374b..21f1af5aa 100644 --- a/src/mcp/tools/consult.ts +++ b/src/mcp/tools/consult.ts @@ -14,7 +14,10 @@ import { resolveRemoteServiceConfig } from "../../remote/remoteServiceConfig.js" import { createRemoteBrowserExecutor } from "../../remote/client.js"; import type { BrowserSessionRunnerDeps } from "../../browser/sessionRunner.js"; -async function readSessionLogTail(sessionId: string, maxBytes: number): Promise { +export async function readSessionLogTail( + sessionId: string, + maxBytes: number, +): Promise { try { const log = await sessionStore.readLog(sessionId); if (log.length <= maxBytes) { @@ -40,6 +43,8 @@ import { import type { BrowserModelStrategy } from "../../browser/types.js"; import { normalizeThinkingTimeLevel } from "../../oracle/thinkingTime.js"; import type { ThinkingTimeLevel } from "../../oracle/types.js"; +import { launchDetachedSession } from "../../cli/detachedSession.js"; +import { buildSessionLifecycle } from "../../cli/sessionLifecycle.js"; // Use raw shapes so the MCP SDK (with its bundled Zod) wraps them and emits valid JSON Schema. const consultInputShape = { @@ -135,6 +140,12 @@ const consultInputShape = { .describe( "Browser-only image output fallback path, mirroring the CLI --output option for image operations.", ), + waitForCompletion: z + .boolean() + .optional() + .describe( + "When false, start the Oracle run in a detached worker and return its sessionId immediately. Use the wait tool to block for completion without agent-side polling. Defaults to true for compatibility.", + ), dryRun: z .boolean() .optional() @@ -151,7 +162,7 @@ const consultInputShape = { .describe("Optional human-friendly session id (used for later `oracle sessions` lookups)."), } satisfies z.ZodRawShape; -const consultModelSummaryShape = z.object({ +export const consultModelSummaryShape = z.object({ model: z.string(), status: z.string(), startedAt: z.string().optional(), @@ -181,7 +192,7 @@ const consultModelSummaryShape = z.object({ logPath: z.string().optional(), }); -const consultArtifactSummaryShape = z.object({ +export const consultArtifactSummaryShape = z.object({ kind: z.enum(["transcript", "deep-research-report", "image", "file"]), path: z.string(), label: z.string().optional(), @@ -189,7 +200,7 @@ const consultArtifactSummaryShape = z.object({ sizeBytes: z.number().optional(), }); -const consultImageSummaryShape = consultArtifactSummaryShape.extend({ +export const consultImageSummaryShape = consultArtifactSummaryShape.extend({ kind: z.literal("image"), alt: z.string().optional(), width: z.number().optional(), @@ -231,6 +242,7 @@ export const consultOutputShape = { models: z.array(consultModelSummaryShape).optional(), artifacts: z.array(consultArtifactSummaryShape).optional(), images: z.array(consultImageSummaryShape).optional(), + detached: z.boolean().optional(), } satisfies z.ZodRawShape; export type ConsultModelSummary = z.infer; @@ -508,7 +520,13 @@ type McpLoggingServer = Pick; export async function runConsultTool( input: unknown, - { server }: { server: McpLoggingServer }, + { + server, + launchDetached = launchDetachedSession, + }: { + server: McpLoggingServer; + launchDetached?: typeof launchDetachedSession; + }, ): Promise { const textContent = (text: string) => [{ type: "text" as const, text }]; let parsedInput; @@ -539,6 +557,7 @@ export async function runConsultTool( browserKeepBrowser, generateImage, outputPath, + waitForCompletion = true, dryRun, slug, } = parsedInput; @@ -589,7 +608,6 @@ export async function runConsultTool( ), }; } - let browserConfig: BrowserSessionConfig | undefined; if (resolvedEngine === "browser") { browserConfig = buildConsultBrowserConfig({ @@ -640,6 +658,23 @@ export async function runConsultTool( }; } + if (!waitForCompletion && resolvedEngine === "browser" && resolvedRemote.host) { + return { + isError: true, + content: textContent( + "Detached MCP consults are not supported with a remote browser service yet. Keep waitForCompletion:true for this run.", + ), + }; + } + if (!waitForCompletion && process.env.ORACLE_NO_DETACH === "1") { + return { + isError: true, + content: textContent( + "Detached MCP consults are disabled by ORACLE_NO_DETACH=1. Remove it or keep waitForCompletion:true.", + ), + }; + } + const browserGuard = ensureBrowserAvailable(resolvedEngine, { remoteHost: resolvedRemote.host, }); @@ -681,12 +716,64 @@ export async function runConsultTool( mode: resolvedEngine, slug, browserConfig, - waitPreference: true, + waitPreference: waitForCompletion, }, cwd, notifications, ); + if (!waitForCompletion) { + try { + await launchDetached({ + sessionId: sessionMeta.id, + prepare: async (workerPid) => { + const lifecycle = buildSessionLifecycle({ + engine: resolvedEngine, + detached: true, + workerPid, + reattachCommand: `oracle session ${sessionMeta.id}`, + }); + await sessionStore.updateSession(sessionMeta.id, { + status: "running", + startedAt: new Date().toISOString(), + lifecycle, + }); + }, + }); + const started = (await sessionStore.readSession(sessionMeta.id)) ?? sessionMeta; + const summary = `Session ${sessionMeta.id} (${started.status}; detached)`; + return { + content: textContent( + `${summary}\nUse the wait tool with id=${JSON.stringify(sessionMeta.id)} to wait without polling.`, + ), + structuredContent: { + sessionId: sessionMeta.id, + status: started.status, + output: "", + models: summarizeModelRunsForConsult(started.models), + artifacts: summarizeArtifactsForConsult(started.artifacts), + images: summarizeImageArtifactsForConsult(started.artifacts), + detached: true, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await sessionStore + .updateSession(sessionMeta.id, { + status: "error", + completedAt: new Date().toISOString(), + errorMessage: message, + response: { status: "error" }, + error: { category: "internal", message }, + }) + .catch(() => undefined); + return { + isError: true, + content: textContent(`Unable to start detached session ${sessionMeta.id}: ${message}`), + }; + } + } + const logWriter = sessionStore.createLogWriter(sessionMeta.id); // Stream logs to both the session log and MCP logging notifications, but avoid buffering in memory const log = (line?: string): void => { @@ -761,7 +848,7 @@ export function registerConsultTool(server: McpServer): void { { title: "Run an oracle session", description: - 'Run an Oracle session (API or ChatGPT browser automation). Use `files` to attach project context. If `engine` is omitted, Oracle follows CLI defaults: config/ORACLE_ENGINE first, then API when OPENAI_API_KEY is set, otherwise browser. Browser GPT-5.5 Pro consults can take many minutes; use `dryRun:true` first when configuring an agent and inspect `sessions`/`oracle status` before retrying. Browser manual-login uses a private Oracle Chrome profile separate from the user\'s normal Chrome; dry-run output includes first-time setup guidance when that path is active. For browser-based image/file uploads, set `browserAttachments:"always"`. For ChatGPT image generation, set `generateImage` to enable the same image wait/download path as CLI --generate-image and read returned paths from `images`. Browser consults can include `browserFollowUps` for a multi-turn ChatGPT review in one conversation. Sessions are stored under `ORACLE_HOME_DIR` (shared with the CLI).', + 'Run an Oracle session (API or ChatGPT browser automation). Use `files` to attach project context. If `engine` is omitted, Oracle follows CLI defaults: config/ORACLE_ENGINE first, then API when OPENAI_API_KEY is set, otherwise browser. Browser GPT-5.5 Pro consults can take many minutes; set `waitForCompletion:false` to return a durable sessionId immediately, then use `wait` to block without agent-side polling. Use `dryRun:true` first when configuring an agent and inspect `sessions`/`oracle status` before retrying. Browser manual-login uses a private Oracle Chrome profile separate from the user\'s normal Chrome; dry-run output includes first-time setup guidance when that path is active. For browser-based image/file uploads, set `browserAttachments:"always"`. For ChatGPT image generation, set `generateImage` to enable the same image wait/download path as CLI --generate-image and read returned paths from `images`. Browser consults can include `browserFollowUps` for a multi-turn ChatGPT review in one conversation. Sessions are stored under `ORACLE_HOME_DIR` (shared with the CLI).', // Cast to any to satisfy SDK typings across differing Zod versions. inputSchema: consultInputShape, outputSchema: consultOutputShape, diff --git a/src/mcp/tools/wait.ts b/src/mcp/tools/wait.ts new file mode 100644 index 000000000..607ffbec9 --- /dev/null +++ b/src/mcp/tools/wait.ts @@ -0,0 +1,243 @@ +import { watch } from "node:fs"; +import type { FSWatcher } from "node:fs"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { SessionMetadata } from "../../sessionStore.js"; +import { sessionStore } from "../../sessionStore.js"; +import { waitInputSchema } from "../types.js"; +import { + consultArtifactSummaryShape, + consultImageSummaryShape, + consultModelSummaryShape, + readSessionLogTail, + summarizeArtifactsForConsult, + summarizeImageArtifactsForConsult, + summarizeModelRunsForConsult, +} from "./consult.js"; + +const TERMINAL_SESSION_STATUSES = new Set(["completed", "partial", "error", "cancelled"]); +const DEFAULT_FALLBACK_INTERVAL_MS = 1_000; + +const waitInputShape = { + id: z.string().min(1, "Session id is required.").describe("Oracle session id or slug."), + timeoutMs: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "How long to wait for a terminal session state. Omit to wait indefinitely; 0 returns an immediate snapshot. A wait timeout never cancels the Oracle session.", + ), +} satisfies z.ZodRawShape; + +const waitOutputShape = { + sessionId: z.string(), + status: z.string(), + waitStatus: z.enum(["terminal", "timed_out"]), + timedOut: z.boolean(), + cancelled: z.boolean(), + output: z.string(), + models: z.array(consultModelSummaryShape).optional(), + artifacts: z.array(consultArtifactSummaryShape).optional(), + images: z.array(consultImageSummaryShape).optional(), +} satisfies z.ZodRawShape; + +export interface SessionChangeSource { + wait(delayMs: number, signal?: AbortSignal): Promise; + close(): void; +} + +export interface WaitForSessionDeps { + readSession: (id: string) => Promise; + getSessionDir: (id: string) => Promise; + createChangeSource: (directory: string) => SessionChangeSource; + now: () => number; +} + +export interface WaitForSessionResult { + metadata: SessionMetadata; + waitStatus: "terminal" | "timed_out"; + timedOut: boolean; +} + +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new DOMException("Oracle session wait was cancelled.", "AbortError"); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw abortReason(signal); + } +} + +export function isTerminalSessionStatus(status: string): boolean { + return TERMINAL_SESSION_STATUSES.has(status); +} + +export function createSessionChangeSource(directory: string): SessionChangeSource { + let watcher: FSWatcher | undefined; + let notified = false; + let pendingWake: (() => void) | undefined; + + const onWake = (): void => { + if (!pendingWake) { + notified = true; + return; + } + const wake = pendingWake; + pendingWake = undefined; + wake(); + }; + const onError = (): void => { + const failedWatcher = watcher; + watcher = undefined; + failedWatcher?.off("change", onWake); + failedWatcher?.off("error", onError); + failedWatcher?.close(); + onWake(); + }; + + try { + watcher = watch(directory); + watcher.on("change", onWake); + watcher.on("error", onError); + } catch { + // A timer fallback below keeps waits correct when filesystem notifications + // are unavailable or the session directory is on an unsupported volume. + } + + return { + wait(delayMs, signal) { + throwIfAborted(signal); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (pendingWake === wake) pendingWake = undefined; + signal?.removeEventListener("abort", onAbort); + if (error) reject(error); + else resolve(); + }; + const wake = (): void => finish(); + const onAbort = (): void => finish(abortReason(signal!)); + const timer = setTimeout(wake, delayMs); + pendingWake = wake; + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) { + onAbort(); + return; + } + if (notified) { + notified = false; + queueMicrotask(wake); + } + }); + }, + close() { + watcher?.off("change", onWake); + watcher?.off("error", onError); + watcher?.close(); + watcher = undefined; + }, + }; +} + +const defaultWaitDeps: WaitForSessionDeps = { + readSession: (id) => sessionStore.readSession(id), + getSessionDir: async (id) => (await sessionStore.getPaths(id)).dir, + createChangeSource: createSessionChangeSource, + now: Date.now, +}; + +export async function waitForSessionTerminal( + { + id, + timeoutMs, + signal, + fallbackIntervalMs = DEFAULT_FALLBACK_INTERVAL_MS, + }: { + id: string; + timeoutMs?: number; + signal?: AbortSignal; + fallbackIntervalMs?: number; + }, + deps: WaitForSessionDeps = defaultWaitDeps, +): Promise { + throwIfAborted(signal); + const deadline = timeoutMs === undefined ? undefined : deps.now() + timeoutMs; + let metadata = await deps.readSession(id); + if (!metadata) { + throw new Error(`Session "${id}" not found.`); + } + if (isTerminalSessionStatus(metadata.status)) { + return { metadata, waitStatus: "terminal", timedOut: false }; + } + if (timeoutMs === 0) { + return { metadata, waitStatus: "timed_out", timedOut: true }; + } + + const directory = await deps.getSessionDir(id); + const changes = deps.createChangeSource(directory); + try { + // Close the read/watch race: the session may have completed while the + // filesystem watcher was being installed. + metadata = (await deps.readSession(id)) ?? metadata; + while (!isTerminalSessionStatus(metadata.status)) { + throwIfAborted(signal); + const remaining = deadline === undefined ? undefined : deadline - deps.now(); + if (remaining !== undefined && remaining <= 0) { + return { metadata, waitStatus: "timed_out", timedOut: true }; + } + const delayMs = Math.max(1, Math.min(fallbackIntervalMs, remaining ?? fallbackIntervalMs)); + await changes.wait(delayMs, signal); + metadata = (await deps.readSession(id)) ?? metadata; + } + return { metadata, waitStatus: "terminal", timedOut: false }; + } finally { + changes.close(); + } +} + +type McpToolExtra = RequestHandlerExtra; + +export async function runWaitTool(input: unknown, extra?: Pick) { + const { id, timeoutMs } = waitInputSchema.parse(input); + const result = await waitForSessionTerminal({ id, timeoutMs, signal: extra?.signal }); + const { metadata } = result; + const logTail = (await readSessionLogTail(metadata.id, 4_000)) ?? ""; + const summary = `Session ${metadata.id} (${metadata.status}; wait=${result.waitStatus})`; + return { + content: [{ type: "text" as const, text: [summary, logTail || "(log empty)"].join("\n") }], + structuredContent: { + sessionId: metadata.id, + status: metadata.status, + waitStatus: result.waitStatus, + timedOut: result.timedOut, + cancelled: metadata.status === "cancelled", + output: logTail, + models: summarizeModelRunsForConsult(metadata.models), + artifacts: summarizeArtifactsForConsult(metadata.artifacts), + images: summarizeImageArtifactsForConsult(metadata.artifacts), + }, + }; +} + +export function registerWaitTool(server: McpServer): void { + server.registerTool( + "wait", + { + title: "Wait for an oracle session", + description: + "Wait for an existing Oracle session to reach a terminal state without agent-side polling. Omit timeoutMs to wait indefinitely, or set a bounded caller wait. Wait timeout or request cancellation never cancels the Oracle session; call wait again with the same id to continue waiting.", + inputSchema: waitInputShape, + outputSchema: waitOutputShape, + }, + async (input: unknown, extra: McpToolExtra) => runWaitTool(input, extra), + ); +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts index ccf074020..d267ec257 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -36,6 +36,7 @@ export const consultInputSchema = z browserKeepBrowser: z.boolean().optional(), generateImage: z.string().optional(), outputPath: z.string().optional(), + waitForCompletion: z.boolean().optional(), dryRun: z.boolean().optional(), search: z.boolean().optional(), slug: z.string().optional(), @@ -53,3 +54,12 @@ export const sessionsInputSchema = z.object({ }); export type SessionsInput = z.infer; + +export const waitInputSchema = z + .object({ + id: z.string().min(1, "Session id is required."), + timeoutMs: z.number().int().nonnegative().optional(), + }) + .strict(); + +export type WaitInput = z.infer; diff --git a/tests/cli/detachedSession.test.ts b/tests/cli/detachedSession.test.ts new file mode 100644 index 000000000..cf0383310 --- /dev/null +++ b/tests/cli/detachedSession.test.ts @@ -0,0 +1,72 @@ +import path from "node:path"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { pathToFileURL } from "node:url"; +import type { ChildProcess } from "node:child_process"; +import { describe, expect, test, vi } from "vitest"; +import { + buildDetachedSessionSpawnSpec, + launchDetachedSession, + resolveOracleCliEntrypoint, +} from "../../src/cli/detachedSession.js"; + +describe("detached session launcher", () => { + test("uses a hidden detached Node child with a gated session handoff", () => { + const spec = buildDetachedSessionSpawnSpec({ + sessionId: "long-pro-session", + cliEntrypoint: "C:\\oracle\\dist\\bin\\oracle-cli.js", + env: { EXISTING: "1" }, + nodeExecutable: "C:\\node\\node.exe", + }); + + expect(spec).toMatchObject({ + command: "C:\\node\\node.exe", + args: ["--", "C:\\oracle\\dist\\bin\\oracle-cli.js", "--exec-session", "long-pro-session"], + options: { + detached: true, + stdio: ["pipe", "ignore", "ignore"], + windowsHide: true, + env: { + EXISTING: "1", + ORACLE_DETACHED_START_GATE: "1", + }, + }, + }); + }); + + test("resolves the built CLI next to the dist source tree", () => { + const moduleUrl = pathToFileURL( + path.join(process.cwd(), "dist", "src", "cli", "detachedSession.js"), + ).href; + expect(resolveOracleCliEntrypoint(moduleUrl)).toBe( + path.join(process.cwd(), "dist", "bin", "oracle-cli.js"), + ); + }); + + test("opens the start gate only after durable lifecycle preparation", async () => { + const stdin = new PassThrough(); + const written: Buffer[] = []; + stdin.on("data", (chunk: Buffer) => written.push(chunk)); + const child = Object.assign(new EventEmitter(), { + pid: 4242, + stdin, + unref: vi.fn(), + kill: vi.fn(), + }) as unknown as ChildProcess; + const prepare = vi.fn(async () => undefined); + const spawnProcess = vi.fn(() => child); + + const launched = launchDetachedSession({ + sessionId: "long-pro-session", + prepare, + spawnProcess, + }); + child.emit("spawn"); + + await expect(launched).resolves.toBe(4242); + expect(prepare).toHaveBeenCalledWith(4242); + expect(Buffer.concat(written).toString("utf8")).toBe("ready\n"); + expect(child.unref).toHaveBeenCalledTimes(1); + expect(child.kill).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/mcp.schema.test.ts b/tests/mcp.schema.test.ts index c7618b4d2..98e53e77d 100644 --- a/tests/mcp.schema.test.ts +++ b/tests/mcp.schema.test.ts @@ -86,6 +86,7 @@ describe("oracle-mcp schemas", () => { if (!client) throw new Error("MCP client not connected"); const { tools } = await client.listTools({}, { timeout: 10_000 }); expect(tools.length).toBeGreaterThan(0); + expect(tools.map((tool) => tool.name)).toContain("wait"); for (const tool of tools) { for (const schema of [tool.inputSchema, tool.outputSchema]) { if (!schema) continue; diff --git a/tests/mcp/consult.test.ts b/tests/mcp/consult.test.ts index 3cdc2828f..b76602ce3 100644 --- a/tests/mcp/consult.test.ts +++ b/tests/mcp/consult.test.ts @@ -1,9 +1,9 @@ import { mkdtempSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; -import type { SessionModelRun } from "../../src/sessionStore.js"; +import { sessionStore, type SessionModelRun } from "../../src/sessionStore.js"; import { applyConsultPreset } from "../../src/mcp/consultPresets.ts"; import { consultInputSchema } from "../../src/mcp/types.ts"; import { setOracleHomeDirOverrideForTest } from "../../src/oracleHome.js"; @@ -12,12 +12,97 @@ import { buildConsultDryRunResolved, formatConsultDryRunResolved, registerConsultTool, + runConsultTool, summarizeArtifactsForConsult, summarizeImageArtifactsForConsult, summarizeModelRunsForConsult, } from "../../src/mcp/tools/consult.ts"; describe("summarizeModelRunsForConsult", () => { + test("starts a detached consult and returns a durable session id", async () => { + const home = mkdtempSync(path.join(tmpdir(), "oracle-home-")); + setOracleHomeDirOverrideForTest(home); + const previousNoDetach = process.env.ORACLE_NO_DETACH; + delete process.env.ORACLE_NO_DETACH; + try { + const launchDetached = vi.fn( + async ({ prepare }: { prepare: (pid: number) => Promise }) => { + await prepare(4242); + return 4242; + }, + ); + const result = await runConsultTool( + { + prompt: "review this plan", + files: [], + model: "gpt-5.4", + engine: "api", + waitForCompletion: false, + }, + { + server: { sendLoggingMessage: vi.fn(async () => undefined) }, + launchDetached: launchDetached as never, + }, + ); + const structured = result.structuredContent as { + sessionId?: string; + status?: string; + detached?: boolean; + }; + + expect(result.isError).not.toBe(true); + expect(structured).toMatchObject({ status: "running", detached: true }); + expect(structured.sessionId).toBeTruthy(); + expect(launchDetached).toHaveBeenCalledTimes(1); + + const stored = await sessionStore.readSession(structured.sessionId!); + expect(stored).toMatchObject({ + status: "running", + lifecycle: { + execution: "background", + attached: false, + detached: true, + workerPid: 4242, + }, + options: { waitPreference: false }, + }); + } finally { + if (previousNoDetach === undefined) delete process.env.ORACLE_NO_DETACH; + else process.env.ORACLE_NO_DETACH = previousNoDetach; + setOracleHomeDirOverrideForTest(null); + rmSync(home, { recursive: true, force: true }); + } + }); + + test("keeps detached dry-runs non-mutating when detaching is disabled", async () => { + const previousNoDetach = process.env.ORACLE_NO_DETACH; + process.env.ORACLE_NO_DETACH = "1"; + try { + const launchDetached = vi.fn(); + const result = await runConsultTool( + { + prompt: "preview a long run", + files: [], + model: "gpt-5.4", + engine: "api", + waitForCompletion: false, + dryRun: true, + }, + { + server: { sendLoggingMessage: vi.fn(async () => undefined) }, + launchDetached: launchDetached as never, + }, + ); + + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ status: "dry-run", dryRun: true }); + expect(launchDetached).not.toHaveBeenCalled(); + } finally { + if (previousNoDetach === undefined) delete process.env.ORACLE_NO_DETACH; + else process.env.ORACLE_NO_DETACH = previousNoDetach; + } + }); + test("applies the ChatGPT Pro Heavy consult preset as overridable defaults", () => { expect( applyConsultPreset({ diff --git a/tests/mcp/wait.test.ts b/tests/mcp/wait.test.ts new file mode 100644 index 000000000..a543ff33a --- /dev/null +++ b/tests/mcp/wait.test.ts @@ -0,0 +1,188 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { SessionMetadata } from "../../src/sessionStore.js"; +import { sessionStore } from "../../src/sessionStore.js"; +import { setOracleHomeDirOverrideForTest } from "../../src/oracleHome.js"; +import { + runWaitTool, + waitForSessionTerminal, + type SessionChangeSource, + type WaitForSessionDeps, +} from "../../src/mcp/tools/wait.js"; + +function metadata(status: string): SessionMetadata { + return { + id: "session-1", + createdAt: "2026-08-28T00:00:00.000Z", + status, + cwd: "/tmp", + model: "gpt-5.6-sol", + mode: "browser", + options: { prompt: "review", file: [], model: "gpt-5.6-sol" }, + }; +} + +function fakeSource(waitImpl: SessionChangeSource["wait"] = async () => undefined) { + return { + source: { wait: vi.fn(waitImpl), close: vi.fn() } satisfies SessionChangeSource, + create: vi.fn(), + }; +} + +describe("waitForSessionTerminal", () => { + test("returns immediately for a terminal session", async () => { + const source = fakeSource(); + source.create.mockReturnValue(source.source); + const deps: WaitForSessionDeps = { + readSession: vi.fn(async () => metadata("completed")), + getSessionDir: vi.fn(async () => "/tmp/session-1"), + createChangeSource: source.create, + now: () => 0, + }; + + await expect(waitForSessionTerminal({ id: "session-1" }, deps)).resolves.toMatchObject({ + metadata: { status: "completed" }, + waitStatus: "terminal", + timedOut: false, + }); + expect(source.create).not.toHaveBeenCalled(); + }); + + test("wakes on a session change and rereads durable metadata", async () => { + const source = fakeSource(); + source.create.mockReturnValue(source.source); + const readSession = vi + .fn<() => Promise>() + .mockResolvedValueOnce(metadata("running")) + .mockResolvedValueOnce(metadata("running")) + .mockResolvedValueOnce(metadata("completed")); + const deps: WaitForSessionDeps = { + readSession, + getSessionDir: vi.fn(async () => "/tmp/session-1"), + createChangeSource: source.create, + now: () => 0, + }; + + const result = await waitForSessionTerminal({ id: "session-1", timeoutMs: 60_000 }, deps); + + expect(result).toMatchObject({ + metadata: { status: "completed" }, + waitStatus: "terminal", + timedOut: false, + }); + expect(readSession).toHaveBeenCalledTimes(3); + expect(source.source.wait).toHaveBeenCalledTimes(1); + expect(source.source.close).toHaveBeenCalledTimes(1); + }); + + test("times out without changing the running session", async () => { + const source = fakeSource(); + source.create.mockReturnValue(source.source); + let nowCalls = 0; + const readSession = vi.fn(async () => metadata("running")); + const deps: WaitForSessionDeps = { + readSession, + getSessionDir: vi.fn(async () => "/tmp/session-1"), + createChangeSource: source.create, + now: () => (nowCalls++ === 0 ? 0 : 10), + }; + + await expect( + waitForSessionTerminal({ id: "session-1", timeoutMs: 10 }, deps), + ).resolves.toMatchObject({ + metadata: { status: "running" }, + waitStatus: "timed_out", + timedOut: true, + }); + expect(readSession).toHaveBeenCalledTimes(2); + expect(source.source.wait).not.toHaveBeenCalled(); + expect(source.source.close).toHaveBeenCalledTimes(1); + }); + + test("request cancellation stops only the waiter", async () => { + const controller = new AbortController(); + const source = fakeSource(async (_delayMs, signal) => { + controller.abort(new DOMException("caller cancelled", "AbortError")); + signal?.throwIfAborted(); + }); + source.create.mockReturnValue(source.source); + const readSession = vi.fn(async () => metadata("running")); + const deps: WaitForSessionDeps = { + readSession, + getSessionDir: vi.fn(async () => "/tmp/session-1"), + createChangeSource: source.create, + now: () => 0, + }; + + await expect( + waitForSessionTerminal({ id: "session-1", signal: controller.signal }, deps), + ).rejects.toThrow("caller cancelled"); + expect(readSession).toHaveBeenCalledTimes(2); + expect(source.source.close).toHaveBeenCalledTimes(1); + }); + + test("rejects a missing session before installing a watcher", async () => { + const source = fakeSource(); + source.create.mockReturnValue(source.source); + const deps: WaitForSessionDeps = { + readSession: vi.fn(async () => null), + getSessionDir: vi.fn(async () => "/tmp/missing"), + createChangeSource: source.create, + now: () => 0, + }; + + await expect(waitForSessionTerminal({ id: "missing" }, deps)).rejects.toThrow( + 'Session "missing" not found.', + ); + expect(source.create).not.toHaveBeenCalled(); + }); +}); + +describe("wait MCP result", () => { + afterEach(() => { + setOracleHomeDirOverrideForTest(null); + }); + + test("returns terminal output and artifact summaries without another sessions call", async () => { + const home = mkdtempSync(path.join(tmpdir(), "oracle-wait-")); + setOracleHomeDirOverrideForTest(home); + try { + const created = await sessionStore.createSession( + { prompt: "review", file: [], model: "gpt-5.6-sol", mode: "browser" }, + "/tmp", + ); + const writer = sessionStore.createLogWriter(created.id); + writer.logLine("final answer"); + await new Promise((resolve) => writer.stream.end(resolve)); + await sessionStore.updateSession(created.id, { + status: "completed", + completedAt: new Date().toISOString(), + artifacts: [{ kind: "transcript", path: "artifacts/transcript.md" }], + }); + + const result = (await runWaitTool({ id: created.id, timeoutMs: 0 })) as { + structuredContent: { + sessionId: string; + status: string; + waitStatus: string; + timedOut: boolean; + output: string; + artifacts?: Array<{ kind: string; path: string }>; + }; + }; + + expect(result.structuredContent).toMatchObject({ + sessionId: created.id, + status: "completed", + waitStatus: "terminal", + timedOut: false, + output: expect.stringContaining("final answer"), + artifacts: [{ kind: "transcript", path: "artifacts/transcript.md" }], + }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +});