ask-pro 0.2.87 → 0.2.93

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/SKILL.md CHANGED
@@ -11,7 +11,9 @@ 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
14
+ On a display-less Linux host, install Xvfb (`sudo apt install xvfb`); ask-pro
15
+ manages a private display for headed Chrome during login and queries. Set
16
+ `CHROME_PATH` if Chrome is not discovered, and
15
17
  `ASK_PRO_LOGIN_HOST` to the host's private Tailscale IP. Before the first query,
16
18
  run `ask-pro --login` and give the human its `login_url`. Keep that command alive
17
19
  while they sign in from their desktop or phone. The link expires after 15 minutes
@@ -1,4 +1,5 @@
1
- // The browser relays human input only. Never persist input, frames, or the access key.
1
+ // The browser relays human input only. Never persist input or frames.
2
+ // Keep the short-lived access key in the URL fragment so reload preserves access.
2
3
  export const loginPage = `<!doctype html>
3
4
  <html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
4
5
  <title>ask-pro sign in</title>
@@ -8,7 +9,7 @@ export const loginPage = `<!doctype html>
8
9
  <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
10
  <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
11
  <script>
11
- const token=location.hash.slice(1);history.replaceState(null,'',location.pathname);
12
+ const token=location.hash.slice(1);
12
13
  const screen=document.querySelector('#screen'),tabs=document.querySelector('#tabs'),status=document.querySelector('#status'),text=document.querySelector('#text');
13
14
  let stopped=false,frameUrl,inputQueue=Promise.resolve();
14
15
  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;}
@@ -80,7 +80,29 @@ export async function startLoginServer(options) {
80
80
  }
