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
167 changes: 149 additions & 18 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 @@ -80,6 +79,7 @@ import {
createPerfTrace,
isTraceValueFlag,
} from "../src/cli/perfTrace.js";
import { launchDetachedSessionRunner } from "../src/cli/detachedSession.js";

interface CliOptions extends OptionValues {
prompt?: string;
Expand All @@ -106,6 +106,7 @@ interface CliOptions extends OptionValues {
apiKey?: string;
session?: string;
execSession?: string;
finalizeSession?: string;
followup?: string;
followupModel?: string;
notify?: boolean;
Expand Down Expand Up @@ -205,6 +206,39 @@ interface RestartCommandOptions {
remoteToken?: string;
}

interface FollowUpCommandOptions {
prompt?: string;
slug?: string;
wait?: boolean;
recover?: boolean;
file?: string[];
}

function collectFollowUpCommandOptions(...values: unknown[]): FollowUpCommandOptions {
const options: FollowUpCommandOptions = {};
for (const value of values) {
if (!value || typeof value !== "object") continue;
const candidate =
typeof (value as Command).opts === "function"
? (value as Command).opts<FollowUpCommandOptions>()
: (value as FollowUpCommandOptions);
for (const [key, candidateValue] of Object.entries(candidate)) {
if (candidateValue === undefined) continue;
if (
key === "file" &&
Array.isArray(candidateValue) &&
candidateValue.length === 0 &&
options.file &&
options.file.length > 0
) {
continue;
}
(options as Record<string, unknown>)[key] = candidateValue;
}
}
return options;
}

const VERSION = getCliVersion();
const CLI_ENTRYPOINT = fileURLToPath(import.meta.url);
const LEGACY_FLAG_ALIASES = new Map<string, string>([
Expand Down Expand Up @@ -535,6 +569,7 @@ program
).default(undefined),
)
.addOption(new Option("--exec-session <id>").hideHelp())
.addOption(new Option("--finalize-session <id>").hideHelp())
.addOption(new Option("--session <id>").hideHelp())
.addOption(
new Option("--status", "Show stored sessions (alias for `oracle status`).")
Expand Down Expand Up @@ -1284,6 +1319,36 @@ program
await restartSession(sessionId, restartOptions);
});

program
.command("follow-up <parentSessionId> [prompt]")
.description("Continue a stored browser session as a new child session.")
.option("-p, --prompt <text>", "Follow-up prompt to send to the saved ChatGPT conversation.")
.option("-s, --slug <words>", "Custom child session slug (3-5 words).")
.addOption(new Option("--wait").default(undefined))
.addOption(new Option("--no-wait").default(undefined).hideHelp())
.option(
"-f, --file <paths...>",
"Unsupported for follow-up v1; start a new consult to attach files.",
collectPaths,
[],
)
.option(
"--no-recover",
"Do not relaunch Chrome to reopen the saved conversation URL; require a live matching tab.",
)
.action(
async (
parentSessionId: string,
promptArg: string | undefined,
optionsOrCommand: FollowUpCommandOptions | Command,
cmd?: Command,
) => {
const options = collectFollowUpCommandOptions(program, cmd, optionsOrCommand, promptArg);
const positionalPrompt = typeof promptArg === "string" ? promptArg : undefined;
await runFollowUpCommand(parentSessionId, positionalPrompt, options);
},
);

function buildRunOptions(
options: ResolvedCliOptions,
overrides: Partial<RunOracleOptions> = {},
Expand Down Expand Up @@ -2009,6 +2074,11 @@ async function runRootCommand(options: CliOptions): Promise<void> {
return;
}

if (options.finalizeSession) {
await finalizeSession(options.finalizeSession);
return;
}

if (renderMarkdown || copyMarkdown) {
if (!options.prompt) {
throw new Error("Prompt is required when using --render-markdown or --copy-markdown.");
Expand Down Expand Up @@ -2432,24 +2502,73 @@ async function runInteractiveSession(
}

async function launchDetachedSession(sessionId: string): Promise<boolean> {
return new Promise((resolve, reject) => {
try {
const args = ["--", CLI_ENTRYPOINT, "--exec-session", sessionId];
const env = buildDetachedPerfTraceEnv(process.env, perfTraceArgs.value, sessionId);
const child = spawn(process.execPath, args, {
detached: true,
stdio: "ignore",
env,
});
child.once("error", reject);
child.once("spawn", () => {
child.unref();
resolve(true);
});
} catch (error) {
reject(error);
}
const env = buildDetachedPerfTraceEnv(process.env, perfTraceArgs.value, sessionId);
return launchDetachedSessionRunner(sessionId, {
cliEntrypoint: CLI_ENTRYPOINT,
env,
});
}

async function runFollowUpCommand(
parentSessionId: string,
promptArg: string | undefined,
options: FollowUpCommandOptions,
): Promise<void> {
const prompt = (await resolveDashPrompt(options.prompt ?? promptArg ?? "")) ?? "";
if (!prompt.trim()) {
console.error(
chalk.red("Prompt is required for follow-up. Use positional [prompt] or --prompt."),
);
process.exitCode = 1;
return;
}
if (options.file && options.file.length > 0) {
console.error(
chalk.red(
"Browser follow-up is prompt-only in v1. Start a new `oracle consult` run to attach files.",
),
);
process.exitCode = 1;
return;
}

const { startBrowserFollowUpSession, waitForFollowUpSession } =
await import("../src/cli/browserFollowUp.js");
const result = await startBrowserFollowUpSession(parentSessionId, {
prompt,
slug: options.slug,
wait: options.wait,
recover: options.recover !== false,
files: options.file,
cliEntrypoint: CLI_ENTRYPOINT,
env: process.env,
log: console.log,
});

console.log(chalk.blue(`Follow-up session: ${result.session.id}`));
console.log(chalk.dim(`Parent session: ${result.parentSessionId}`));
console.log(chalk.dim(`Conversation: ${result.parentConversationUrl}`));
if (!result.finalizerStarted) {
console.log(chalk.yellow("Detached finalizer did not start; use oracle-await if needed."));
}

const shouldWait = result.session.options?.waitPreference === true;
if (!shouldWait) {
for (const line of formatSessionLifecycleBlock(result.session)) {
console.log(line);
}
console.log(chalk.blue(`Reattach via: ${result.reattachCommand}`));
return;
}

const finalMeta = await waitForFollowUpSession(result.session.id, { log: console.log });
if (!finalMeta) {
console.log(chalk.red(`Follow-up session ${result.session.id} disappeared.`));
process.exitCode = 1;
return;
}
const { attachSession } = await import("../src/cli/sessionDisplay.js");
await attachSession(result.session.id, { renderMarkdown: true, suppressMetadata: true });
}

async function restartSession(sessionId: string, options: RestartCommandOptions): Promise<void> {
Expand Down Expand Up @@ -2684,6 +2803,18 @@ async function executeSession(sessionId: string) {
}
}

async function finalizeSession(sessionId: string) {
const { logLine, stream } = sessionStore.createLogWriter(sessionId);
try {
const { finalizeBrowserSessionUntilComplete } = await import("../src/cli/sessionFinalizer.js");
await finalizeBrowserSessionUntilComplete(sessionId, {
log: logLine,
});
} finally {
stream.end();
}
}

function printDebugHelp(cliName: string): void {
console.log(chalk.bold("Advanced Options"));
printDebugOptionGroup([
Expand Down
71 changes: 71 additions & 0 deletions skills/oracle/RUNBOOK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Oracle Runbook

## Waiting out long browser consults (`oracle-await`)

Browser GPT-5.5 Pro / Pro Extended consults can outlive MCP client request
timeouts. An MCP `-32001` timeout does not prove the ChatGPT run stopped. The
browser tab may continue, finish, and still leave local metadata stale if the
MCP/controller process was killed before finalization.

Do not trust `meta.json` `status:"running"` as authoritative after a client
timeout. Render every poll cycle:

```bash
oracle-await <slug-or-id>
# defaults: first wait 5m, render every 3m, cap 22m
# override: oracle-await <id> [first_wait_s] [interval_s] [max_s]
```

Exit codes:

- `0`: READY, transcript path and answer printed.
- `2`: still running or not captured by the cap.
- `3`: session reported an error.
- `4`: unknown session or missing wrapper.

Manual equivalent:

```bash
oracle session <slug-or-id> --render
```

`--render` is both check and recovery: it reattaches to the stored ChatGPT tab,
captures `artifacts/transcript.md`, and flips the session to `completed` when a
stable answer is present.

If metadata says `status:"error"` but `artifacts/transcript.md` exists and is
non-empty, treat the transcript as the result. This can happen when Node/undici
crashes during wrapper cleanup (for example `setTypeOfService EINVAL`) after
the browser answer was already captured. Do not rerun; read/render the saved
session.

## Continuing a saved browser conversation (`oracle follow-up`)

Use `follow-up` when the saved ChatGPT conversation has useful context and you
want one more turn:

```bash
oracle follow-up <parent-session-id> --prompt "Ask the next question" --slug "next review turn"
oracle follow-up <parent-session-id> "Ask the next question" --wait
```

This creates a new child session with its own metadata, log, lifecycle, and
`artifacts/transcript.md`. The parent session remains the audit record for the
earlier run. `oracle session --harvest` and `oracle session --live` stay
read-only recovery/inspection tools; they do not add turns.

Follow-up v1 is prompt-only. Start a new `oracle consult` if the next turn needs
fresh files or attachments.

## MCP timeout triage

If an MCP browser consult times out after opening Chrome:

1. Do not rerun immediately.
2. List sessions with `oracle status --hours 72`.
3. Run `oracle-await <slug>` if the session exists.
4. If no session exists, restart the MCP host and verify it points at the
canonical wrapper/build, not a stale checkout path.

Always pass an explicit `slug` for long MCP browser consults so the session is
easy to recover after a timeout.
13 changes: 13 additions & 0 deletions skills/oracle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,19 @@ Recommended defaults:
- Runs may detach or take a long time (browser/API + GPT‑5.5 Pro often does). If the CLI times out: don’t re-run; reattach.
- List: `oracle status --hours 72`
- Attach: `oracle session <id> --render`
- To ask a follow-up in the same saved ChatGPT conversation, create a child
session instead of mutating the old one:
- `oracle follow-up <id> --prompt "..." --slug "<3-5 words>"`
- Add `--wait` to observe the child session; otherwise reattach with
`oracle session <child-id> --render`.
- Follow-up v1 is prompt-only. Start a fresh consult if you need new files.
- MCP/browser timeouts can leave `meta.json` stale at `status:"running"` even
when the ChatGPT tab already has a complete answer. Use `oracle-await <id>`
(or `oracle session <id> --render`) to render every poll cycle; render is the
check and the recovery path.
- If a run reports `status:"error"` after saving `artifacts/transcript.md`
(for example Node/undici `setTypeOfService EINVAL` during wrapper cleanup),
read the saved transcript instead of rerunning.
- Use `--slug "<3-5 words>"` to keep session IDs readable.
- Duplicate prompt guard exists; use `--force` only when you truly want a fresh run.
- CLI guardrails: root runs without a prompt exit nonzero; `--dry-run` conflicts with `--render` / `--render-markdown`; Ctrl-C exits foreground API runs with code 130 while browser cleanup/reattach still runs.
Expand Down
Loading
Loading