ask-pro 0.1.53 → 0.1.61

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
@@ -1,8 +1,8 @@
1
1
  # ask-pro
2
2
 
3
3
  `ask-pro` gives coding agents a focused ChatGPT Pro second opinion through a
4
- human-logged-in browser. The calling agent supplies the context and owns the
5
- result; ask-pro never applies generated code.
4
+ human-logged-in browser. The calling agent supplies the question and evidence,
5
+ and owns the result; ask-pro never applies generated code.
6
6
 
7
7
  ## Install
8
8
 
@@ -72,8 +72,9 @@ ask-pro --no-temporary --prompt-file question.md --files src --files tests
72
72
  ```
73
73
 
74
74
  ask-pro selects `Latest` when available, otherwise the highest numbered available
75
- GPT model, with `Pro` intelligence. It has no repo or conversation
76
- context unless you provide it, so keep the prompt and file bundle focused.
75
+ GPT model, with `Pro` intelligence. Supply the caller's context explicitly.
76
+ Use a focused file bundle for local code; connected GitHub can provide remote
77
+ repository context when available (see below).
77
78
 
78
79
  Request generated files only when needed; treat them as data and never execute
79
80
  them automatically:
@@ -87,6 +88,61 @@ Use `ask-pro --help` for the full CLI. For multiline prompts, use
87
88
  `--prompt-file`. Prefer `--no-temporary` when recovery matters; `--temporary`
88
89
  requires Temporary Chat. The [skill](skills/ask-pro/SKILL.md) has agent guidance.
89
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
+
112
+ ## Optional GitHub context
113
+
114
+ Use `--github` to check the GitHub connection in ask-pro's signed-in ChatGPT
115
+ account before sending any prompt or files. It defaults to normal ChatGPT for
116
+ recovery; an explicit `--temporary` still requires Temporary Chat.
117
+
118
+ If GitHub is disconnected or its connection cannot be confirmed, ask-pro opens
119
+ GitHub's ChatGPT page and returns `connect_github_then_resume`. Install the
120
+ plugin if needed, connect your account yourself, and review repository access.
121
+ Select only the repositories you want available; an organization may require
122
+ approval. Then run the emitted `--resume` command. It checks the connection again
123
+ before submitting. ChatGPT login alone does not pass this GitHub check.
124
+
125
+ Then explicitly request repository evidence in the prompt:
126
+
127
+ ```powershell
128
+ ask-pro --github "Read owner/repo and explain its request authentication flow."
129
+ ```
130
+
131
+ Use `--files` for local changes or a precise snapshot that GitHub cannot retrieve.
132
+ For committed repository code, prefer `--github` with the repository, ref, paths,
133
+ and question; source-file uploads are usually unnecessary when retrieval works.
134
+ The flag requests read-only work and citations, but **does not enforce read-only
135
+ GitHub permissions**. The live GitHub plugin advertises write capabilities;
136
+ review the actual grant even though the [Help Center](https://help.openai.com/en/articles/11145903-connecting-github-to-chatgpt)
137
+ describes a read-only app. [Allow read actions](https://help.openai.com/en/articles/20001495-managing-app-permissions-in-chatgpt)
138
+ controls approval prompts, not the underlying access grant.
139
+
140
+ A connected account does not prove access to a particular repository, revision,
141
+ or Pro experience. ask-pro does not select an app in the composer, approve app
142
+ actions, or verify the answer's sources. The opt-in [GitHub smoke](docs/manual-tests.md#connected-github-context)
143
+ checks actual retrieval. GitHub authorization stays with ChatGPT; ask-pro needs
144
+ no GitHub token.
145
+
90
146
  ## Development
91
147
 
92
148
  ```powershell
@@ -1,6 +1,9 @@
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";
6
+ import { GITHUB_SETUP_URL, GitHubConnectionRequiredError } from "../src/browser/actions/github.js";
4
7
  import fs from "node:fs/promises";
5
8
  import path from "node:path";
6
9
  import { Command, Option } from "commander";
@@ -20,7 +23,16 @@ program
20
23
  .option("--prompt-file <path>", "read the question from a UTF-8 file; use - for stdin")
21
24
  .option("--artifacts", "ask Pro for ask-pro-response.zip plus markdown fallback")
22
25
  .option("--response-zip", "alias for --artifacts")
26
+ .addOption(new Option("--github", "use connected GitHub; pause for setup before sending").conflicts([
27
+ "resume",
28
+ "status",
29
+ "harvest",
30
+ "copy",
31
+ ]))
23
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"]))
24
36
  .option("--status [session-id]", "show ask-pro session status")
25
37
  .option("--harvest [session-id]", "print harvested ANSWER.md for a session")
26
38
  .option("--copy [session-id]", "print the copy target for a session")
@@ -45,6 +57,8 @@ program
45
57
  await program.parseAsync(process.argv);
