Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ ask_pro-specific notes:
reduced to the V1 ask-pro surface.
- Keep the CLI small: `ask-pro "<question>"`, `--files`, `--prompt-file`,
`--artifacts` / `--response-zip`, `--dry-run`, `--resume`, `--status`,
`--harvest`, `--copy`, `--temporary`, `--no-temporary`, and
`--harvest`, `--copy`, `--answers`, `--temporary`, `--no-temporary`, and
`--verbose`, and `--github`.
- Browser auth is human-controlled. Never ask for, type, read, or log passwords,
MFA codes, recovery codes, session cookies, or raw auth tokens.
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

### Added

- Hand native ChatGPT clarification questions to the calling agent, including
numbered choices and free text. Resume with `--answers` to answer in the same
conversation, including recovery when the provider expires its question cards.

- Add `--github` with a connection check before upload, human setup in the
managed browser, and a resumable handoff. Request read-only repository evidence
and citations while retaining local-file context. The flag does not restrict
Expand All @@ -14,6 +18,12 @@

### Fixed

- Wait for the new reply when answering expired question cards, and keep
answers retryable when the composer rejects input before sending.

- Capture replies in ChatGPT's current search-unit message layout and close
the controller's DevTools connection when a resumed session hands off questions.

- Prefer ChatGPT's `Latest` model when available, otherwise select the highest
numbered available GPT model and confirm `Pro` intelligence.
- Ignore background Cloudflare scripts and unrelated notices on usable ChatGPT
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,27 @@ Use `ask-pro --help` for the full CLI. For multiline prompts, use
`--prompt-file`. Prefer `--no-temporary` when recovery matters; `--temporary`
requires Temporary Chat. The [skill](skills/ask-pro/SKILL.md) has agent guidance.

When Pro asks native questions, ask-pro returns `awaiting_answers`, prints each
question and its numbered choices, and saves `QUESTIONS.json`. The calling agent
can answer directly, or ask its user when a decision needs their input. Write a
UTF-8 JSON file using the emitted `request_id`, with one answer per question:

```json
{ "requestId": "<request_id>", "answers": [2, 3, "A workshop on incident response"] }
```

Numbers select 1-based options; strings enter free text. Then continue the same chat:

```powershell
ask-pro --resume <session-id> --answers answers.json
```

`--answers -` accepts piped JSON. ask-pro requests a native pause; if ChatGPT expires
the cards anyway, it sends your answers as a reply in the same conversation.
Keep the session's original Temporary Chat mode when answering.
Further question rounds use the same
flow; `--status` repeats pending questions and `--harvest` waits for a final answer.

## Optional GitHub context

Use `--github` to check the GitHub connection in ask-pro's signed-in ChatGPT
Expand Down
58 changes: 55 additions & 3 deletions bin/ask-pro-cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env node
import "dotenv/config";
import { AssistantStoppedError } from "../src/browser/errors.js";
import { AssistantQuestionsError } from "../src/browser/actions/questions.js";
import { readAskProQuestions } from "../src/ask-pro/questions.js";
import { GITHUB_SETUP_URL, GitHubConnectionRequiredError } from "../src/browser/actions/github.js";
import fs from "node:fs/promises";
import path from "node:path";
Expand Down Expand Up @@ -32,6 +34,7 @@ interface AskProOptions {
dryRun?: boolean;
files?: string[];
promptFile?: string;
answers?: string;
artifacts?: boolean;
github?: boolean;
responseZip?: boolean;
Expand Down Expand Up @@ -65,6 +68,11 @@ program
]),
)
.option("--resume [session-id]", "resume a prepared or waiting ask-pro session")
.addOption(
new Option("--answers <path>", "answer native questions from JSON; use - for stdin")
.implies({ resume: true })
.conflicts(["status", "harvest", "copy", "dryRun", "promptFile"]),
)
.option("--status [session-id]", "show ask-pro session status")
.option("--harvest [session-id]", "print harvested ANSWER.md for a session")
.option("--copy [session-id]", "print the copy target for a session")
Expand All @@ -90,6 +98,8 @@ await program.parseAsync(process.argv);

