ask-pro 0.1.58 → 0.1.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,6 +88,27 @@ Use `ask-pro --help` for the full CLI. For multiline prompts, use
88
88
  `--prompt-file`. Prefer `--no-temporary` when recovery matters; `--temporary`
89
89
  requires Temporary Chat. The [skill](skills/ask-pro/SKILL.md) has agent guidance.
90
90
 
91
+ When Pro asks native questions, ask-pro returns `awaiting_answers`, prints each
92
+ question and its numbered choices, and saves `QUESTIONS.json`. The calling agent
93
+ can answer directly, or ask its user when a decision needs their input. Write a
94
+ UTF-8 JSON file using the emitted `request_id`, with one answer per question:
95
+
96
+ ```json
97
+ { "requestId": "<request_id>", "answers": [2, 3, "A workshop on incident response"] }
98
+ ```
99
+
100
+ Numbers select 1-based options; strings enter free text. Then continue the same chat:
101
+
102
+ ```powershell
103
+ ask-pro --resume <session-id> --answers answers.json
104
+ ```
105
+
106
+ `--answers -` accepts piped JSON. ask-pro requests a native pause; if ChatGPT expires
107
+ the cards anyway, it sends your answers as a reply in the same conversation.
108
+ Keep the session's original Temporary Chat mode when answering.
109
+ Further question rounds use the same
110
+ flow; `--status` repeats pending questions and `--harvest` waits for a final answer.
111
+
91
112
  ## Optional GitHub context
92
113
 
93
114
  Use `--github` to check the GitHub connection in ask-pro's signed-in ChatGPT
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import "dotenv/config";
3
3
  import { AssistantStoppedError } from "../src/browser/errors.js";
4
+ import { AssistantQuestionsError } from "../src/browser/actions/questions.js";
5
+ import { readAskProQuestions } from "../src/ask-pro/questions.js";
4
6
  import { GITHUB_SETUP_URL, GitHubConnectionRequiredError } from "../src/browser/actions/github.js";
5
7
  import fs from "node:fs/promises";
6
8
  import path from "node:path";
@@ -28,6 +30,9 @@ program
28
30
  "copy",
29
31
  ]))
30
32
  .option("--resume [session-id]", "resume a prepared or waiting ask-pro session")
33
+ .addOption(new Option("--answers <path>", "answer native questions from JSON; use - for stdin")
34
+ .implies({ resume: true })
35
+ .conflicts(["status", "harvest", "copy", "dryRun", "promptFile"]))
31
36
  .option("--status [session-id]", "show ask-pro session status")
32
37
  .option("--harvest [session-id]", "print harvested ANSWER.md for a session")
33
38
  .option("--copy [session-id]", "print the copy target for a session")
@@ -52,6 +57,8 @@ program
52
57
  await program.parseAsync(process.argv);
