ask-pro 0.2.78 → 0.2.87

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
@@ -27,8 +27,8 @@ Both installs run the same bundled launcher. The `ask-pro` npm package
27
27
  (`npm i -g ask-pro`) still works too. All three share the same state.
28
28
 
29
29
  You need Node.js 24+, npm, Chrome, and a ChatGPT account with Pro access. The
30
- browser runtime is developed and tested on Windows; other operating systems
31
- need validation.
30
+ browser runtime supports Windows and Linux. Linux without `DISPLAY` or
31
+ `WAYLAND_DISPLAY` uses headless Chrome. See [Linux setup and remote sign-in](docs/linux.md).
32
32
 
33
33
  **Upgrading from an earlier version:** nothing to do. On its first run,
34
34
  ask-pro moves your signed-in browser profiles from `~/.codex/state/ask-pro` to
package/SKILL.md CHANGED
@@ -11,6 +11,15 @@ The calling agent still owns the work. Use Pro for judgment, architecture, risk
11
11
 
12
12
  ## Running ask-pro
13
13
 
14
+ On a headless Linux host, set `CHROME_PATH` if Chrome is not discovered, and
15
+ `ASK_PRO_LOGIN_HOST` to the host's private Tailscale IP. Before the first query,
16
+ run `ask-pro --login` and give the human its `login_url`. Keep that command alive
17
+ while they sign in from their desktop or phone. The link expires after 15 minutes
18
+ or Done. Never drive the login page or capture its frames during real sign-in.
19
+ After the human confirms the ChatGPT composer is visible, resume the saved
20
+ session or start the requested query. A later `needs_auth` result includes a
21
+ `login` command for the same profile. See [Linux setup](docs/linux.md).
22
+
14
23
  This skill folder contains ask-pro itself; nothing else needs installing. In
15
24
  the commands below, `ask-pro` stands for:
16
25
 
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import "dotenv/config";
3
+ import { runRemoteLogin } from "../src/ask-pro/remoteLogin.js";
3
4
  import { AssistantStoppedError } from "../src/browser/errors.js";
4
5
  import { AssistantQuestionsError } from "../src/browser/actions/questions.js";
5
6
  import { readAskProQuestions } from "../src/ask-pro/questions.js";
@@ -8,7 +9,7 @@ import fs from "node:fs/promises";
8
9
  import path from "node:path";
9
10
  import { Command, Option } from "commander";
10
11
  import { createAskProSession, getAskProSessionPaths, pruneExpiredAskProSessions, readAskProAnswer, readAskProStatus, updateAskProResumeCommand, updateAskProStatus, } from "../src/ask-pro/session.js";
11
- import { AskProNeedsAuthError, isSuspiciousPreambleAnswer, resumeAskProBrowserSession, runAskProBrowserSession, } from "../src/ask-pro/browserRunner.js";
12
+ import { AskProNeedsAuthError, resolveAskProLoginProfile, isSuspiciousPreambleAnswer, resumeAskProBrowserSession, runAskProBrowserSession, } from "../src/ask-pro/browserRunner.js";
12
13
  import { renderToonRecord } from "../src/ask-pro/toon.js";
13
14
  import { askProAgentIdForLegacyBrowserProfileDir, askProAgentIdForManagedBrowserProfileDir, defaultAskProBrowserProfileDir, isLegacyAskProManagedBrowserProfileDir, } from "../src/browser/profilePaths.js";
14
15
  import { migrateLegacyAskProProfiles } from "../src/browser/profileMigration.js";
@@ -20,6 +21,20 @@ program
20
21
  .version(getCliVersion())
21
22
  .argument("[question...]", "question to send to ChatGPT Pro")
22
23
  .option("--dry-run", "prepare the session and context bundle without opening the browser")
24
+ .addOption(new Option("--login [session-id]", "open a private remote sign-in link for headless Chrome").conflicts([
25
+ "dryRun",
26
+ "files",
27
+ "promptFile",
28
+ "answers",
29
+ "artifacts",
30
+ "github",
31
+ "responseZip",
32
+ "resume",
33
+ "status",
34
+ "harvest",
35
+ "copy",
36
+ "temporary",
37
+ ]))
23
38
  .option("--files <pattern>", "include files or globs in the context bundle", collectFiles, [])
24
39
  .option("--prompt-file <path>", "read the question from a UTF-8 file; use - for stdin")