81
81
  if (req.method === "GET" && route.pathname === "/frame") {
82
82
  const c = await connect(route.searchParams.get("target") ?? "");
83
- const frame = await c.Page.captureScreenshot({ format: "jpeg", quality: 70 });
83
+ // Reload cancels the image request. Release its pending CDP capture so
84
+ // it cannot hold the shared request queue and freeze the new viewer.
85
+ const cancel = () => {
86
+ if (!res.writableEnded) {
87
+ if (client === c) {
88
+ client = undefined;
89
+ targetId = undefined;
90
+ }
91
+ void c.close().catch(() => undefined);
92
+ }
93
+ };
94
+ if (res.destroyed) {
95
+ cancel();
96
+ return;
97
+ }
98
+ res.once("close", cancel);
99
+ let frame;
100
+ try {
101
+ frame = await c.Page.captureScreenshot({ format: "jpeg", quality: 70 });
102
+ }
103
+ finally {
104
+ res.removeListener("close", cancel);
105
+ }
84
106
  res.setHeader("Content-Type", "image/jpeg");
85
107
  res.end(Buffer.from(frame.data, "base64"));
86
108
  return;
@@ -222,7 +244,9 @@ export async function runRemoteLogin(onReady, savedProfile) {
222
244
  if (peers !== 0)
223
245
  throw Error("Browser busy. Wait for ask-pro runs to finish before --login.");
224
246
  const chrome = (await maybeReuseRunningChrome(profile, () => { })) ??
225
- (await launchChrome(resolveBrowserConfig({ manualLogin: true, headless: true }), profile, () => { }, { ignoreLockId: lock.lockId }));
247
+ (await launchChrome(resolveBrowserConfig({ manualLogin: true }), profile, () => { }, {
248
+ ignoreLockId: lock.lockId,
249
+ }));
226
250
  releaseChromeProcessHandle(chrome);
227
251
  if (chrome.pid)
228
252
  await writeChromePid(profile, chrome.pid);
@@ -12,6 +12,8 @@ import { clearChromeLaunchStarting, clearIdleBrowserProfileCaches, cleanupStaleP
12
12
  import { delay } from "./utils.js";
13
13
  import { formatElapsed } from "./format.js";
14
14
  import { withTimeout } from "./reattachHelpers.js";
15
+ import { isHeadlessLinux } from "./config.js";
16
+ import { spawnChromeWithVirtualDisplay } from "./linuxChrome.js";
15
17
  const execFileAsync = promisify(execFile);
16
18
  const CPU_THROTTLING_OVERRIDE_FLAGS = new Set([
17
19
  "--disable-backgrounding-occluded-windows",
@@ -747,7 +749,9 @@ export function spawnWindowsProcessOutsideJob(command, args, options) {
747
749
  }
748
750
  const spawnManagedChrome = ((command, args, options) => process.platform === "win32"
749
751
  ? spawnWindowsProcessOutsideJob(command, args, options)
750
- : spawn(command, args, options));
752
+ : isHeadlessLinux() && !args.some((arg) => arg.startsWith("--headless"))
753
+ ? spawnChromeWithVirtualDisplay(command, args, options)
754
+ : spawn(command, args, options));
751
755
  async function launchManagedChrome({ chromeFlags, chromePath, userDataDir, host, requestedPort, }) {
752
756
  const launcher = new Launcher({
753
757
  chromePath: chromePath ?? undefined,
@@ -79,7 +79,7 @@ export function resolveBrowserConfig(config) {
79
79
  cookieSyncWaitMs: config?.cookieSyncWaitMs ?? DEFAULT_BROWSER_CONFIG.cookieSyncWaitMs,
80
80
  inlineCookies: config?.inlineCookies ?? DEFAULT_BROWSER_CONFIG.inlineCookies,
81
81
  inlineCookiesSource: config?.inlineCookiesSource ?? DEFAULT_BROWSER_CONFIG.inlineCookiesSource,
82
- headless: config?.headless ?? isHeadlessLinux(),
82
+ headless: config?.headless ?? false,
83
83
  keepBrowser: config?.keepBrowser ?? DEFAULT_BROWSER_CONFIG.keepBrowser,
84
84
  hideWindow: config?.hideWindow ?? DEFAULT_BROWSER_CONFIG.hideWindow,
85
85
  startMinimized: config?.startMinimized ?? DEFAULT_BROWSER_CONFIG.startMinimized,
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import os from "node:os";
4
4
  import net from "node:net";
5
5
  import CDP from "chrome-remote-interface";
6
- import { DEFAULT_BROWSER_CONFIG, resolveBrowserConfig } from "./config.js";
6
+ import { DEFAULT_BROWSER_CONFIG, resolveBrowserConfig, isHeadlessLinux } from "./config.js";
7
7
  import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome, connectWithNewTab, closeTab, closeRemoteChromeTarget, closeChromeGracefully, listRemoteChromeTargets, maybeReuseRunningChrome, releaseChromeProcessHandle, restoreChromeWindowByPid, shouldLaunchChromeMinimized, } from "./chromeLifecycle.js";
8
8
  import { syncCookies } from "./cookies.js";
9
9
  import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
@@ -1346,7 +1346,7 @@ export async function runBrowserMode(options) {
1346
1346
  ? "ChatGPT login required; leaving browser open so you can sign in."
1347
1347
  : "Cloudflare challenge detected; leaving browser open so you can complete the check.");
1348
1348
  logger(`Reuse this browser profile with: ${reuseProfileHint}`);
1349
- if (isLoginRequired && manualLogin) {
1349
+ if (isLoginRequired && manualLogin && !isHeadlessLinux()) {
1350
1350
  const recoveryRuntime = client?.Runtime;
1351
1351
  if (!recoveryRuntime) {
1352
1352
  throw normalizedError;
@@ -0,0 +1,82 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { randomBytes } from "node:crypto";
4
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ // A separate supervisor outlives the controller when a browser is kept for resume.
8
+ // Its PID represents the managed browser lifetime, including its private display.
9
+ export function spawnChromeWithVirtualDisplay(command, args, options) {
10
+ const check = spawnSync("Xvfb", ["-help"], { stdio: "ignore" });
11
+ if (check.error)
12
+ throw Error("Xvfb is required on Linux without a display. Run: sudo apt install xvfb");
13
+ return spawn(process.execPath, [fileURLToPath(import.meta.url), "--supervise", command, ...args], options);
14
+ }
15
+ async function supervise() {
16
+ const directory = await mkdtemp(path.join(os.tmpdir(), "ask-pro-display-"));
17
+ const authority = path.join(directory, "Xauthority");
18
+ const cookie = randomBytes(16);
19
+ const writeAuthority = async (number) => {
20
+ const fields = [
21
+ Buffer.alloc(0),
22
+ Buffer.from(number),
23
+ Buffer.from("MIT-MAGIC-COOKIE-1"),
24
+ cookie,
25
+ ];
26
+ const records = fields.map((field) => {
27
+ const length = Buffer.alloc(2);
28
+ length.writeUInt16BE(field.length);
29
+ return Buffer.concat([length, field]);
30
+ });
31
+ await writeFile(authority, Buffer.concat([Buffer.from([255, 255]), ...records]), {
32
+ mode: 0o600,
33
+ });
34
+ };
35
+ await writeAuthority("");
36
+ const display = spawn("Xvfb", ["-displayfd", "3", "-screen", "0", "1280x800x24", "-nolisten", "tcp", "-auth", authority], {
37
+ stdio: ["ignore", "ignore", "inherit", "pipe"],
38
+ });
39
+ let chrome;
40
+ const stop = () => {
41
+ if (chrome)
42
+ chrome.kill("SIGTERM");
43
+ else
44
+ display.kill();
45
+ };
46
+ process.on("SIGTERM", stop);
47
+ process.on("SIGINT", stop);
48
+ let timer;
49
+ try {
50
+ const number = await new Promise((resolve, reject) => {
51
+ timer = setTimeout(() => reject(Error("Xvfb did not start within 10 seconds")), 10_000);
52
+ let output = "";
53
+ display.once("error", reject);
54
+ display.once("exit", () => reject(Error("Xvfb exited before Chrome started")));
55
+ display.stdio[3].on("data", (chunk) => {
56
+ output += String(chunk);
57
+ if (/^\d+\n$/.test(output))
58
+ resolve(output.trim());
59
+ });
60
+ }).finally(() => clearTimeout(timer));
61
+ await writeAuthority(number);
62
+ chrome = spawn(process.argv[3], process.argv.slice(4), {
63
+ env: { ...process.env, DISPLAY: `:${number}`, XAUTHORITY: authority },
64
+ stdio: "inherit",
65
+ });
66
+ display.once("exit", () => chrome?.kill("SIGTERM"));
67
+ process.exitCode = await new Promise((resolve, reject) => {
68
+ chrome.once("error", reject);
69
+ chrome.once("exit", (code) => resolve(code ?? 1));
70
+ });
71
+ }
72
+ finally {
73
+ display.kill();
74
+ await rm(directory, { recursive: true, force: true });
75
+ }
76
+ }
77
+ if (process.argv[2] === "--supervise") {
78
+ await supervise().catch((error) => {
79
+ console.error(error.message);
80
+ process.exitCode = 1;
81
+ });
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ask-pro",
3
- "version": "0.2.87",
3
+ "version": "0.2.93",
4
4
  "description": "Browser-backed ChatGPT Pro escalation for hard engineering questions.",
5
5
  "keywords": [
6
6
  "agents",
@@ -29,6 +29,7 @@
29
29
  "dist/src/browser/attachRunning.js",
30
30
  "dist/src/browser/chatgptModelCatalog.js",
31
31
  "dist/src/browser/chromeLifecycle.js",
32
+ "dist/src/browser/linuxChrome.js",
32
33
  "dist/src/browser/config.js",
33
34
  "dist/src/browser/constants.js",
34
35
  "dist/src/browser/cookies.js",