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 +60 -4
- package/dist/bin/ask-pro-cli.js +77 -4
- package/dist/src/ask-pro/browserRunner.js +67 -6
- package/dist/src/ask-pro/questions.js +40 -0
- package/dist/src/ask-pro/session.js +5 -4
- package/dist/src/browser/actions/assistantResponse.js +12 -3
- package/dist/src/browser/actions/github.js +67 -0
- package/dist/src/browser/actions/promptComposer.js +8 -2
- package/dist/src/browser/actions/questions.js +195 -0
- package/dist/src/browser/constants.js +2 -2
- package/dist/src/browser/index.js +43 -3
- package/dist/src/browser/reattach.js +24 -5
- package/package.json +1 -1
- package/skills/ask-pro/SKILL.md +58 -4
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
|
|
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.
|
|
76
|
-
|
|
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
|
package/dist/bin/ask-pro-cli.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
package/skills/ask-pro/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
|
40
|
-
`ask-pro --no-temporary --files "<glob>" "<prompt>"` for
|
|
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
|
|
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
|