46
58
  async function runAskPro(question, options) {
47
59
  const cwd = resolveProjectCwd(options);
60
+ if (options.answers && question.trim())
61
+ throw new Error("Use --answers with --resume, without a question argument.");
48
62
  await pruneExpiredAskProSessions({ cwd });
49
63
  if (options.status !== undefined) {
50
64
  const { status } = await readAskProStatus({ cwd, sessionId: optionSessionId(options.status) });
@@ -108,6 +122,12 @@ async function runAskPro(question, options) {
108
122
  }
109
123
  if (options.resume !== undefined) {
110
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
+ }
111
131
  const effectiveOptions = mergeStatusOptions(options, status);
112
132
  const resumeCommand = buildResumeCommand(status.sessionId, effectiveOptions, cwd);
113
133
  const harvestCommand = buildHarvestCommand(status.sessionId, cwd);
@@ -124,6 +144,8 @@ async function runAskPro(question, options) {
124
144
  return;
125
145
  }
126
146
  const dryRun = options.dryRun === true;
147
+ if (options.github && options.temporary === undefined)
148
+ options.temporary = false;
127
149
  const resolvedQuestion = await resolveQuestion(question, options, cwd);
128
150
  const artifacts = options.artifacts === true || options.responseZip === true;
129
151
  const session = await createAskProSession({
@@ -132,6 +154,7 @@ async function runAskPro(question, options) {
132
154
  filePatterns: options.files ?? [],
133
155
  dryRun,
134
156
  artifacts,
157
+ github: options.github,
135
158
  });
136
159
  const resumeCommand = buildResumeCommand(session.id, options, cwd);
137
160
  const harvestCommand = buildHarvestCommand(session.id, cwd);
@@ -182,6 +205,10 @@ function optionSessionId(value) {
182
205
  }
183
206
  async function submitOrResumeBrowserSession(cwd, sessionId, options) {
184
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.");
185
212
  if (status.status === "COMPLETED" || status.status === "HARVESTED") {
186
213
  printStatusRecord(status, {
187
214
  ...(await readBrowserPreflight(cwd, status)),
@@ -190,19 +217,31 @@ async function submitOrResumeBrowserSession(cwd, sessionId, options) {
190
217
  return;
191
218
  }
192
219
  if (status.status === "WAITING" ||
220
+ status.status === "AWAITING_ANSWERS" ||
193
221
  status.status === "WAIT_TIMED_OUT" ||
194
222
  status.status === "INCOMPLETE_ANSWER" ||
195
- status.status === "NEEDS_USER_AUTH") {
223
+ status.status === "NEEDS_USER_AUTH" ||
224
+ status.status === "NEEDS_GITHUB_CONNECTION") {
196
225
  try {
197
226
  await resumeAskProBrowserSession({
198
227
  cwd,
199
228
  sessionId,
200
229
  temporary: options.temporary,
201
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"),
202
236
  });
203
237
  }
204
238
  catch (error) {
205
- if (error instanceof AssistantStoppedError) {
239
+ if (error instanceof GitHubConnectionRequiredError) {
240
+ const { status: paused } = await readAskProStatus({ cwd, sessionId });
241
+ printStatusRecord(paused);
242
+ return;
243
+ }
244
+ if (error instanceof AssistantStoppedError || error instanceof AssistantQuestionsError) {
206
245
  const { status: stopped } = await readAskProStatus({ cwd, sessionId });
207
246
  printStatusRecord(stopped, await readBrowserPreflight(cwd, stopped));
208
247
  return;
@@ -234,7 +273,12 @@ async function submitOrResumeBrowserSession(cwd, sessionId, options) {
234
273
  });
235
274
  }
236
275
  catch (error) {
237
- if (error instanceof AssistantStoppedError) {
276
+ if (error instanceof GitHubConnectionRequiredError) {
277
+ const { status: paused } = await readAskProStatus({ cwd, sessionId });
278
+ printStatusRecord(paused);
279
+ return;
280
+ }
281
+ if (error instanceof AssistantStoppedError || error instanceof AssistantQuestionsError) {
238
282
  const { status: stopped } = await readAskProStatus({ cwd, sessionId });
239
283
  printStatusRecord(stopped, await readBrowserPreflight(cwd, stopped));
240
284
  return;
@@ -320,6 +364,11 @@ function printStatusRecord(status, extra = {}) {
320
364
  state: normalizeState(status.status),
321
365
  reason: status.reason,
322
366
  temporary: normalizeTemporary(status.temporary),
367
+ github: status.github || undefined,
368
+ setup_url: status.status === "NEEDS_GITHUB_CONNECTION" ? GITHUB_SETUP_URL : undefined,
369
+ help: status.status === "NEEDS_GITHUB_CONNECTION"
370
+ ? "In the opened ChatGPT page, install GitHub if needed, connect your account, review repository access, then resume."
371
+ : undefined,
323
372
  action: actionForStatus(status),
324
373
  resume: shouldPrintResume(status) ? status.resumeCommand : undefined,
325
374
  harvest: shouldPrintHarvest(status) ? status.harvestCommand : undefined,
@@ -360,6 +409,10 @@ function actionForStatus(status) {
360
409
  if (status.reason === "stopped_without_answer")
361
410
  return "choose_resume_or_retry";
362
411
  switch (status.status) {
412
+ case "NEEDS_GITHUB_CONNECTION":
413
+ return "connect_github_then_resume";
414
+ case "AWAITING_ANSWERS":
415
+ return "answer_questions";
363
416
  case "DRY_RUN_COMPLETE":
364
417
  case "INCOMPLETE_ANSWER":
365
418
  case "READY_TO_SUBMIT":
@@ -383,8 +436,10 @@ function shouldPrintResume(status) {
383
436
  "DRY_RUN_COMPLETE",
384
437
  "INCOMPLETE_ANSWER",
385
438
  "WAITING",
439
+ "AWAITING_ANSWERS",
386
440
  "READY_TO_SUBMIT",
387
441
  "NEEDS_USER_AUTH",
442
+ "NEEDS_GITHUB_CONNECTION",
388
443
  "WAIT_TIMED_OUT",
389
444
  "FAILED",
390
445
  ].includes(status.status);
@@ -402,7 +457,7 @@ function isAnswerBearingStatus(status) {
402
457
  return ["COMPLETED", "HARVESTED"].includes(status.status);
403
458
  }
404
459
  async function readRecoverableCapturedAnswer(cwd, status) {
405
- if (status.status === "INCOMPLETE_ANSWER") {
460
+ if (status.status === "INCOMPLETE_ANSWER" || status.status === "AWAITING_ANSWERS") {
406
461
  return null;
407
462
  }
408
463
  try {
@@ -428,7 +483,25 @@ async function readBrowserPreflightForSession(cwd, sessionId) {
428
483
  return {};
429
484
  const profileDir = typeof metadata.profileDir === "string" ? metadata.profileDir : undefined;
430
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
+ }
431
503
  return compactFields({
504
+ ...questions,
432
505
  profile,
433
506
  profile_path: profile && profileDir ? collapseHome(profileDir) : undefined,
434
507
  chrome: chromeMode(metadata),
@@ -2,6 +2,9 @@ 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";
7
+ import { GitHubConnectionRequiredError } from "../browser/actions/github.js";
5
8
  import { askProAgentIdForLegacyBrowserProfileDir, askProAgentIdForManagedBrowserProfileDir, askProBrowserProfileDirForAgentId, ensureAskProBrowserProfileDir, isLegacyAskProManagedBrowserProfileDir, isAskProManagedBrowserProfileDir, resolveAskProAgentId, } from "../browser/profilePaths.js";
6
9
  import { runBrowserMode } from "../browserMode.js";
7
10
  import { closeTab } from "../browser/chromeLifecycle.js";
@@ -18,10 +21,11 @@ export async function runAskProBrowserSession(options) {
18
21
  const { dir } = getAskProSessionPaths(options.cwd, options.sessionId);
19
22
  return withSessionControllerLease(dir, () => runAskProBrowserSessionWithLease(options));
20
23
  }
21
- async function runAskProBrowserSessionWithLease({ cwd, sessionId, temporary, chatgptUrl: chatgptUrlOverride, browserProfileDir, agentId: agentIdOverride, allowStartMinimized = true, verbose, }) {
24
+ async function runAskProBrowserSessionWithLease({ cwd, sessionId, temporary: requestedTemporary, chatgptUrl: chatgptUrlOverride, browserProfileDir, agentId: agentIdOverride, allowStartMinimized = true, verbose, }) {
22
25
  const paths = getAskProSessionPaths(cwd, sessionId);
23
26
  const prompt = await readAskProPrompt({ cwd, sessionId });
24
27
  const { status: sessionStatus } = await readAskProStatus({ cwd, sessionId });
28
+ const temporary = requestedTemporary ?? (sessionStatus.github ? false : undefined);
25
29
  const artifactsRequested = sessionStatus.artifacts === true;
26
30
  const agentId = agentIdOverride !== undefined ? agentIdOverride : resolveAskProAgentId();
27
31
  const browserProfile = browserProfileDir ?? (await ensureAskProBrowserProfileDir(agentId));
@@ -62,6 +66,7 @@ async function runAskProBrowserSessionWithLease({ cwd, sessionId, temporary, cha
62
66
  },
63
67
  ],
64
68
  config: {
69
+ github: sessionStatus.github,
65
70
  url: chatgptUrl,
66
71
  manualLogin: true,
67
72
  attachRunning: false,
@@ -163,7 +168,7 @@ async function runAskProBrowserSessionWithLease({ cwd, sessionId, temporary, cha
163
168
  temporary,
164
169
  })) {
165
170
  await appendAskProLog(cwd, sessionId, "Temporary Chat did not expose the Pro model; retrying in normal ChatGPT.");
166
- await closeFallbackTemporaryTab(cwd, sessionId, logger);
171
+ await closeSessionBrowserTab(cwd, sessionId, logger);
167
172
  await updateAskProStatus({
168
173
  cwd,
169
174
  sessionId,
@@ -206,6 +211,16 @@ async function runAskProBrowserSessionWithLease({ cwd, sessionId, temporary, cha
206
211
  verbose,
207
212
  });
208
213
  }
214
+ if (error instanceof GitHubConnectionRequiredError) {
215
+ await writeTerminalBrowserMetadata(cwd, sessionId, "needs_github_connection", error.reason);
216
+ await updateAskProStatus({
217
+ cwd,
218
+ sessionId,
219
+ status: "NEEDS_GITHUB_CONNECTION",
220
+ reason: error.reason,
221
+ });
222
+ throw error;
223
+ }
209
224
  if (isAuthGateError(error)) {
210
225
  const currentMetadata = await readBrowserMetadata(paths.browser).catch(() => ({}));
211
226
  await writeAskProBrowserMetadata({
@@ -232,6 +247,12 @@ async function runAskProBrowserSessionWithLease({ cwd, sessionId, temporary, cha
232
247
  });
233
248
  throw new AskProNeedsAuthError(sessionId, browserProfile, classifyBrowserError(error));
234
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
+ }
235
256
  if (error instanceof AssistantStoppedError) {
236
257
  await writeTerminalBrowserMetadata(cwd, sessionId, "incomplete_answer", "stopped_without_answer");
237
258
  await updateAskProStatus({
@@ -268,13 +289,28 @@ export async function resumeAskProBrowserSession(options) {
268
289
  const { dir } = getAskProSessionPaths(options.cwd, options.sessionId);
269
290
  return withSessionControllerLease(dir, () => resumeAskProBrowserSessionWithLease(options));
270
291
  }
271
- async function resumeAskProBrowserSessionWithLease({ cwd, sessionId, temporary, verbose, }) {
292
+ async function resumeAskProBrowserSessionWithLease({ cwd, sessionId, temporary, verbose, answers, }) {
272
293
  const paths = getAskProSessionPaths(cwd, sessionId);
273
294
  const prompt = await readAskProPrompt({ cwd, sessionId });
274
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;
275
306
  const artifactsRequested = sessionStatus.artifacts === true;
276
307
  const logger = buildAskProBrowserLogger(cwd, sessionId, verbose);
277
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
+ }
278
314
  const effectiveTemporary = temporary ?? metadata.temporary;
279
315
  const chatgptUrl = effectiveTemporary === true
280
316
  ? ASK_PRO_TEMPORARY_CHATGPT_URL
@@ -282,6 +318,20 @@ async function resumeAskProBrowserSessionWithLease({ cwd, sessionId, temporary,
282
318
  ? ASK_PRO_CHATGPT_URL
283
319
  : (metadata.url ?? ASK_PRO_TEMPORARY_CHATGPT_URL);
284
320
  const fallbackProfile = await resolveResumeBrowserProfile(metadata);
321
+ if (sessionStatus.status === "NEEDS_GITHUB_CONNECTION" ||
322
+ (sessionStatus.github && metadata.runtime?.githubCheckPending === true)) {
323
+ await closeSessionBrowserTab(cwd, sessionId, logger);
324
+ await runAskProBrowserSessionWithLease({
325
+ cwd,
326
+ sessionId,
327
+ temporary: effectiveTemporary,
328
+ browserProfileDir: fallbackProfile,
329
+ agentId: metadata.agentId ?? null,
330
+ allowStartMinimized: false,
331
+ verbose,
332
+ });
333
+ return;
334
+ }
285
335
  let capturedFinalStatus = null;
286
336
  if (metadata.runtime) {
287
337
  metadata.runtime = normalizeResumeRuntime(metadata.runtime, metadata.profileDir, fallbackProfile);
@@ -350,6 +400,10 @@ async function resumeAskProBrowserSessionWithLease({ cwd, sessionId, temporary,
350
400
  thinkingTime: "pro",
351
401
  startMinimized: false,
352
402
  }, logger, {
403
+ questionAnswers,
404
+ afterQuestionsAnswered: async () => {
405
+ await fs.rm(path.join(paths.dir, "QUESTIONS.json"), { force: true });
406
+ },
353
407
  promptPreview: prompt,
354
408
  chromeModeCb: async (chromeMode) => {
355
409
  const current = await readBrowserMetadata(paths.browser).catch(() => metadata);
@@ -446,6 +500,12 @@ async function resumeAskProBrowserSessionWithLease({ cwd, sessionId, temporary,
446
500
  });
447
501
  throw new AskProNeedsAuthError(sessionId, fallbackProfile, classifyBrowserError(error));
448
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
+ }
449
509
  if (error instanceof AssistantStoppedError) {
450
510
  await writeTerminalBrowserMetadata(cwd, sessionId, "incomplete_answer", "stopped_without_answer");
451
511
  await updateAskProStatus({
@@ -467,11 +527,12 @@ async function resumeAskProBrowserSessionWithLease({ cwd, sessionId, temporary,
467
527
  throw error;
468
528
  }
469
529
  const reason = error instanceof Error ? error.message : String(error);
470
- 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);
471
532
  await updateAskProStatus({
472
533
  cwd,
473
534
  sessionId,
474
- status: "FAILED",
535
+ status: answersPending ? "AWAITING_ANSWERS" : questionAnswers ? "WAIT_TIMED_OUT" : "FAILED",
475
536
  reason,
476
537
  });
477
538
  throw error;
@@ -494,7 +555,7 @@ function withNoTemporaryResumeCommand(command) {
494
555
  }
495
556
  return withoutStrictTemporary.replace(/\s--resume(?=\s|$)/, " --no-temporary --resume");
496
557
  }
497
- async function closeFallbackTemporaryTab(cwd, sessionId, logger) {
558
+ async function closeSessionBrowserTab(cwd, sessionId, logger) {
498
559
  const paths = getAskProSessionPaths(cwd, sessionId);
499
560
  const metadata = await readBrowserMetadata(paths.browser).catch(() => null);
500
561
  const runtime = metadata?.runtime;
@@ -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
+ }
@@ -20,7 +20,7 @@ const DEFAULT_EXCLUDES = [
20
20
  ];
21
21
  const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,127}$/;
22
22
  const SESSION_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
23
- export async function createAskProSession({ cwd, question, filePatterns, dryRun, artifacts = false, }) {
23
+ export async function createAskProSession({ cwd, question, filePatterns, dryRun, artifacts = false, github = false, }) {
24
24
  const trimmedQuestion = question.trim();
25
25
  if (!trimmedQuestion) {
26
26
  throw new Error("ask-pro requires a question.");
@@ -56,8 +56,9 @@ export async function createAskProSession({ cwd, question, filePatterns, dryRun,
56
56
  harvestCommand: `ask-pro --harvest ${sessionId}`,
57
57
  dryRun,
58
58
  artifacts,
59
+ ...(github ? { github: true } : {}),
59
60
  };
60
- const submittedPrompt = renderSubmittedPrompt(question, artifacts);
61
+ const submittedPrompt = renderSubmittedPrompt(question, artifacts, github);
61
62
  const manifestMarkdown = renderManifestMarkdown(manifest);
62
63
  const browserMetadata = {
63
64
  schemaVersion: 1,
@@ -621,11 +622,11 @@ function redactSecretsForLog(message) {
621
622
  const findings = [];
622
623
  return redactSecrets(message, "log", findings);
623
624
  }
624
- function renderSubmittedPrompt(question, artifacts) {
625
+ function renderSubmittedPrompt(question, artifacts, github) {
625
626
  const artifactRequest = artifacts
626
627
  ? "\nIf file generation is available, also create a downloadable zip named ask-pro-response.zip. It should contain IMPLEMENTATION_PLAN.md, TASKS.json, TEST_PLAN.md, RISK_REGISTER.md, FILES_TO_EDIT.md, and REPO_CONTEXT_USED.md. If you cannot create a zip, return the same content in markdown sections.\n"
627
628
  : "";
628
- return `${question}
629
+ return `${question}${github ? "\n\nUse the connected GitHub app for the requested repository evidence. Keep the work read-only: do not change files, create issues or pull requests, or post comments. Cite the files and revision actually retrieved, and report missing access or revision conflicts." : ""}
629
630
 
630
631
  Read MANIFEST.md in CONTEXT.zip first. Treat the context files it lists as authoritative evidence only for the scope they cover, and call out material gaps or conflicts.
631
632
  ${artifactRequest}
@@ -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) {
@@ -0,0 +1,67 @@
1
+ import { BrowserAutomationError } from "../errors.js";
2
+ import { delay } from "../utils.js";
3
+ const GITHUB_PLUGIN_ID = "plugin_connector_1p_1a69035c238881919c4190932b2df699";
4
+ const GITHUB_APP_ID = "connector_76869538009648d5b282a4bb21c3d157";
5
+ export const GITHUB_SETUP_URL = `https://chatgpt.com/plugins/${GITHUB_PLUGIN_ID}`;
6
+ const GITHUB_SETTINGS_URL = `https://chatgpt.com/settings/plugins-settings/${GITHUB_PLUGIN_ID}/apps/${GITHUB_APP_ID}`;
7
+ export class GitHubConnectionRequiredError extends BrowserAutomationError {
8
+ reason;
9
+ constructor(reason) {
10
+ super("Connect GitHub in the opened ChatGPT page, review repository access, then resume.", {
11
+ stage: "github-connection-required",
12
+ });
13
+ this.reason = reason;
14
+ }
15
+ }
16
+ // Read only visible connection controls, never account identifiers or OAuth state.
17
+ function readGitHubConnectionState(accountSectionId) {
18
+ const visible = (element) => {
19
+ const rect = element.getBoundingClientRect();
20
+ const style = getComputedStyle(element);
21
+ return (rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden");
22
+ };
23
+ const heading = Array.from(document.querySelectorAll("h1")).some((element) => visible(element) && element.textContent?.trim() === "GitHub");
24
+ if (!heading)
25
+ return "unknown";
26
+ const section = document.getElementById(accountSectionId);
27
+ if (!section || !visible(section))
28
+ return "unknown";
29
+ const buttons = Array.from(section.querySelectorAll("button")).filter(visible);
30
+ if (buttons.some((button) => /^reconnect\b/i.test(button.textContent?.trim() ?? ""))) {
31
+ return "disconnected";
32
+ }
33
+ if (buttons.some((button) => button.getAttribute("aria-label")?.startsWith("Actions for "))) {
34
+ return "connected";
35
+ }
36
+ return buttons.some((button) => /^connect\b/i.test(button.textContent?.trim() ?? ""))
37
+ ? "disconnected"
38
+ : "unknown";
39
+ }
40
+ export async function ensureGitHubConnection(Page, Runtime, logger, timeoutMs) {
41
+ const { result: location } = await Runtime.evaluate({
42
+ expression: "location.href",
43
+ returnByValue: true,
44
+ });
45
+ const returnUrl = location.value;
46
+ await Page.navigate({ url: GITHUB_SETTINGS_URL });
47
+ const deadline = Date.now() + timeoutMs;
48
+ let state = "unknown";
49
+ do {
50
+ const result = await Runtime.evaluate({
51
+ expression: `(${readGitHubConnectionState.toString()})(${JSON.stringify(`plugin-connected-accounts-${GITHUB_APP_ID}`)})`,
52
+ returnByValue: true,
53
+ }).catch(() => undefined);
54
+ state = result?.result.value ?? "unknown";
55
+ if (state !== "unknown")
56
+ break;
57
+ if (Date.now() < deadline)
58
+ await delay(300);
59
+ } while (Date.now() < deadline);
60
+ if (state !== "connected") {
61
+ // Stop on ChatGPT before the human starts OAuth; do not observe consent or callback URLs.
62
+ await Page.navigate({ url: GITHUB_SETUP_URL });
63
+ throw new GitHubConnectionRequiredError(state === "disconnected" ? "github_connection_required" : "github_connection_unverified");
64
+ }
65
+ logger("GitHub connected. Repository access depends on the selected repositories.");
66
+ await Page.navigate({ url: returnUrl });
67
+ }
@@ -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
+ }
@@ -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"]';
@@ -10,6 +10,7 @@ import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked,
10
10
  import { INPUT_SELECTORS } from "./constants.js";
11
11
  import { uploadAttachmentViaDataTransfer } from "./actions/remoteFileTransfer.js";
12
12
  import { ensureThinkingTime } from "./actions/thinkingTime.js";
13
+ import { ensureGitHubConnection, GitHubConnectionRequiredError } from "./actions/github.js";
13
14
  import { startThinkingStatusMonitor } from "./actions/thinkingStatus.js";
14
15
  import { createAssistantContinuation } from "./actions/assistantResponse.js";
15
16
  import { createPostSubmitInputGuard } from "./actions/inputGuard.js";
@@ -18,6 +19,7 @@ import { estimateTokenCount, withRetries, delay } from "./utils.js";
18
19
  import { formatElapsed } from "./format.js";
19
20
  import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from "./constants.js";
20
21
  import { AssistantStoppedError, BrowserAutomationError } from "./errors.js";
22
+ import { AssistantQuestionsError } from "./actions/questions.js";
21
23
  import { defaultAskProBrowserProfileDir } from "./profilePaths.js";
22
24
  import { applyPageLanguageOverrides, seedChromeProfileLanguage } from "./language.js";
23
25
  import { alignPromptEchoPair, buildPromptEchoMatcher, extractConversationIdFromUrl, withTimeout, } from "./reattachHelpers.js";
@@ -278,9 +280,10 @@ export async function runBrowserMode(options) {
278
280
  logger.sessionLog = options.log.sessionLog;
279
281
  }
280
282
  const runtimeHintCb = options.runtimeHintCb;
283
+ let githubCheckPending = config.github;
281
284
  let lastTargetId;
282
285
  let lastUrl;
283
- const emitRuntimeHint = async () => {
286
+ const emitRuntimeHint = async (required = false) => {
284
287
  if (!runtimeHintCb || !chrome?.port) {
285
288
  return;
286
289
  }
@@ -294,11 +297,14 @@ export async function runBrowserMode(options) {
294
297
  conversationId,
295
298
  userDataDir,
296
299
  controllerPid: process.pid,
300
+ githubCheckPending,
297
301
  };
298
302
  try {
299
303
  await runtimeHintCb(hint);
300
304
  }
301
305
  catch (error) {
306
+ if (required)
307
+ throw error;
302
308
  const message = error instanceof Error ? error.message : String(error);
303
309
  logger(`Failed to persist runtime hint: ${message}`);
304
310
  }
@@ -517,6 +523,7 @@ export async function runBrowserMode(options) {
517
523
  });
518
524
  client = connection.client;
519
525
  isolatedTargetId = connection.targetId ?? null;
526
+ lastTargetId = connection.targetId ?? undefined;
520
527
  if (!isolatedTargetId) {
521
528
  launchTargetIds = [];
522
529
  }
@@ -702,6 +709,12 @@ export async function runBrowserMode(options) {
702
709
  }));
703
710
  if (manualLogin)
704
711
  manualLoginRecoveryInProgress = false;
712
+ if (config.github) {
713
+ await raceWithDisconnect(ensureGitHubConnection(Page, Runtime, logger, config.inputTimeoutMs));
714
+ githubCheckPending = false;
715
+ // Persist this before upload/send so a failed save cannot cause a duplicate retry.
716
+ await emitRuntimeHint(true);
717
+ }
705
718
  if (config.url !== baseUrl) {
706
719
  await raceWithDisconnect(navigateToPromptReadyWithFallback(Page, Runtime, {
707
720
  url: config.url,
@@ -1280,6 +1293,18 @@ export async function runBrowserMode(options) {
1280
1293
  }
1281
1294
  catch (error) {
1282
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
+ }
1302
+ if (normalizedError instanceof GitHubConnectionRequiredError) {
1303
+ preserveBrowserOnError = true;
1304
+ await revealAuthenticatedWindow("github-connection-required");
1305
+ await emitRuntimeHint();
1306
+ throw normalizedError;
1307
+ }
1283
1308
  if (manualLoginRecoveryInProgress && isWebSocketClosureError(normalizedError)) {
1284
1309
  const livePort = await readDevToolsPort(userDataDir);
1285
1310
  const endpoint = livePort
@@ -1358,6 +1383,9 @@ export async function runBrowserMode(options) {
1358
1383
  isolatedTargetId = recovered.ownsTarget ? recovered.targetId : null;
1359
1384
  ownsTarget = recovered.ownsTarget;
1360
1385
  connectionClosedUnexpectedly = false;
1386
+ // The restarted run owns persistence; the outer hook would save stale onboarding state.
1387
+ removeTerminationHooks?.();
1388
+ removeTerminationHooks = null;
1361
1389
  const restartedResult = await runBrowserMode(config.browserTabRef
1362
1390
  ? {
1363
1391
  ...options,
@@ -1771,9 +1799,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1771
1799
  let expectedConversationUrl;
1772
1800
  let expectedConversationId;
1773
1801
  let attachedExistingTab = false;
1802
+ let githubCheckPending = config.github;
1803
+ let githubSetupRequired = false;
1774
1804
  let ownsTarget = true;
1775
1805
  const runtimeHintCb = options.runtimeHintCb;
1776
- const emitRuntimeHint = async () => {
1806
+ const emitRuntimeHint = async (required = false) => {
1777
1807
  if (!runtimeHintCb)
1778
1808
  return;
1779
1809
  try {
@@ -1787,9 +1817,12 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1787
1817
  tabUrl: lastUrl,
1788
1818
  conversationId,
1789
1819
  controllerPid: process.pid,
1820
+ githubCheckPending,
1790
1821
  });
1791
1822
  }
1792
1823
  catch (error) {
1824
+ if (required)
1825
+ throw error;
1793
1826
  const message = error instanceof Error ? error.message : String(error);
1794
1827
  logger(`Failed to persist runtime hint: ${message}`);
1795
1828
  }
@@ -1924,6 +1957,12 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1924
1957
  await raceWithDisconnect(ensureLoggedIn(Runtime, logger, { remoteSession: true }));
1925
1958
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
1926
1959
  }
1960
+ if (config.github) {
1961
+ await raceWithDisconnect(ensureGitHubConnection(Page, Runtime, logger, config.inputTimeoutMs));
1962
+ githubCheckPending = false;
1963
+ await emitRuntimeHint(true);
1964
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
1965
+ }
1927
1966
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
1928
1967
  try {
1929
1968
  const { result } = await Runtime.evaluate({
@@ -2312,6 +2351,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2312
2351
  }
2313
2352
  catch (error) {
2314
2353
  const normalizedError = error instanceof Error ? error : new Error(String(error));
2354
+ githubSetupRequired = normalizedError instanceof GitHubConnectionRequiredError;
2315
2355
  const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError);
2316
2356
  connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
2317
2357
  if (!socketClosed) {
@@ -2344,7 +2384,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2344
2384
  // ignore
2345
2385
  }
2346
2386
  removeDialogHandler?.();
2347
- if (ownsTarget) {
2387
+ if (ownsTarget && !githubSetupRequired) {
2348
2388
  await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
2349
2389
  }
2350
2390
  // Don't kill remote Chrome - it's not ours to manage
@@ -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.53",
3
+ "version": "0.1.61",
4
4
  "description": "Browser-backed ChatGPT Pro escalation for hard engineering questions.",
5
5
  "keywords": [
6
6
  "agents",
@@ -34,10 +34,13 @@ When invoked:
34
34
 
35
35
  1. Inspect the repo and the relevant files.
36
36
  2. Identify the exact decision Pro should answer.
37
- 3. Choose a small, high-signal file bundle with `--files`.
37
+ 3. For committed code on GitHub, prefer `--github` and give Pro the repository,
38
+ ref, relevant paths, and question. Let it retrieve the code instead of assembling
39
+ a source bundle. Add a small `--files` bundle only for uncommitted changes,
40
+ local-only evidence, or repository content the connection cannot access.
38
41
  4. Write the prompt yourself using the Prompt Shape below.
39
- 5. Run the smallest useful command, usually
40
- `ask-pro --no-temporary --files "<glob>" "<prompt>"` for repo advisories.
42
+ 5. Run the smallest useful command: `ask-pro --github "<prompt>"` for GitHub
43
+ evidence, or `ask-pro --no-temporary --files "<glob>" "<prompt>"` for a file bundle.
41
44
  For multiline prompts, write a temporary prompt file and use
42
45
  `ask-pro --no-temporary --prompt-file <path> --files "<glob>"`; do not rely
43
46
  on shell multiline quoting.
@@ -88,7 +91,7 @@ again. Example:
88
91
 
89
92
  ## Prompt Shape
90
93
 
91
- Assume Pro has no caller or repository context. Include each material fact and
94
+ Assume Pro has no caller context or local checkout. Include each material fact and
92
95
  instruction once: the goal, current state, hard constraints, evidence, success
93
96
  criteria, and required output. Do not rely on the agent's conversation context, repo
94
97
  folklore, prior ask-pro runs, branch names, or unstated user preferences.
@@ -113,12 +116,62 @@ Keep bundles focused: source files under review, focused tests, relevant docs,
113
116
  known recent changes, and validation status. Avoid whole-repo bundles unless the
114
117
  question is explicitly architectural.
115
118
 
119
+ ## Connected GitHub (optional)
120
+
121
+ Prefer `--github` for remote repository evidence. It checks
122
+ the connection before uploading or submitting, and defaults to normal ChatGPT.
123
+ Do not require GitHub for file-based consults. Availability varies by account,
124
+ workspace, model, and ChatGPT experience.
125
+
126
+ If the CLI returns `needs_github_connection` / `connect_github_then_resume`,
127
+ direct the human to the opened GitHub page in ask-pro's browser. They install the
128
+ plugin if needed, complete sign-in, review permissions, and select repositories;
129
+ organization approval may be needed. Wait for them to finish, then run the
130
+ emitted resume command without adding `--github`. The requirement is saved with
131
+ the session and checked again. Do not start a second session or submit through
132
+ the browser yourself. Never collect GitHub credentials or automate authorization.
133
+
134
+ If available, GitHub's **Allow read actions** permission can avoid read approval
135
+ prompts. The human chooses that setting. It controls approval behavior, not the
136
+ underlying permissions of a write-capable app; see
137
+ [app permissions](https://help.openai.com/en/articles/20001495-managing-app-permissions-in-chatgpt).
138
+ The live GitHub plugin advertises write capabilities despite the Help Center's
139
+ read-only description. `--github` requests read-only work; it does not restrict
140
+ the provider's permissions or prove that writes are impossible.
141
+
142
+ If GitHub is already connected, proceed without a setup question or source-file
143
+ bundle. For the consult, name the repository (`owner/repo`), relevant paths, and
144
+ requested branch or commit in the prompt. Explicitly ask Pro
145
+ to answer the specific question. The wrapper requests the connected GitHub app,
146
+ read-only work, citations, the revision inspected, and access gaps. Do not
147
+ infer repository access from ChatGPT login or a successful CLI exit.
148
+
149
+ Keep `--files` for local/uncommitted changes, non-GitHub evidence, and snapshots
150
+ that the app cannot retrieve. Distinguish
151
+ uploaded evidence from GitHub evidence, and ask Pro to flag revision conflicts.
152
+ If the app or requested revision is unavailable, offer a new consult with a
153
+ focused file bundle and
154
+ disclose that limitation. Do not silently switch away from Pro. The CLI submits
155
+ text; it does not select an app in the composer or handle app approval prompts.
156
+ Until a live run demonstrates retrieval, describe this as an available setup
157
+ path, not a verified connection. Confirm material claims against the repo.
158
+
116
159
  ## Output
117
160
 
118
161
  Normal `ask-pro` stdout is compact TOON-style telemetry. Use `state`, `action`,
119
162
  `resume`, and `harvest` to decide the next command. Browser progress may appear
120
163
  on stderr and can be ignored unless diagnosing a stuck run.
121
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
+
122
175
  When present, use `profile`, `profile_path`, `chrome`, and `language` only as
123
176
  diagnostic hints. They tell you whether the run used the shared profile, an
124
177
  isolated agent profile, saved DevTools state, and English browser steering.
@@ -148,6 +201,7 @@ state is `INCOMPLETE_ANSWER` / `preamble_without_artifacts`, do not treat
148
201
  ```bash
149
202
  ask-pro "Review the async billing webhook migration plan and return an implementation plan."
150
203
  ask-pro --no-temporary --prompt-file question.md --files src --files tests
204
+ ask-pro --github "In owner/repo at <commit>, inspect src/auth and explain token validation."
151
205
  ask-pro --temporary "Review this sensitive migration plan, and fail if Temporary Chat cannot use Pro."
152
206
  ask-pro --no-temporary "Review this in normal ChatGPT instead of Temporary Chat."
153
207
  ask-pro --prompt-file question.md --files .\src