53
58
  async function runAskPro(question, options) {
54
59
  const cwd = resolveProjectCwd(options);
60
+ if (options.answers && question.trim())
61
+ throw new Error("Use --answers with --resume, without a question argument.");
55
62
  await pruneExpiredAskProSessions({ cwd });
56
63
  if (options.status !== undefined) {
57
64
  const { status } = await readAskProStatus({ cwd, sessionId: optionSessionId(options.status) });
@@ -115,6 +122,12 @@ async function runAskPro(question, options) {
115
122
  }
116
123
  if (options.resume !== undefined) {
117
124
  const { status } = await readAskProStatus({ cwd, sessionId: optionSessionId(options.resume) });
125
+ if (options.answers !== undefined && options.temporary !== undefined) {
126
+ const metadata = await readBrowserMetadata(cwd, status.sessionId);
127
+ const savedTemporary = metadata?.temporary ?? metadata?.url?.includes("temporary-chat=true");
128
+ if (savedTemporary !== options.temporary)
129
+ throw new Error("Cannot change Temporary Chat mode while answering questions; resume with the saved mode.");
130
+ }
118
131
  const effectiveOptions = mergeStatusOptions(options, status);
119
132
  const resumeCommand = buildResumeCommand(status.sessionId, effectiveOptions, cwd);
120
133
  const harvestCommand = buildHarvestCommand(status.sessionId, cwd);
@@ -192,6 +205,10 @@ function optionSessionId(value) {
192
205
  }
193
206
  async function submitOrResumeBrowserSession(cwd, sessionId, options) {
194
207
  const { status } = await readAskProStatus({ cwd, sessionId });
208
+ if (options.answers !== undefined && status.status !== "AWAITING_ANSWERS")
209
+ throw new Error("This session is not waiting for answers. Resume without --answers to inspect it.");
210
+ if (options.answers === "-" && process.stdin.isTTY)
211
+ throw new Error("--answers - requires piped stdin.");
195
212
  if (status.status === "COMPLETED" || status.status === "HARVESTED") {
196
213
  printStatusRecord(status, {
197
214
  ...(await readBrowserPreflight(cwd, status)),
@@ -200,6 +217,7 @@ async function submitOrResumeBrowserSession(cwd, sessionId, options) {
200
217
  return;
201
218
  }
202
219
  if (status.status === "WAITING" ||
220
+ status.status === "AWAITING_ANSWERS" ||
203
221
  status.status === "WAIT_TIMED_OUT" ||
204
222
  status.status === "INCOMPLETE_ANSWER" ||
205
223
  status.status === "NEEDS_USER_AUTH" ||
@@ -210,6 +228,11 @@ async function submitOrResumeBrowserSession(cwd, sessionId, options) {
210
228
  sessionId,
211
229
  temporary: options.temporary,
212
230
  verbose: options.verbose,
231
+ answers: options.answers === undefined
232
+ ? undefined
233
+ : options.answers === "-"
234
+ ? await readStdin()
235
+ : await fs.readFile(path.resolve(cwd, options.answers), "utf8"),
213
236
  });
214
237
  }
215
238
  catch (error) {
@@ -218,7 +241,7 @@ async function submitOrResumeBrowserSession(cwd, sessionId, options) {
218
241
  printStatusRecord(paused);
219
242
  return;
220
243
  }
221
- if (error instanceof AssistantStoppedError) {
244
+ if (error instanceof AssistantStoppedError || error instanceof AssistantQuestionsError) {
222
245
  const { status: stopped } = await readAskProStatus({ cwd, sessionId });
223
246
  printStatusRecord(stopped, await readBrowserPreflight(cwd, stopped));
224
247
  return;
@@ -255,7 +278,7 @@ async function submitOrResumeBrowserSession(cwd, sessionId, options) {
255
278
  printStatusRecord(paused);
256
279
  return;
257
280
  }
258
- if (error instanceof AssistantStoppedError) {
281
+ if (error instanceof AssistantStoppedError || error instanceof AssistantQuestionsError) {
259
282
  const { status: stopped } = await readAskProStatus({ cwd, sessionId });
260
283
  printStatusRecord(stopped, await readBrowserPreflight(cwd, stopped));
261
284
  return;
@@ -388,6 +411,8 @@ function actionForStatus(status) {
388
411
  switch (status.status) {
389
412
  case "NEEDS_GITHUB_CONNECTION":
390
413
  return "connect_github_then_resume";
414
+ case "AWAITING_ANSWERS":
415
+ return "answer_questions";
391
416
  case "DRY_RUN_COMPLETE":
392
417
  case "INCOMPLETE_ANSWER":
393
418
  case "READY_TO_SUBMIT":
@@ -411,6 +436,7 @@ function shouldPrintResume(status) {
411
436
  "DRY_RUN_COMPLETE",
412
437
  "INCOMPLETE_ANSWER",
413
438
  "WAITING",
439
+ "AWAITING_ANSWERS",
414
440
  "READY_TO_SUBMIT",
415
441
  "NEEDS_USER_AUTH",
416
442
  "NEEDS_GITHUB_CONNECTION",
@@ -431,7 +457,7 @@ function isAnswerBearingStatus(status) {
431
457
  return ["COMPLETED", "HARVESTED"].includes(status.status);
432
458
  }
433
459
  async function readRecoverableCapturedAnswer(cwd, status) {
434
- if (status.status === "INCOMPLETE_ANSWER") {
460
+ if (status.status === "INCOMPLETE_ANSWER" || status.status === "AWAITING_ANSWERS") {
435
461
  return null;
436
462
  }
437
463
  try {
@@ -457,7 +483,25 @@ async function readBrowserPreflightForSession(cwd, sessionId) {
457
483
  return {};
458
484
  const profileDir = typeof metadata.profileDir === "string" ? metadata.profileDir : undefined;
459
485
  const profile = profileMode(profileDir);
486
+ const questions = {};
487
+ if (metadata.status === "awaiting_answers") {
488
+ const request = await readAskProQuestions(getAskProSessionPaths(cwd, sessionId).dir);
489
+ questions.request_id = request.requestId;
490
+ questions.questions = request.questions.length;
491
+ questions.questions_file = `.ask-pro/sessions/${sessionId}/QUESTIONS.json`;
492
+ questions.reply = `${buildSessionCommand(cwd, ["--resume", sessionId, "--answers", "<answers.json>"])}`;
493
+ questions.answers_format =
494
+ '{"requestId":"<request_id>","answers":[1,"free text"]} (one answer per question; choices are 1-based)';
495
+ for (const [index, question] of request.questions.entries()) {
496
+ questions[`question_${index + 1}`] = question.question;
497
+ questions[`options_${index + 1}`] = question.options
498
+ .map((option, choice) => `${choice + 1}: ${option}`)
499
+ .join("; ");
500
+ questions[`free_text_${index + 1}`] = question.freeText;
501
+ }
502
+ }
460
503
  return compactFields({
504
+ ...questions,
461
505
  profile,
462
506
  profile_path: profile && profileDir ? collapseHome(profileDir) : undefined,
463
507
  chrome: chromeMode(metadata),
@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { DEFAULT_CHATGPT_BROWSER_MODEL_LABEL } from "../browser/chatgptModelCatalog.js";
4
4
  import { AssistantStoppedError, BrowserAutomationError } from "../browser/errors.js";
5
+ import { AssistantQuestionsError } from "../browser/actions/questions.js";
6
+ import { readAskProQuestions, parseQuestionAnswers, writeAskProQuestions } from "./questions.js";
5
7
  import { GitHubConnectionRequiredError } from "../browser/actions/github.js";
6
8
  import { askProAgentIdForLegacyBrowserProfileDir, askProAgentIdForManagedBrowserProfileDir, askProBrowserProfileDirForAgentId, ensureAskProBrowserProfileDir, isLegacyAskProManagedBrowserProfileDir, isAskProManagedBrowserProfileDir, resolveAskProAgentId, } from "../browser/profilePaths.js";
7
9
  import { runBrowserMode } from "../browserMode.js";
@@ -245,6 +247,12 @@ async function runAskProBrowserSessionWithLease({ cwd, sessionId, temporary: req
245
247
  });
246
248
  throw new AskProNeedsAuthError(sessionId, browserProfile, classifyBrowserError(error));
247
249
  }
250
+ if (error instanceof AssistantQuestionsError) {
251
+ await writeAskProQuestions(paths.dir, error.questions);
252
+ await writeTerminalBrowserMetadata(cwd, sessionId, "awaiting_answers", "questions");
253
+ await updateAskProStatus({ cwd, sessionId, status: "AWAITING_ANSWERS", reason: "questions" });
254
+ throw error;
255
+ }
248
256
  if (error instanceof AssistantStoppedError) {
249
257
  await writeTerminalBrowserMetadata(cwd, sessionId, "incomplete_answer", "stopped_without_answer");
250
258
  await updateAskProStatus({
@@ -281,13 +289,28 @@ export async function resumeAskProBrowserSession(options) {
281
289
  const { dir } = getAskProSessionPaths(options.cwd, options.sessionId);
282
290
  return withSessionControllerLease(dir, () => resumeAskProBrowserSessionWithLease(options));
283
291
  }
284
- async function resumeAskProBrowserSessionWithLease({ cwd, sessionId, temporary, verbose, }) {
292
+ async function resumeAskProBrowserSessionWithLease({ cwd, sessionId, temporary, verbose, answers, }) {
285
293
  const paths = getAskProSessionPaths(cwd, sessionId);
286
294
  const prompt = await readAskProPrompt({ cwd, sessionId });
287
295
  const { status: sessionStatus } = await readAskProStatus({ cwd, sessionId });
296
+ const request = answers !== undefined ? await readAskProQuestions(paths.dir) : undefined;
297
+ if (answers !== undefined && sessionStatus.status !== "AWAITING_ANSWERS")
298
+ throw new Error("This session is not waiting for answers. Resume without --answers to inspect it.");
299
+ const questionAnswers = request && answers !== undefined
300
+ ? {
301
+ questions: request.questions,
302
+ answers: parseQuestionAnswers(answers, request),
303
+ submissionState: { started: false },
304
+ }
305
+ : undefined;
288
306
  const artifactsRequested = sessionStatus.artifacts === true;
289
307
  const logger = buildAskProBrowserLogger(cwd, sessionId, verbose);
290
308
  const metadata = await readBrowserMetadata(paths.browser);
309
+ if (questionAnswers &&
310
+ temporary !== undefined &&
311
+ temporary !== (metadata.temporary ?? isTemporaryAskProUrl(metadata.url ?? ""))) {
312
+ throw new Error("Cannot change Temporary Chat mode while answering questions; resume with the saved mode.");
313
+ }
291
314
  const effectiveTemporary = temporary ?? metadata.temporary;
292
315
  const chatgptUrl = effectiveTemporary === true
293
316
  ? ASK_PRO_TEMPORARY_CHATGPT_URL
@@ -377,6 +400,10 @@ async function resumeAskProBrowserSessionWithLease({ cwd, sessionId, temporary,
377
400
  thinkingTime: "pro",
378
401
  startMinimized: false,
379
402
  }, logger, {
403
+ questionAnswers,
404
+ afterQuestionsAnswered: async () => {
405
+ await fs.rm(path.join(paths.dir, "QUESTIONS.json"), { force: true });
406
+ },
380
407
  promptPreview: prompt,
381
408
  chromeModeCb: async (chromeMode) => {
382
409
  const current = await readBrowserMetadata(paths.browser).catch(() => metadata);
@@ -473,6 +500,12 @@ async function resumeAskProBrowserSessionWithLease({ cwd, sessionId, temporary,
473
500
  });
474
501
  throw new AskProNeedsAuthError(sessionId, fallbackProfile, classifyBrowserError(error));
475
502
  }
503
+ if (error instanceof AssistantQuestionsError) {
504
+ await writeAskProQuestions(paths.dir, error.questions);
505
+ await writeTerminalBrowserMetadata(cwd, sessionId, "awaiting_answers", "questions");
506
+ await updateAskProStatus({ cwd, sessionId, status: "AWAITING_ANSWERS", reason: "questions" });
507
+ throw error;
508
+ }
476
509
  if (error instanceof AssistantStoppedError) {
477
510
  await writeTerminalBrowserMetadata(cwd, sessionId, "incomplete_answer", "stopped_without_answer");
478
511
  await updateAskProStatus({
@@ -494,11 +527,12 @@ async function resumeAskProBrowserSessionWithLease({ cwd, sessionId, temporary,
494
527
  throw error;
495
528
  }
496
529
  const reason = error instanceof Error ? error.message : String(error);
497
- await writeTerminalBrowserMetadata(cwd, sessionId, "failed", reason);
530
+ const answersPending = questionAnswers && !questionAnswers.submissionState.started;
531
+ await writeTerminalBrowserMetadata(cwd, sessionId, answersPending ? "awaiting_answers" : questionAnswers ? "wait_timed_out" : "failed", reason);
498
532
  await updateAskProStatus({
499
533
  cwd,
500
534
  sessionId,
501
- status: "FAILED",
535
+ status: answersPending ? "AWAITING_ANSWERS" : questionAnswers ? "WAIT_TIMED_OUT" : "FAILED",
502
536
  reason,
503
537
  });
504
538
  throw error;
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { atomicWriteFile } from "./atomicWrite.js";
5
+ export async function readAskProQuestions(sessionDir) {
6
+ return JSON.parse(await fs.readFile(path.join(sessionDir, "QUESTIONS.json"), "utf8"));
7
+ }
8
+ export async function writeAskProQuestions(sessionDir, questions) {
9
+ const previous = await readAskProQuestions(sessionDir).catch(() => null);
10
+ const request = {
11
+ requestId: previous && JSON.stringify(previous.questions) === JSON.stringify(questions)
12
+ ? previous.requestId
13
+ : randomUUID(),
14
+ questions,
15
+ };
16
+ await atomicWriteFile(path.join(sessionDir, "QUESTIONS.json"), `${JSON.stringify(request, null, 2)}\n`);
17
+ }
18
+ export function parseQuestionAnswers(raw, request) {
19
+ const value = JSON.parse(raw);
20
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
21
+ throw new Error("Answers must be a JSON object with requestId and answers.");
22
+ }
23
+ if (value?.requestId !== request.requestId)
24
+ throw new Error("Answers belong to a different question request; read the current QUESTIONS.json.");
25
+ if (!Array.isArray(value.answers) || value.answers.length !== request.questions.length) {
26
+ throw new Error("Provide one answer per question in answers, in question order.");
27
+ }
28
+ for (const [index, answer] of value.answers.entries()) {
29
+ const question = request.questions[index];
30
+ if (typeof answer === "number" &&
31
+ Number.isInteger(answer) &&
32
+ answer >= 1 &&
33
+ answer <= question.options.length)
34
+ continue;
35
+ if (typeof answer === "string" && answer.trim() && question.freeText)
36
+ continue;
37
+ throw new Error(`Question ${index + 1}: choose a numbered option or provide nonempty free text.`);
38
+ }
39
+ return value.answers;
40
+ }
@@ -4,6 +4,7 @@ import { logDomFailure } from "../domDebug.js";
4
4
  import { buildClickDispatcher } from "./domEvents.js";
5
5
  import { AssistantStoppedError } from "../errors.js";
6
6
  import { readComposerSnapshot, submitPrompt } from "./promptComposer.js";
7
+ import { AssistantQuestionsError, readAssistantQuestions } from "./questions.js";
7
8
  const ASSISTANT_POLL_TIMEOUT_ERROR = "assistant-response-watchdog-timeout";
8
9
  export function createAssistantContinuation(Runtime, Input, logger, inputGuard, state = { used: false }) {
9
10
  return async (turnIndex) => {
@@ -60,14 +61,18 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
60
61
  const deadline = Date.now() + timeoutMs;
61
62
  while (true) {
62
63
  try {
63
- return await waitForAssistantResponseOnce(Runtime, Math.max(1, deadline - Date.now()), logger, minTurnIndex, expectedConversationId);
64
+ const answer = await waitForAssistantResponseOnce(Runtime, Math.max(1, deadline - Date.now()), logger, minTurnIndex, expectedConversationId);
65
+ const questions = await readAssistantQuestions(Runtime);
66
+ if (questions)
67
+ throw new AssistantQuestionsError(questions);
68
+ return answer;
64
69
  }
65
70
  catch (error) {
66
- if (!(error instanceof AssistantStoppedError))
67
- throw error;
68
71
  await Runtime.evaluate({
69
72
  expression: "window.__askProCancelResponseObserver?.()",
70
73
  }).catch(() => undefined);
74
+ if (!(error instanceof AssistantStoppedError))
75
+ throw error;
71
76
  if (!continueResponse || Date.now() >= deadline)
72
77
  throw error;
73
78
  minTurnIndex = await continueResponse(error.turnIndex);
@@ -126,6 +131,7 @@ async function waitForAssistantResponseOnce(Runtime, timeoutMs, logger, minTurnI
126
131
  }
127
132
  else if (source === "poll") {
128
133
  pollerAbort.abort();
134
+ evaluationPromise.catch(() => undefined);
129
135
  throw error;
130
136
  }
131
137
  else if (source === "evaluation") {
@@ -341,6 +347,9 @@ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expecte
341
347
  if (abortSignal?.aborted) {
342
348
  return null;
343
349
  }
350
+ const questions = await readAssistantQuestions(Runtime);
351
+ if (questions)
352
+ throw new AssistantQuestionsError(questions);
344
353
  const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId);
345
354
  const normalized = normalizeAssistantSnapshot(snapshot);
346
355
  if (normalized) {
@@ -332,7 +332,7 @@ export async function submitPrompt(deps, prompt, logger) {
332
332
  composerState: postVerification,
333
333
  });
334
334
  }
335
- const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames, deps.inputTimeoutMs ?? undefined);
335
+ const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames, deps.inputTimeoutMs ?? undefined, deps.submissionState);
336
336
  if (!clicked) {
337
337
  const canSubmitViaEnter = await canSubmitPromptViaEnter(runtime);
338
338
  if (!canSubmitViaEnter) {
@@ -342,6 +342,8 @@ export async function submitPrompt(deps, prompt, logger) {
342
342
  code: "unsafe-enter-submit",
343
343
  });
344
344
  }
345
+ if (deps.submissionState)
346
+ deps.submissionState.started = true;
345
347
  await input.dispatchKeyEvent({
346
348
  type: "keyDown",
347
349
  ...ENTER_KEY_EVENT,
@@ -539,7 +541,7 @@ async function waitForDomReady(Runtime, logger, timeoutMs = 10_000) {
539
541
  export function buildComposerSendReadinessExpressionForTest() {
540
542
  return buildComposerSendReadinessExpression();
541
543
  }
542
- async function attemptSendButton(Runtime, logger, attachmentNames, inputTimeoutMs) {
544
+ async function attemptSendButton(Runtime, logger, attachmentNames, inputTimeoutMs, submissionState) {
543
545
  const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
544
546
  const deadline = Date.now() +
545
547
  (needAttachment ? Math.max(15_000, Math.min(inputTimeoutMs ?? 30_000, 30_000)) : 8_000);
@@ -572,6 +574,8 @@ async function attemptSendButton(Runtime, logger, attachmentNames, inputTimeoutM
572
574
  continue;
573
575
  }
574
576
  }
577
+ if (submissionState)
578
+ submissionState.started = true;
575
579
  const { result } = await Runtime.evaluate({
576
580
  expression: buildComposerSendClickExpression(),
577
581
  returnByValue: true,
@@ -579,6 +583,8 @@ async function attemptSendButton(Runtime, logger, attachmentNames, inputTimeoutM
579
583
  if (result.value === "clicked") {
580
584
  return true;
581
585
  }
586
+ if (submissionState)
587
+ submissionState.started = false;
582
588
  if (result.value === "stop-button") {
583
589
  throw new BrowserAutomationError("Refusing to click ChatGPT stop control as a send button.", {
584
590
  stage: "submit-prompt",
@@ -0,0 +1,195 @@
1
+ import { readComposerSnapshot, submitPrompt } from "./promptComposer.js";
2
+ import { CONVERSATION_TURN_SELECTOR } from "../constants.js";
3
+ export class AssistantQuestionsError extends Error {
4
+ questions;
5
+ constructor(questions) {
6
+ super("ChatGPT is waiting for answers to its questions.");
7
+ this.questions = questions;
8
+ this.name = "AssistantQuestionsError";
9
+ }
10
+ }
11
+ // This function also runs in Chrome; keep it independent of module state.
12
+ async function questionCards(answers, expected, submission = { started: false }) {
13
+ const selector = '[data-codex-composer-request-navigation="true"]';
14
+ const visible = (node) => {
15
+ const rect = node.getBoundingClientRect();
16
+ return rect.width > 0 && rect.height > 0 && !node.closest('[inert], [aria-hidden="true"]');
17
+ };
18
+ const card = () => Array.from(document.querySelectorAll(selector)).find(visible);
19
+ let root = card();
20
+ if (!root)
21
+ return null;
22
+ // Native pointer interaction cancels ChatGPT's automatic question dismissal.
23
+ root.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }));
24
+ const pause = () => new Promise((resolve) => setTimeout(resolve, 100));
25
+ const read = () => {
26
+ root = card();
27
+ if (!root)
28
+ throw new Error("ChatGPT question card disappeared.");
29
+ return {
30
+ question: root.querySelector(".text-size-chat")?.innerText.trim() ?? "",
31
+ options: Array.from(root.querySelectorAll('[role="radio"]')).map((option) => option.getAttribute("aria-label") ?? option.innerText.replace(/^\d+\s*/, "").trim()),
32
+ freeText: Boolean(root.querySelector('[role="textbox"], textarea, input:not([type="file"])')),
33
+ };
34
+ };
35
+ const position = () => {
36
+ const match = root?.innerText.match(/(?:^|\n)(\d+) of (\d+)(?:\n|$)/);
37
+ return match ? { index: Number(match[1]), count: Number(match[2]) } : { index: 1, count: 1 };
38
+ };
39
+ const move = async (direction) => {
40
+ const before = position().index;
41
+ const button = root?.querySelector(`button[aria-label="${direction} question"]`);
42
+ if (!button || button.disabled)
43
+ throw new Error("Cannot browse ChatGPT's questions.");
44
+ button.click();
45
+ const deadline = Date.now() + 5_000;
46
+ do {
47
+ await pause();
48
+ read();
49
+ if (position().index !== before)
50
+ return;
51
+ } while (Date.now() < deadline);
52
+ throw new Error("ChatGPT did not change questions.");
53
+ };
54
+ while (position().index > 1)
55
+ await move("Previous");
56
+ const questions = [];
57
+ const count = position().count;
58
+ for (let index = 0; index < count; index++) {
59
+ const current = read();
60
+ if (!current.question)
61
+ throw new Error("Cannot read ChatGPT's question.");
62
+ questions.push(current);
63
+ if (index + 1 < count)
64
+ await move("Next");
65
+ }
66
+ while (position().index > 1)
67
+ await move("Previous");
68
+ if (!answers)
69
+ return questions;
70
+ if (JSON.stringify(questions) !== JSON.stringify(expected)) {
71
+ throw new Error("ChatGPT's questions changed; read the current questions before answering.");
72
+ }
73
+ if (answers.length !== questions.length)
74
+ throw new Error("Provide one answer per question.");
75
+ for (const [index, answer] of answers.entries()) {
76
+ const question = questions[index];
77
+ if (typeof answer === "number") {
78
+ if (!Number.isInteger(answer) || answer < 1 || answer > question.options.length) {
79
+ throw new Error(`Question ${index + 1}: choose 1-${question.options.length} or provide free text.`);
80
+ }
81
+ }
82
+ else if (typeof answer !== "string" || !answer.trim() || !question.freeText) {
83
+ throw new Error(`Question ${index + 1}: a nonempty free-text answer is required.`);
84
+ }
85
+ }
86
+ for (const [index, answer] of answers.entries()) {
87
+ read();
88
+ if (typeof answer === "number") {
89
+ const options = Array.from(root.querySelectorAll('[role="radio"]'));
90
+ if (index + 1 === count)
91
+ submission.started = true;
92
+ options[answer - 1].click();
93
+ if (index + 1 < count) {
94
+ const deadline = Date.now() + 5_000;
95
+ do {
96
+ await pause();
97
+ read();
98
+ } while (position().index === index + 1 && Date.now() < deadline);
99
+ if (position().index !== index + 2)
100
+ throw new Error(`ChatGPT did not accept answer for question ${index + 1}.`);
101
+ }
102
+ }
103
+ else {
104
+ const input = root.querySelector('[role="textbox"], textarea, input:not([type="file"])');
105
+ if (!input)
106
+ throw new Error("ChatGPT's free-text input is missing.");
107
+ input.focus();
108
+ if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
109
+ const prototype = input instanceof HTMLInputElement
110
+ ? HTMLInputElement.prototype
111
+ : HTMLTextAreaElement.prototype;
112
+ Object.getOwnPropertyDescriptor(prototype, "value").set.call(input, answer);
113
+ input.dispatchEvent(new Event("input", { bubbles: true }));
114
+ }
115
+ else {
116
+ const selection = window.getSelection();
117
+ const range = document.createRange();
118
+ range.selectNodeContents(input);
119
+ selection?.removeAllRanges();
120
+ selection?.addRange(range);
121
+ document.execCommand("insertText", false, answer);
122
+ }
123
+ await pause();
124
+ if ((input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement
125
+ ? input.value
126
+ : Array.from(input.children)
127
+ .map((child) => (child.textContent ? child.innerText : ""))
128
+ .join("\n")) !== answer) {
129
+ throw new Error(`Could not enter answer for question ${index + 1}.`);
130
+ }
131
+ }
132
+ if (typeof answer === "string" && index + 1 < count)
133
+ await move("Next");
134
+ }
135
+ // Choice clicks advance/submit natively; a custom answer uses the card's submit button.
136
+ if (typeof answers.at(-1) === "string") {
137
+ const submit = root.querySelector("[data-request-input-skip]");
138
+ if (!submit || submit.disabled || !/^(submit|send|continue)$/i.test(submit.innerText.trim()))
139
+ throw new Error("ChatGPT's answer submit button is not ready.");
140
+ submission.started = true;
141
+ submit.click();
142
+ }
143
+ const deadline = Date.now() + 5_000;
144
+ while (card() && Date.now() < deadline)
145
+ await pause();
146
+ if (card())
147
+ throw new Error("ChatGPT did not accept the answers; inspect the question card before retrying.");
148
+ return questions;
149
+ }
150
+ export function buildQuestionCardsExpression(answers, expected) {
151
+ // tsx preserves function names with __name; Chrome only needs the function itself.
152
+ const call = `(${questionCards.toString()})(${JSON.stringify(answers) ?? "undefined"}, ${JSON.stringify(expected) ?? "undefined"}, submission)`;
153
+ return `(() => { const __name = (fn) => fn; const submission = { started: false }; return ${answers ? `${call}.then(questions => ({ questions, started: submission.started }), error => ({ error: String(error), started: submission.started }))` : call}; })()`;
154
+ }
155
+ export async function readAssistantQuestions(Runtime) {
156
+ const evaluation = await Runtime.evaluate({
157
+ expression: buildQuestionCardsExpression(),
158
+ awaitPromise: true,
159
+ returnByValue: true,
160
+ });
161
+ if (evaluation.exceptionDetails)
162
+ throw new Error(evaluation.exceptionDetails.exception?.description ?? "Cannot read ChatGPT's questions.");
163
+ return Array.isArray(evaluation.result.value) && evaluation.result.value.length > 0
164
+ ? evaluation.result.value
165
+ : null;
166
+ }
167
+ export async function answerAssistantQuestions(Runtime, Input, logger, answers, expected, submissionState = { started: false }) {
168
+ // A lost transport may have submitted successfully; never replay that attempt automatically.
169
+ submissionState.started = true;
170
+ const evaluation = await Runtime.evaluate({
171
+ expression: buildQuestionCardsExpression(answers, expected),
172
+ awaitPromise: true,
173
+ returnByValue: true,
174
+ });
175
+ if (evaluation.exceptionDetails)
176
+ throw new Error(evaluation.exceptionDetails.exception?.description ?? "Cannot answer ChatGPT's questions.");
177
+ const value = evaluation.result.value;
178
+ submissionState.started = value.started;
179
+ if (value.error)
180
+ throw new Error(value.error);
181
+ if (!value.questions) {
182
+ // Native snooze returned 409 in the canary; expired cards need a reply in the same chat.
183
+ const composer = await readComposerSnapshot(Runtime);
184
+ if (composer.activeValue.trim())
185
+ throw new Error("The ChatGPT composer has a draft; clear it before answering expired questions.");
186
+ const prompt = `Answers to your clarification questions:\n\n${expected.map((question, index) => `${index + 1}. ${question.question}\n${typeof answers[index] === "number" ? question.options[answers[index] - 1] : answers[index]}`).join("\n\n")}\n\nContinue the original request using these answers.`;
187
+ const { result } = await Runtime.evaluate({
188
+ expression: `document.querySelectorAll(${JSON.stringify(CONVERSATION_TURN_SELECTOR)}).length`,
189
+ returnByValue: true,
190
+ });
191
+ const baselineTurns = result.value;
192
+ await submitPrompt({ runtime: Runtime, input: Input, baselineTurns, submissionState }, prompt, logger);
193
+ return baselineTurns;
194
+ }
195
+ }
@@ -1,5 +1,5 @@
1
1
  import { formatElapsed } from "../format.js";
2
- import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR } from "../constants.js";
2
+ import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, STOP_BUTTON_SELECTOR, } from "../constants.js";
3
3
  const THINKING_STALE_HINT_MS = 10 * 60_000;
4
4
  export function startThinkingStatusMonitor(Runtime, logger, options = {}) {
5
5
  const intervalMs = resolveThinkingStatusInterval(options.intervalMs);
@@ -200,13 +200,15 @@ function buildThinkingStatusExpression() {
200
200
  [node.textContent, node.getAttribute?.('aria-label'), node.getAttribute?.('title')]
201
201
  .some((label) => normalize(label) === expected),
202
202
  );
203
- if (hasControl('answer now') && hasControl('stop answering')) {
203
+ const stopActive = hasControl('stop answering') ||
204
+ Array.from(document.querySelectorAll(${JSON.stringify(STOP_BUTTON_SELECTOR)})).some(isVisible);
205
+ if (hasControl('answer now') && stopActive) {
204
206
  return {
205
207
  message: 'pro gate active',
206
208
  source: 'inline',
207
209
  };
208
210
  }
209
- if (hasControl('stop answering')) {
211
+ if (stopActive) {
210
212
  return { message: 'active', source: 'inline' };
211
213
  }
212
214
  const looksLikeThinking = (node) => {
@@ -32,8 +32,8 @@ export const ANSWER_SELECTORS = [
32
32
  ];
33
33
  export const CONVERSATION_TURN_SELECTOR = 'article[data-testid^="conversation-turn"], div[data-testid^="conversation-turn"], section[data-testid^="conversation-turn"], ' +
34
34
  "article[data-message-author-role], div[data-message-author-role], section[data-message-author-role], " +
35
- "article[data-turn], div[data-turn], section[data-turn]";
36
- export const ASSISTANT_ROLE_SELECTOR = '[data-message-author-role="assistant"], [data-turn="assistant"]';
35
+ 'article[data-turn], div[data-turn], section[data-turn], [data-content-search-unit-key$=":assistant"], [data-content-search-unit-key$=":user"]';
36
+ export const ASSISTANT_ROLE_SELECTOR = '[data-message-author-role="assistant"], [data-turn="assistant"], [data-markdown-text-style="assistant-message"]';
37
37
  export const CLOUDFLARE_TITLE = "just a moment";
38
38
  export const PROMPT_PRIMARY_SELECTOR = "#prompt-textarea";
39
39
  export const PROMPT_FALLBACK_SELECTOR = 'textarea[name="prompt-textarea"]';
@@ -19,6 +19,7 @@ import { estimateTokenCount, withRetries, delay } from "./utils.js";
19
19
  import { formatElapsed } from "./format.js";
20
20
  import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from "./constants.js";
21
21
  import { AssistantStoppedError, BrowserAutomationError } from "./errors.js";
22
+ import { AssistantQuestionsError } from "./actions/questions.js";
22
23
  import { defaultAskProBrowserProfileDir } from "./profilePaths.js";
23
24
  import { applyPageLanguageOverrides, seedChromeProfileLanguage } from "./language.js";
24
25
  import { alignPromptEchoPair, buildPromptEchoMatcher, extractConversationIdFromUrl, withTimeout, } from "./reattachHelpers.js";
@@ -1292,6 +1293,12 @@ export async function runBrowserMode(options) {
1292
1293
  }
1293
1294
  catch (error) {
1294
1295
  let normalizedError = error instanceof Error ? error : new Error(String(error));
1296
+ if (normalizedError instanceof AssistantQuestionsError) {
1297
+ preserveBrowserOnError = true;
1298
+ preserveWindowStateOnError = true;
1299
+ await emitRuntimeHint();
1300
+ throw normalizedError;
1301
+ }
1295
1302
  if (normalizedError instanceof GitHubConnectionRequiredError) {
1296
1303
  preserveBrowserOnError = true;
1297
1304
  await revealAuthenticatedWindow("github-connection-required");
@@ -1,4 +1,5 @@
1
1
  import { AssistantStoppedError } from "./errors.js";
2
+ import { AssistantQuestionsError, answerAssistantQuestions, } from "./actions/questions.js";
2
3
  import { createAssistantContinuation } from "./actions/assistantResponse.js";
3
4
  import CDP from "chrome-remote-interface";
4
5
  import os from "node:os";
@@ -113,6 +114,7 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
113
114
  ? await acquireReattachRunLease(directUserDataDir, resolvedConfig.profileLockTimeoutMs, logger)
114
115
  : null;
115
116
  let closeConnection = null;
117
+ let answeringQuestions = false;
116
118
  try {
117
119
  const liveRuntime = await refreshAttachRuntime(runtime, logger).catch(() => runtime);
118
120
  if (!liveRuntime.chromePort && !liveRuntime.chromeBrowserWSEndpoint) {
@@ -152,7 +154,8 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
152
154
  })),
153
155
  close: async () => undefined,
154
156
  };
155
- closeConnection = connection.close;
157
+ closeConnection =
158
+ browserWSEndpoint && !deps.connect ? connection.close : () => connection.client.close();
156
159
  const connectedTargetId = "targetId" in connection ? connection.targetId : target?.targetId;
157
160
  const ownsConnectedTarget = Boolean(!resolvedConfig.browserTabRef &&
158
161
  liveRuntime.chromeTargetId &&
@@ -213,7 +216,14 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
213
216
  const pingTimeoutMs = Math.min(5_000, Math.max(1_500, Math.floor(timeoutMs * 0.05)));
214
217
  await withTimeout(Runtime.evaluate({ expression: "1+1", returnByValue: true }), pingTimeoutMs, "Reattach target did not respond");
215
218
  await ensureConversationOpen();
216
- const minTurnIndex = await readConversationTurnIndex(Runtime, logger);
219
+ let minTurnIndex = await readConversationTurnIndex(Runtime, logger);
220
+ if (deps.questionAnswers) {
221
+ answeringQuestions = true;
222
+ const replyBoundary = await answerAssistantQuestions(Runtime, Input, logger, deps.questionAnswers.answers, deps.questionAnswers.questions, deps.questionAnswers.submissionState);
223
+ if (replyBoundary !== undefined)
224
+ minTurnIndex = replyBoundary;
225
+ await deps.afterQuestionsAnswered?.();
226
+ }
217
227
  const promptEcho = buildPromptEchoMatcher(deps.promptPreview);
218
228
  const answer = await withTimeout(waitForResponse(Runtime, timeoutMs, logger, minTurnIndex ?? undefined, runtime.conversationId, createAssistantContinuation(Runtime, Input, logger, undefined, deps.continuationState)), timeoutMs + 5_000, "Reattach response timed out");
219
229
  const recovered = await recoverPromptEcho(Runtime, answer, promptEcho, logger, minTurnIndex, timeoutMs);
@@ -228,7 +238,7 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
228
238
  markdown: aligned.answerMarkdown,
229
239
  },
230
240
  });
231
- await connection.close().catch(() => undefined);
241
+ await closeConnection().catch(() => undefined);
232
242
  closeConnection = null;
233
243
  const keepBrowserOpen = Boolean(resolvedConfig.keepBrowser ||
234
244
  resolvedConfig.browserTabRef ||
@@ -261,7 +271,10 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
261
271
  await closeConnection?.().catch(() => undefined);
262
272
  await releaseReattachRunLease(reattachRunLease, logger);
263
273
  reattachRunLease = null;
264
- if (error instanceof AssistantStoppedError)
274
+ if (error instanceof AssistantStoppedError ||
275
+ error instanceof AssistantQuestionsError ||
276
+ answeringQuestions ||
277
+ deps.questionAnswers?.submissionState.started)
265
278
  throw error;
266
279
  const message = error instanceof Error ? error.message : String(error);
267
280
  logger(`Existing Chrome reattach failed (${message}); reopening browser to locate the session.`);
@@ -482,7 +495,13 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
482
495
  const waitForResponse = deps.waitForAssistantResponse ?? waitForAssistantResponse;
483
496
  const captureMarkdown = deps.captureAssistantMarkdown ?? captureAssistantMarkdown;
484
497
  const timeoutMs = resolved.timeoutMs ?? 120_000;
485
- const minTurnIndex = await readConversationTurnIndex(Runtime, logger);
498
+ let minTurnIndex = await readConversationTurnIndex(Runtime, logger);
499
+ if (deps.questionAnswers) {
500
+ const replyBoundary = await answerAssistantQuestions(Runtime, client.Input, logger, deps.questionAnswers.answers, deps.questionAnswers.questions, deps.questionAnswers.submissionState);
501
+ if (replyBoundary !== undefined)
502
+ minTurnIndex = replyBoundary;
503
+ await deps.afterQuestionsAnswered?.();
504
+ }
486
505
  const promptEcho = buildPromptEchoMatcher(deps.promptPreview);
487
506
  const answer = await waitForResponse(Runtime, timeoutMs, logger, minTurnIndex ?? undefined, runtime.conversationId, createAssistantContinuation(Runtime, client.Input, logger, undefined, deps.continuationState));
488
507
  const recovered = await recoverPromptEcho(Runtime, answer, promptEcho, logger, minTurnIndex, timeoutMs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ask-pro",
3
- "version": "0.1.58",
3
+ "version": "0.1.63",
4
4
  "description": "Browser-backed ChatGPT Pro escalation for hard engineering questions.",
5
5
  "keywords": [
6
6
  "agents",
@@ -162,6 +162,16 @@ Normal `ask-pro` stdout is compact TOON-style telemetry. Use `state`, `action`,
162
162
  `resume`, and `harvest` to decide the next command. Browser progress may appear
163
163
  on stderr and can be ignored unless diagnosing a stuck run.
164
164
 
165
+ `AWAITING_ANSWERS` / `answer_questions` means Pro is asking native interactive
166
+ questions. Read the printed questions/options or `QUESTIONS.json`, answer from
167
+ the caller's context, and ask the human only for decisions you cannot infer.
168
+ Write UTF-8 JSON `{"requestId":"<request_id>","answers":[2,3,"free text"]}`
169
+ with one answer per question in order, then run the emitted
170
+ `ask-pro --resume <session-id> --answers <answers.json>` command. Numbers select
171
+ 1-based choices; strings enter the native free-text field. `--answers -` accepts
172
+ piped JSON. Do not submit defaults or treat the question preamble as the answer.
173
+ Repeat for further question rounds, then harvest the final answer normally.
174
+
165
175
  When present, use `profile`, `profile_path`, `chrome`, and `language` only as
166
176
  diagnostic hints. They tell you whether the run used the shared profile, an
167
177
  isolated agent profile, saved DevTools state, and English browser steering.