25
40
  .option("--artifacts", "ask Pro for ask-pro-response.zip plus markdown fallback")
@@ -58,6 +73,21 @@ program
58
73
  await program.parseAsync(process.argv);
59
74
  async function runAskPro(question, options) {
60
75
  const cwd = resolveProjectCwd(options);
76
+ if (options.login) {
77
+ if (question.trim())
78
+ throw new Error("Use --login without a question.");
79
+ const profile = typeof options.login === "string"
80
+ ? await resolveAskProLoginProfile(cwd, options.login)
81
+ : undefined;
82
+ await runRemoteLogin((url) => writeToon("ask_pro", {
83
+ state: "needs_auth",
84
+ action: "open_login_url",
85
+ login_url: url,
86
+ expires_minutes: 15,
87
+ }), profile);
88
+ writeToon("ask_pro", { state: "login_closed", action: "confirm_composer_then_resume" });
89
+ return;
90
+ }
61
91
  if (options.answers && question.trim())
62
92
  throw new Error("Use --answers with --resume, without a question argument.");
63
93
  await pruneExpiredAskProSessions({ cwd });
@@ -347,6 +377,11 @@ async function printAuthInstructions(sessionId, options, cwd, error) {
347
377
  ...fallbackPreflight,
348
378
  ...browserPreflight,
349
379
  action: "human_login_then_resume",
380
+ ...(process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY
381
+ ? {
382
+ login: `${buildLauncherCommand()} --cwd ${quoteCommandArg(cwd)} --login ${quoteCommandArg(sessionId)}`,
383
+ }
384
+ : {}),
350
385
  resume: resumeCommand,
351
386
  });
352
387
  }
@@ -717,6 +717,10 @@ async function writeTerminalBrowserMetadata(cwd, sessionId, status, reason) {
717
717
  },
718
718
  });
719
719
  }