async function runAskPro(question: string, options: AskProOptions): Promise<void> {
const cwd = resolveProjectCwd(options);
if (options.answers && question.trim())
throw new Error("Use --answers with --resume, without a question argument.");
await pruneExpiredAskProSessions({ cwd });
if (options.status !== undefined) {
const { status } = await readAskProStatus({ cwd, sessionId: optionSessionId(options.status) });
Expand Down Expand Up @@ -150,6 +160,14 @@ async function runAskPro(question: string, options: AskProOptions): Promise<void
}
if (options.resume !== undefined) {
const { status } = await readAskProStatus({ cwd, sessionId: optionSessionId(options.resume) });
if (options.answers !== undefined && options.temporary !== undefined) {
const metadata = await readBrowserMetadata(cwd, status.sessionId);
const savedTemporary = metadata?.temporary ?? metadata?.url?.includes("temporary-chat=true");
if (savedTemporary !== options.temporary)
throw new Error(
"Cannot change Temporary Chat mode while answering questions; resume with the saved mode.",
);
}
const effectiveOptions = mergeStatusOptions(options, status);
const resumeCommand = buildResumeCommand(status.sessionId, effectiveOptions, cwd);
const harvestCommand = buildHarvestCommand(status.sessionId, cwd);
Expand Down Expand Up @@ -242,6 +260,12 @@ async function submitOrResumeBrowserSession(
options: AskProOptions,
): Promise<void> {
const { status } = await readAskProStatus({ cwd, sessionId });
if (options.answers !== undefined && status.status !== "AWAITING_ANSWERS")
throw new Error(
"This session is not waiting for answers. Resume without --answers to inspect it.",
);
if (options.answers === "-" && process.stdin.isTTY)
throw new Error("--answers - requires piped stdin.");
if (status.status === "COMPLETED" || status.status === "HARVESTED") {
printStatusRecord(status, {
...(await readBrowserPreflight(cwd, status)),
Expand All @@ -251,6 +275,7 @@ async function submitOrResumeBrowserSession(
}
if (
status.status === "WAITING" ||
status.status === "AWAITING_ANSWERS" ||
status.status === "WAIT_TIMED_OUT" ||
status.status === "INCOMPLETE_ANSWER" ||
status.status === "NEEDS_USER_AUTH" ||
Expand All @@ -262,14 +287,20 @@ async function submitOrResumeBrowserSession(
sessionId,
temporary: options.temporary,
verbose: options.verbose,
answers:
options.answers === undefined
? undefined
: options.answers === "-"
? await readStdin()
: await fs.readFile(path.resolve(cwd, options.answers), "utf8"),
});
} catch (error) {
if (error instanceof GitHubConnectionRequiredError) {
const { status: paused } = await readAskProStatus({ cwd, sessionId });
printStatusRecord(paused);
return;
}
if (error instanceof AssistantStoppedError) {
if (error instanceof AssistantStoppedError || error instanceof AssistantQuestionsError) {
const { status: stopped } = await readAskProStatus({ cwd, sessionId });
printStatusRecord(stopped, await readBrowserPreflight(cwd, stopped));
return;
Expand Down Expand Up @@ -305,7 +336,7 @@ async function submitOrResumeBrowserSession(
printStatusRecord(paused);
return;
}
if (error instanceof AssistantStoppedError) {
if (error instanceof AssistantStoppedError || error instanceof AssistantQuestionsError) {
const { status: stopped } = await readAskProStatus({ cwd, sessionId });
printStatusRecord(stopped, await readBrowserPreflight(cwd, stopped));
return;
Expand Down Expand Up @@ -458,6 +489,8 @@ function actionForStatus(status: AskProStatusFile): string {
switch (status.status) {
case "NEEDS_GITHUB_CONNECTION":
return "connect_github_then_resume";
case "AWAITING_ANSWERS":
return "answer_questions";
case "DRY_RUN_COMPLETE":
case "INCOMPLETE_ANSWER":
case "READY_TO_SUBMIT":
Expand All @@ -482,6 +515,7 @@ function shouldPrintResume(status: AskProStatusFile): boolean {
"DRY_RUN_COMPLETE",
"INCOMPLETE_ANSWER",
"WAITING",
"AWAITING_ANSWERS",
"READY_TO_SUBMIT",
"NEEDS_USER_AUTH",
"NEEDS_GITHUB_CONNECTION",
Expand Down Expand Up @@ -510,7 +544,7 @@ async function readRecoverableCapturedAnswer(
cwd: string,
status: AskProStatusFile,
): Promise<string | null> {
if (status.status === "INCOMPLETE_ANSWER") {
if (status.status === "INCOMPLETE_ANSWER" || status.status === "AWAITING_ANSWERS") {
return null;
}
try {
Expand Down Expand Up @@ -545,7 +579,25 @@ async function readBrowserPreflightForSession(
if (!metadata) return {};
const profileDir = typeof metadata.profileDir === "string" ? metadata.profileDir : undefined;
const profile = profileMode(profileDir);
const questions: AskProToonFields = {};
if (metadata.status === "awaiting_answers") {
const request = await readAskProQuestions(getAskProSessionPaths(cwd, sessionId).dir);
questions.request_id = request.requestId;
questions.questions = request.questions.length;
questions.questions_file = `.ask-pro/sessions/${sessionId}/QUESTIONS.json`;
questions.reply = `${buildSessionCommand(cwd, ["--resume", sessionId, "--answers", "<answers.json>"])}`;
questions.answers_format =
'{"requestId":"<request_id>","answers":[1,"free text"]} (one answer per question; choices are 1-based)';
for (const [index, question] of request.questions.entries()) {
questions[`question_${index + 1}`] = question.question;
questions[`options_${index + 1}`] = question.options
.map((option, choice) => `${choice + 1}: ${option}`)
.join("; ");
questions[`free_text_${index + 1}`] = question.freeText;
}
}
return compactFields({
...questions,
profile,
profile_path: profile && profileDir ? collapseHome(profileDir) : undefined,
chrome: chromeMode(metadata),
Expand Down
11 changes: 11 additions & 0 deletions docs/manual-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ These checks exercise the real ChatGPT browser path. They are opt-in because
they open Chrome, use the signed-in ChatGPT account, and can take several
minutes or longer for Pro thinking.

## Native questions canary

Ask Pro to use its `ask-user-input` skill for three native cards: two choices,
three choices, and a question whose Other field accepts free text, then wait and
summarize the answers. The October 12 tool rejects questions with zero options.
Verify `awaiting_answers` prints all cards, no answer is harvested, and the cards
can still be answered after their original countdown. Reply with `--resume <id>
--answers answers.json` using `{"requestId":"<request_id>","answers":[2,3,"custom theme"]}`.
Verify the final answer preserves all selections and custom text. Repeat with
one and two cards, and verify a stale request ID is rejected before submission.

## Prerequisites

- Node 24+.
Expand Down
11 changes: 11 additions & 0 deletions docs/windows-work.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ Read this when working on `ask_pro` from Windows and add new findings here.

Future Windows gotchas belong here.

- October 12: native clarification cards use
`data-codex-composer-request-navigation` and show one question at a time.
Pointer interaction requests a pause, but the live server returned 409
("Request can no longer be snoozed") even for fresh cards. ask-pro
browses the cards without submitting defaults, retains Chrome, and returns
`AWAITING_ANSWERS`. Use a UTF-8 JSON file with `--resume ... --answers ...`;
this avoids PowerShell quoting problems with multiline free-text answers.
Expired cards are answered by replying in the same conversation; capture waits
for that new reply. A composer that rejects input before sending leaves answers
pending for retry.

- October 7: the live Select model view exposes `GPT-6`, `GPT-5.6 Sol`, and
`GPT-5.5` instead of `Latest`. The default picker prefers `Latest` when present,
otherwise compares visible enabled terminal GPT versions numerically. Pro
Expand Down
10 changes: 10 additions & 0 deletions skills/ask-pro/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,16 @@ Normal `ask-pro` stdout is compact TOON-style telemetry. Use `state`, `action`,
`resume`, and `harvest` to decide the next command. Browser progress may appear
on stderr and can be ignored unless diagnosing a stuck run.

`AWAITING_ANSWERS` / `answer_questions` means Pro is asking native interactive
questions. Read the printed questions/options or `QUESTIONS.json`, answer from
the caller's context, and ask the human only for decisions you cannot infer.
Write UTF-8 JSON `{"requestId":"<request_id>","answers":[2,3,"free text"]}`
with one answer per question in order, then run the emitted
`ask-pro --resume <session-id> --answers <answers.json>` command. Numbers select
1-based choices; strings enter the native free-text field. `--answers -` accepts
piped JSON. Do not submit defaults or treat the question preamble as the answer.
Repeat for further question rounds, then harvest the final answer normally.

When present, use `profile`, `profile_path`, `chrome`, and `language` only as
diagnostic hints. They tell you whether the run used the shared profile, an
isolated agent profile, saved DevTools state, and English browser steering.
Expand Down
59 changes: 56 additions & 3 deletions src/ask-pro/browserRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import fs from "node:fs/promises";
import path from "node:path";
import { DEFAULT_CHATGPT_BROWSER_MODEL_LABEL } from "../browser/chatgptModelCatalog.js";
import { AssistantStoppedError, BrowserAutomationError } from "../browser/errors.js";
import { AssistantQuestionsError } from "../browser/actions/questions.js";
import { readAskProQuestions, parseQuestionAnswers, writeAskProQuestions } from "./questions.js";
import { GitHubConnectionRequiredError } from "../browser/actions/github.js";
import {
askProAgentIdForLegacyBrowserProfileDir,
Expand Down Expand Up @@ -43,6 +45,7 @@ export interface RunAskProBrowserSessionOptions {
agentId?: string | null;
allowStartMinimized?: boolean;
verbose?: boolean;
answers?: string;
}

export async function runAskProBrowserSession(
Expand Down Expand Up @@ -302,6 +305,12 @@ async function runAskProBrowserSessionWithLease({
throw new AskProNeedsAuthError(sessionId, browserProfile, classifyBrowserError(error));
}

if (error instanceof AssistantQuestionsError) {
await writeAskProQuestions(paths.dir, error.questions);
await writeTerminalBrowserMetadata(cwd, sessionId, "awaiting_answers", "questions");
await updateAskProStatus({ cwd, sessionId, status: "AWAITING_ANSWERS", reason: "questions" });
throw error;
}
if (error instanceof AssistantStoppedError) {
await writeTerminalBrowserMetadata(
cwd,
Expand Down Expand Up @@ -351,13 +360,36 @@ async function resumeAskProBrowserSessionWithLease({
sessionId,
temporary,
verbose,
answers,
}: RunAskProBrowserSessionOptions): Promise<void> {
const paths = getAskProSessionPaths(cwd, sessionId);
const prompt = await readAskProPrompt({ cwd, sessionId });
const { status: sessionStatus } = await readAskProStatus({ cwd, sessionId });
const request = answers !== undefined ? await readAskProQuestions(paths.dir) : undefined;
if (answers !== undefined && sessionStatus.status !== "AWAITING_ANSWERS")
throw new Error(
"This session is not waiting for answers. Resume without --answers to inspect it.",
);
const questionAnswers =
request && answers !== undefined
? {
questions: request.questions,
answers: parseQuestionAnswers(answers, request),
submissionState: { started: false },
}
: undefined;
const artifactsRequested = sessionStatus.artifacts === true;
const logger = buildAskProBrowserLogger(cwd, sessionId, verbose);
const metadata = await readBrowserMetadata(paths.browser);
if (
questionAnswers &&
temporary !== undefined &&
temporary !== (metadata.temporary ?? isTemporaryAskProUrl(metadata.url ?? ""))
) {
throw new Error(
"Cannot change Temporary Chat mode while answering questions; resume with the saved mode.",
);
}
const effectiveTemporary = temporary ?? metadata.temporary;
const chatgptUrl =
effectiveTemporary === true
Expand Down Expand Up @@ -468,6 +500,10 @@ async function resumeAskProBrowserSessionWithLease({
},
logger,
{
questionAnswers,
afterQuestionsAnswered: async () => {
await fs.rm(path.join(paths.dir, "QUESTIONS.json"), { force: true });
},
promptPreview: prompt,
chromeModeCb: async (chromeMode) => {
const current = await readBrowserMetadata(paths.browser).catch(() => metadata);
Expand Down Expand Up @@ -565,6 +601,12 @@ async function resumeAskProBrowserSessionWithLease({
});
throw new AskProNeedsAuthError(sessionId, fallbackProfile, classifyBrowserError(error));
}
if (error instanceof AssistantQuestionsError) {
await writeAskProQuestions(paths.dir, error.questions);
await writeTerminalBrowserMetadata(cwd, sessionId, "awaiting_answers", "questions");
await updateAskProStatus({ cwd, sessionId, status: "AWAITING_ANSWERS", reason: "questions" });
throw error;
}
if (error instanceof AssistantStoppedError) {
await writeTerminalBrowserMetadata(
cwd,
Expand All @@ -591,11 +633,17 @@ async function resumeAskProBrowserSessionWithLease({
throw error;
}
const reason = error instanceof Error ? error.message : String(error);
await writeTerminalBrowserMetadata(cwd, sessionId, "failed", reason);
const answersPending = questionAnswers && !questionAnswers.submissionState.started;
await writeTerminalBrowserMetadata(
cwd,
sessionId,
answersPending ? "awaiting_answers" : questionAnswers ? "wait_timed_out" : "failed",
reason,
);
await updateAskProStatus({
cwd,
sessionId,
status: "FAILED",
status: answersPending ? "AWAITING_ANSWERS" : questionAnswers ? "WAIT_TIMED_OUT" : "FAILED",
reason,
});
throw error;
Expand Down Expand Up @@ -841,7 +889,12 @@ function authFailureChromeMode(chromeMode: AskProBrowserMetadata["chromeMode"])
async function writeTerminalBrowserMetadata(
cwd: string,
sessionId: string,
status: "failed" | "wait_timed_out" | "incomplete_answer" | "needs_github_connection",
status:
| "failed"
| "wait_timed_out"
| "incomplete_answer"
| "needs_github_connection"
| "awaiting_answers",
reason: string,
): Promise<void> {
const paths = getAskProSessionPaths(cwd, sessionId);
Expand Down
Loading
Loading