720
+ export async function resolveAskProLoginProfile(cwd, sessionId) {
721
+ const paths = getAskProSessionPaths(cwd, sessionId);
722
+ return resolveResumeBrowserProfile(await readBrowserMetadata(paths.browser));
723
+ }
720
724
  async function resolveResumeBrowserProfile(metadata) {
721
725
  const agentProfile = resolveStoredAgentProfile(metadata.agentId);
722
726
  const profileDir = metadata.profileDir;
@@ -0,0 +1,29 @@
1
+ // The browser relays human input only. Never persist input, frames, or the access key.
2
+ export const loginPage = `<!doctype html>
3
+ <html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
4
+ <title>ask-pro sign in</title>
5
+ <style>
6
+ :root{font:16px/1.5 system-ui;color:#182421;background:#f5f7f6}body{margin:0}main{max-width:1280px;margin:auto;padding:20px}header{display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-bottom:16px}h1{font-size:22px;margin:0 auto 0 0}button,input,select{font:inherit;padding:10px;border:1px solid #62716b;border-radius:6px;background:white;color:inherit}button{cursor:pointer}button:hover{background:#e0ebe5}:focus-visible{outline:3px solid #197451;outline-offset:2px}#screen{display:block;width:100%;background:white;touch-action:manipulation}form{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px}input{flex:1;min-width:150px}#status{min-height:24px;margin:8px 0;color:#344b41}button:disabled{opacity:.6;cursor:wait}
7
+ </style><main><header><h1>Sign in to ChatGPT</h1><select id="tabs" aria-label="Browser tab"></select><button id="refresh">Refresh tabs</button><button id="done">Done</button></header>
8
+ <p id="status" role="status">Connecting…</p><img id="screen" tabindex="0" alt="Remote browser. Click a field, then type, or use the text box below.">
9
+ <form><input id="text" type="password" autocomplete="off" aria-label="Text for selected browser field" placeholder="Type into selected field"><button>Send text</button><button type="button" id="enter">Enter</button><button type="button" id="tab">Tab</button><button type="button" id="backspace">Backspace</button><button type="button" id="up">Scroll up</button><button type="button" id="down">Scroll down</button></form></main>
10
+ <script>
11
+ const token=location.hash.slice(1);history.replaceState(null,'',location.pathname);
12
+ const screen=document.querySelector('#screen'),tabs=document.querySelector('#tabs'),status=document.querySelector('#status'),text=document.querySelector('#text');
13
+ let stopped=false,frameUrl,inputQueue=Promise.resolve();
14
+ async function api(route,body){const r=await fetch(route,{method:body===undefined?'GET':'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body),cache:'no-store'});if(!r.ok)throw Error(r.status===401?'Link expired. Start ask-pro --login again.':'Connection failed. Retry or refresh tabs.');return r;}
15
+ function report(e){status.textContent=e.message;}
16
+ function input(body){const target=tabs.value;const request=inputQueue.then(()=>api('/input',{target,...body}));inputQueue=request.catch(report);return request;}
17
+ async function refresh(){try{const list=await(await api('/tabs')).json();const previous=tabs.value;tabs.replaceChildren(...list.map(t=>new Option(t.name,t.id)));if(list.some(t=>t.id===previous))tabs.value=previous;status.textContent='Click the browser to sign in. Select Done when the ChatGPT composer is visible.';}catch(e){report(e);}}
18
+ async function frame(){if(stopped)return;try{if(tabs.value){const r=await api('/frame?target='+encodeURIComponent(tabs.value));const url=URL.createObjectURL(await r.blob());screen.src=url;if(frameUrl)URL.revokeObjectURL(frameUrl);frameUrl=url;}}catch(e){report(e);}setTimeout(frame,350);}
19
+ screen.onclick=e=>{const r=screen.getBoundingClientRect();input({kind:'click',x:(e.clientX-r.left)*screen.naturalWidth/r.width,y:(e.clientY-r.top)*screen.naturalHeight/r.height});screen.focus();};
20
+ screen.onkeydown=e=>{if(e.ctrlKey||e.metaKey||e.altKey)return;if(e.key.length===1){e.preventDefault();input({kind:'text',text:e.key});}else if(['Enter','Tab','Backspace','Delete','ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Escape','Home','End'].includes(e.key)){e.preventDefault();input({kind:'key',key:e.key});}};
21
+ screen.onwheel=e=>{e.preventDefault();const r=screen.getBoundingClientRect();input({kind:'scroll',x:(e.clientX-r.left)*screen.naturalWidth/r.width,y:(e.clientY-r.top)*screen.naturalHeight/r.height,deltaY:e.deltaY});};
22
+ document.querySelector('form').onsubmit=async e=>{e.preventDefault();const value=text.value;try{await input({kind:'text',text:value});if(text.value===value)text.value='';}catch(e){report(e);}};
23
+ for(const key of ['Enter','Tab','Backspace'])document.querySelector('#'+key.toLowerCase()).onclick=()=>input({kind:'key',key});
24
+ document.querySelector('#up').onclick=()=>input({kind:'scroll',x:100,y:100,deltaY:-400});
25
+ document.querySelector('#down').onclick=()=>input({kind:'scroll',x:100,y:100,deltaY:400});
26
+ document.querySelector('#refresh').onclick=refresh;
27
+ document.querySelector('#done').onclick=async()=>{try{await api('/done',{});stopped=true;screen.removeAttribute('src');text.value='';status.textContent='Sign-in window closed. You can return to your agent.';}catch(e){report(e);}};
28
+ refresh().then(frame);
29
+ </script></html>`;
@@ -0,0 +1,254 @@
1
+ import http from "node:http";
2
+ import { randomBytes } from "node:crypto";
3
+ import os from "node:os";
4
+ import CDP from "chrome-remote-interface";
5
+ import { resolveBrowserConfig } from "../browser/config.js";
6
+ import { prepareAskProBrowserProfile } from "../browser/profileMigration.js";
7
+ import { resolveAskProAgentId } from "../browser/profilePaths.js";
8
+ import { acquireProfileRunLock, createManagedChromeRunLease, releaseManagedChromeRunLeaseAndCountPeers, writeChromePid, writeDevToolsActivePort, } from "../browser/profileState.js";
9
+ import { launchChrome, maybeReuseRunningChrome, releaseChromeProcessHandle, } from "../browser/chromeLifecycle.js";
10
+ import { loginPage } from "./loginPage.js";
11
+ const keys = new Set([
12
+ "Enter",
13
+ "Tab",
14
+ "Backspace",
15
+ "Delete",
16
+ "ArrowLeft",
17
+ "ArrowRight",
18
+ "ArrowUp",
19
+ "ArrowDown",
20
+ "Escape",
21
+ "Home",
22
+ "End",
23
+ ]);
24
+ /** Narrow human-operated relay: no cookies, DOM, evaluation, or arbitrary CDP endpoint. */
25
+ export async function startLoginServer(options) {
26
+ const token = randomBytes(32).toString("hex");
27
+ let client;
28
+ let targetId;
29
+ let queue = Promise.resolve();
30
+ let finish;
31
+ const done = new Promise((resolve) => {
32
+ finish = resolve;
33
+ });
34
+ const targets = async () => (await CDP.List({ port: options.chromePort })).filter((t) => t.type === "page" && /^(https?:|about:blank)/.test(t.url));
35
+ async function connect(id) {
36
+ if (!(await targets()).some((t) => t.id === id))
37
+ throw Error("Unavailable tab");
38
+ if (targetId !== id || !client) {
39
+ await client?.close().catch(() => undefined);
40
+ client = await CDP({ port: options.chromePort, target: id });
41
+ targetId = id;
42
+ client.on("disconnect", () => {
43
+ if (targetId === id) {
44
+ client = undefined;
45
+ targetId = undefined;
46
+ }
47
+ });
48
+ }
49
+ return client;
50
+ }
51
+ const server = http.createServer((req, res) => {
52
+ res.setHeader("Cache-Control", "no-store");
53
+ res.setHeader("Referrer-Policy", "no-referrer");
54
+ res.setHeader("X-Frame-Options", "DENY");
55
+ res.setHeader("X-Content-Type-Options", "nosniff");
56
+ res.setHeader("Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src blob:; connect-src 'self'; frame-ancestors 'none'; form-action 'none'");
57
+ if (req.method === "GET" && req.url === "/") {
58
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
59
+ res.end(loginPage);
60
+ return;
61
+ }
62
+ if (req.headers.authorization !== "Bearer " + token) {
63
+ res.writeHead(401).end();
64
+ return;
65
+ }
66
+ // Reject cross-origin browser requests even when a caller has the link.
67
+ if (req.headers.origin && req.headers.origin !== "http://" + req.headers.host) {
68
+ res.writeHead(403).end();
69
+ return;
70
+ }
71
+ const route = new URL(req.url ?? "/", "http://localhost");
72
+ const run = async () => {
73
+ if (req.method === "GET" && route.pathname === "/tabs") {
74
+ res.setHeader("Content-Type", "application/json");
75
+ res.end(JSON.stringify((await targets()).map((t) => ({
76
+ id: t.id,
77
+ name: t.url === "about:blank" ? "Blank tab" : new URL(t.url).hostname,
78
+ }))));
79
+ return;
80
+ }
81
+ if (req.method === "GET" && route.pathname === "/frame") {
82
+ const c = await connect(route.searchParams.get("target") ?? "");
83
+ const frame = await c.Page.captureScreenshot({ format: "jpeg", quality: 70 });
84
+ res.setHeader("Content-Type", "image/jpeg");
85
+ res.end(Buffer.from(frame.data, "base64"));
86
+ return;
87
+ }
88
+ if (req.method !== "POST" || !["/input", "/done"].includes(route.pathname)) {
89
+ res.writeHead(404).end();
90
+ return;
91
+ }
92
+ let raw = "";
93
+ for await (const chunk of req) {
94
+ raw += chunk;
95
+ if (Buffer.byteLength(raw) > 16384) {
96
+ res.writeHead(413).end();
97
+ return;
98
+ }
99
+ }
100
+ if (route.pathname === "/done") {
101
+ res.end();
102
+ setImmediate(() => {
103
+ void close();
104
+ });
105
+ return;
106
+ }
107
+ const body = JSON.parse(raw);
108
+ const c = await connect(typeof body.target === "string" ? body.target : "");
109
+ await c.Page.bringToFront();
110
+ const coordinate = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
111
+ if (body.kind === "text" && typeof body.text === "string")
112
+ await c.Input.insertText({ text: body.text });
113
+ else if (body.kind === "key" && typeof body.key === "string" && keys.has(body.key)) {
114
+ const key = body.key;
115
+ const code = {
116
+ Enter: 13,
117
+ Tab: 9,
118
+ Backspace: 8,
119
+ Delete: 46,
120
+ Escape: 27,
121
+ ArrowLeft: 37,
122
+ ArrowUp: 38,
123
+ ArrowRight: 39,
124
+ ArrowDown: 40,
125
+ Home: 36,
126
+ End: 35,
127
+ }[key];
128
+ await c.Input.dispatchKeyEvent({
129
+ type: "keyDown",
130
+ key,
131
+ code: key,
132
+ windowsVirtualKeyCode: code,
133
+ ...(key === "Enter" ? { text: "\r" } : {}),
134
+ });
135
+ await c.Input.dispatchKeyEvent({
136
+ type: "keyUp",
137
+ key,
138
+ code: key,
139
+ windowsVirtualKeyCode: code,
140
+ });
141
+ }
142
+ else if ((body.kind === "click" || body.kind === "scroll") &&
143
+ coordinate(body.x) &&
144
+ coordinate(body.y)) {
145
+ const x = body.x, y = body.y;
146
+ if (body.kind === "click") {
147
+ await c.Input.dispatchMouseEvent({
148
+ type: "mousePressed",
149
+ x,
150
+ y,
151
+ button: "left",
152
+ clickCount: 1,
153
+ });
154
+ await c.Input.dispatchMouseEvent({
155
+ type: "mouseReleased",
156
+ x,
157
+ y,
158
+ button: "left",
159
+ clickCount: 1,
160
+ });
161
+ }
162
+ else if (typeof body.deltaY === "number" && Number.isFinite(body.deltaY))
163
+ await c.Input.dispatchMouseEvent({
164
+ type: "mouseWheel",
165
+ x,
166
+ y,
167
+ deltaX: 0,
168
+ deltaY: body.deltaY,
169
+ });
170
+ else {
171
+ res.writeHead(400).end();
172
+ return;
173
+ }
174
+ }
175
+ else {
176
+ res.writeHead(400).end();
177
+ return;
178
+ }
179
+ res.end();
180
+ };
181
+ queue = queue.then(run).catch(() => {
182
+ if (!res.headersSent)
183
+ res.writeHead(400);
184
+ res.end();
185
+ });
186
+ });
187
+ let closed = false;
188
+ async function close() {
189
+ if (closed)
190
+ return;
191
+ closed = true;
192
+ clearTimeout(timer);
193
+ server.close();
194
+ server.closeAllConnections();
195
+ await client?.close().catch(() => undefined);
196
+ finish();
197
+ }
198
+ await new Promise((resolve, reject) => {
199
+ server.once("error", reject);
200
+ server.listen(options.port ?? 0, options.host, resolve);
201
+ });
202
+ const timer = setTimeout(() => {
203
+ void close();
204
+ }, options.ttlMs ?? 15 * 60_000);
205
+ const address = server.address();
206
+ const hostname = options.host === "0.0.0.0" ? os.hostname() : options.host;
207
+ return { url: "http://" + hostname + ":" + address.port + "/#" + token, done, close };
208
+ }
209
+ export async function runRemoteLogin(onReady, savedProfile) {
210
+ const host = process.env.ASK_PRO_LOGIN_HOST?.trim();
211
+ if (!host)
212
+ throw Error("Set ASK_PRO_LOGIN_HOST to this machine’s private network IP (for example its Tailscale IP), then run --login.");
213
+ const profile = savedProfile ?? (await prepareAskProBrowserProfile(resolveAskProAgentId()));
214
+ const lock = await acquireProfileRunLock(profile, { timeoutMs: 1000 });
215
+ if (!lock)
216
+ throw Error("Browser busy. Wait for ask-pro runs to finish before --login.");
217
+ const lease = await createManagedChromeRunLease(profile);
218
+ let relay;
219
+ try {
220
+ // Count live automation leases while holding the launch lock; login never races a query.
221
+ const peers = await releaseManagedChromeRunLeaseAndCountPeers(profile, lease);
222
+ if (peers !== 0)
223
+ throw Error("Browser busy. Wait for ask-pro runs to finish before --login.");
224
+ const chrome = (await maybeReuseRunningChrome(profile, () => { })) ??
225
+ (await launchChrome(resolveBrowserConfig({ manualLogin: true, headless: true }), profile, () => { }, { ignoreLockId: lock.lockId }));
226
+ releaseChromeProcessHandle(chrome);
227
+ if (chrome.pid)
228
+ await writeChromePid(profile, chrome.pid);
229
+ await writeDevToolsActivePort(profile, chrome.port);
230
+ const pages = await CDP.List({ port: chrome.port });
231
+ if (!pages.some((t) => t.type === "page" && /^https:\/\/(chatgpt\.com|auth\.openai\.com)(\/|$)/.test(t.url))) {
232
+ await CDP.New({ port: chrome.port, url: "https://chatgpt.com/" });
233
+ }
234
+ relay = await startLoginServer({ chromePort: chrome.port, host });
235
+ const stop = () => {
236
+ void relay?.close();
237
+ };
238
+ process.once("SIGINT", stop);
239
+ process.once("SIGTERM", stop);
240
+ try {
241
+ onReady(relay.url);
242
+ await relay.done;
243
+ }
244
+ finally {
245
+ process.removeListener("SIGINT", stop);
246
+ process.removeListener("SIGTERM", stop);
247
+ }
248
+ }
249
+ finally {
250
+ await relay?.close();
251
+ await releaseManagedChromeRunLeaseAndCountPeers(profile, lease);
252
+ await lock.release();
253
+ }
254
+ }
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { promisify } from "node:util";
9
9
  import CDP from "chrome-remote-interface";
10
10
  import { Launcher } from "chrome-launcher";
11
- import { clearChromeLaunchStarting, cleanupStaleProfileState, hasChromeLaunchStarting, markChromeLaunchStarting, readChromePid, readDevToolsPort, verifyDevToolsReachable, } from "./profileState.js";
11
+ import { clearChromeLaunchStarting, clearIdleBrowserProfileCaches, cleanupStaleProfileState, hasChromeLaunchStarting, markChromeLaunchStarting, readChromePid, readDevToolsPort, verifyDevToolsReachable, } from "./profileState.js";
12
12
  import { delay } from "./utils.js";
13
13
  import { formatElapsed } from "./format.js";
14
14
  import { withTimeout } from "./reattachHelpers.js";
@@ -20,13 +20,14 @@ const CPU_THROTTLING_OVERRIDE_FLAGS = new Set([
20
20
  "--disable-ipc-flooding-protection",
21
21
  ]);
22
22
  const CHROME_STARTUP_HANDOFF_MS = 26_000;
23
- export async function launchChrome(config, userDataDir, logger) {
23
+ export async function launchChrome(config, userDataDir, logger, options = {}) {
24
24
  const connectHost = resolveRemoteDebugHost();
25
25
  const debugBindAddress = connectHost && connectHost !== "127.0.0.1" ? "0.0.0.0" : connectHost;
26
26
  const debugPort = config.debugPort ?? parseDebugPortEnv();
27
27
  const chromeFlags = buildChromeLaunchFlags(buildChromeFlags(config.headless ?? false, debugBindAddress, config.acceptLanguage, {
28
28
  startMinimized: shouldLaunchChromeMinimized(config),
29
29
  }));
30
+ await clearIdleBrowserProfileCaches(userDataDir, options);
30
31
  await markChromeLaunchStarting(userDataDir);
31
32
  try {
32
33
  const launcher = await launchManagedChrome({
@@ -581,6 +582,7 @@ export function buildChromeFlags(headless, debugBindAddress, acceptLanguage = "e
581
582
  const primaryLanguage = acceptLanguage.split(",", 1)[0]?.trim() || "en-US";
582
583
  const flags = [
583
584
  "--disable-background-networking",
585
+ "--disk-cache-size=16777216",
584
586
  "--disable-breakpad",
585
587
  "--disable-client-side-phishing-detection",
586
588
  "--disable-default-apps",
@@ -2,6 +2,9 @@ import { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./con
2
2
  import { normalizeBrowserModelStrategy } from "./modelStrategy.js";
3
3
  import { defaultAskProBrowserProfileDir } from "./profilePaths.js";
4
4
  import { normalizeChatgptUrl } from "./utils.js";
5
+ export function isHeadlessLinux(platform = process.platform, env = process.env) {
6
+ return platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY;
7
+ }
5
8
  export const DEFAULT_BROWSER_CONFIG = {
6
9
  chromeProfile: null,
7
10
  chromePath: null,
@@ -76,14 +79,14 @@ export function resolveBrowserConfig(config) {
76
79
  cookieSyncWaitMs: config?.cookieSyncWaitMs ?? DEFAULT_BROWSER_CONFIG.cookieSyncWaitMs,
77
80
  inlineCookies: config?.inlineCookies ?? DEFAULT_BROWSER_CONFIG.inlineCookies,
78
81
  inlineCookiesSource: config?.inlineCookiesSource ?? DEFAULT_BROWSER_CONFIG.inlineCookiesSource,
79
- headless: config?.headless ?? DEFAULT_BROWSER_CONFIG.headless,
82
+ headless: config?.headless ?? isHeadlessLinux(),
80
83
  keepBrowser: config?.keepBrowser ?? DEFAULT_BROWSER_CONFIG.keepBrowser,
81
84
  hideWindow: config?.hideWindow ?? DEFAULT_BROWSER_CONFIG.hideWindow,
82
85
  startMinimized: config?.startMinimized ?? DEFAULT_BROWSER_CONFIG.startMinimized,
83
86
  desiredModel,
84
87
  modelStrategy,
85
88
  chromeProfile: config?.chromeProfile ?? DEFAULT_BROWSER_CONFIG.chromeProfile,
86
- chromePath: config?.chromePath ?? DEFAULT_BROWSER_CONFIG.chromePath,
89
+ chromePath: config?.chromePath ?? process.env.CHROME_PATH ?? DEFAULT_BROWSER_CONFIG.chromePath,
87
90
  chromeCookiePath: config?.chromeCookiePath ?? DEFAULT_BROWSER_CONFIG.chromeCookiePath,
88
91
  attachRunning: config?.attachRunning ?? DEFAULT_BROWSER_CONFIG.attachRunning,
89
92
  browserTabRef: config?.browserTabRef ?? DEFAULT_BROWSER_CONFIG.browserTabRef,
@@ -394,7 +394,7 @@ export async function runBrowserMode(options) {
394
394
  (await launchChrome({
395
395
  ...config,
396
396
  remoteChrome: config.remoteChrome,
397
- }, userDataDir, logger));
397
+ }, userDataDir, logger, { ignoreLockId: profileLock?.lockId }));
398
398
  // Persist profile state so future manual-login runs can reuse this Chrome.
399
399
  if (manualLogin && chrome.port) {
400
400
  await writeDevToolsActivePort(userDataDir, chrome.port);
@@ -182,6 +182,16 @@ export async function isBrowserProfileInUse(userDataDir, options = {}) {
182
182
  const lock = parseProfileRunLock(raw);
183
183
  if (lock && lock.lockId !== options.ignoreLockId && isProcessAlive(lock.pid))
184
184
  return true;
185
+ try {
186
+ const leases = await readdir(path.join(userDataDir, ASK_PRO_RUN_LEASE_DIRNAME));
187
+ if (leases.some((name) => name.endsWith(".lease") && isProcessAlive(Number(name.match(/^(\d+)-/)?.[1])))) {
188
+ return true;
189
+ }
190
+ }
191
+ catch (error) {
192
+ if (error.code !== "ENOENT")
193
+ return true;
194
+ }
185
195
  const pid = await readChromePid(userDataDir);
186
196
  const port = await readDevToolsPort(userDataDir);
187
197
  const [owner, devTools] = await Promise.all([
@@ -192,6 +202,19 @@ export async function isBrowserProfileInUse(userDataDir, options = {}) {
192
202
  return true;
193
203
  return owner === null;
194
204
  }
205
+ // Call while holding the profile's launch lock; only disposable caches are removed.
206
+ export async function clearIdleBrowserProfileCaches(userDataDir, options = {}) {
207
+ try {
208
+ if (await isBrowserProfileInUse(userDataDir, options))
209
+ return;
210
+ for (const name of ["Cache", "Code Cache"]) {
211
+ await rm(path.join(userDataDir, "Default", name), { recursive: true, force: true }).catch(() => undefined);
212
+ }
213
+ }
214
+ catch {
215
+ // Cache maintenance must never prevent a browser run.
216
+ }
217
+ }
195
218
  export async function acquireProfileRunLock(userDataDir, options) {
196
219
  const timeoutMs = options.timeoutMs;
197
220
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
@@ -355,7 +355,9 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
355
355
  if (!reusedChrome) {
356
356
  await seedChromeProfileLanguage(userDataDir, resolved.acceptLanguage, logger);
357
357
  }
358
- chrome = reusedChrome ?? (await launchChrome(resolved, userDataDir, logger));
358
+ chrome =
359
+ reusedChrome ??
360
+ (await launchChrome(resolved, userDataDir, logger, { ignoreLockId: launchLock?.lockId }));
359
361
  if (manualLogin) {
360
362
  reattachRunLease = {
361
363
  userDataDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ask-pro",
3
- "version": "0.2.78",
3
+ "version": "0.2.87",
4
4
  "description": "Browser-backed ChatGPT Pro escalation for hard engineering questions.",
5
5
  "keywords": [
6
6
  "agents",