Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 121 additions & 15 deletions chatgpt-controller.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,24 @@ export class ChatGPTController {

async waitForPromptVisible({ timeoutMs = 10 * 60_000, pollMs = 500 } = {}) {
const start = Date.now();
let evalFailures = 0;
while (Date.now() - start < timeoutMs) {
this.#throwIfStopRequested();
const st = await this.detectChallenge().catch(() => null);
// A dead target (window closed, session detached) makes every evaluate
// throw; swallowing that forever looked like a silent 10-minute hang.
// Surface it fast so callers can recreate the tab.
let st = null;
try {
st = await this.detectChallenge();
evalFailures = 0;
} catch (error) {
evalFailures++;
if (evalFailures >= 5) {
const err = new Error('tab_target_lost');
err.data = { evalFailures, lastError: String(error?.message || error) };
throw err;
}
}
if (st?.blocked) await this.#enterBlockedState(st);
if (st?.promptVisible) return st;

Expand Down Expand Up @@ -285,7 +300,8 @@ export class ChatGPTController {
const style = window.getComputedStyle(n);
return r.width > 0 && r.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
};
const stop = Array.from(document.querySelectorAll(${stopSel})).find(visible);
const inScope = (n) => !n.closest('nav, aside, [role="navigation"], [data-testid*="history" i]');
const stop = Array.from(document.querySelectorAll(${stopSel})).filter(inScope).find(visible);
if (!stop) return false;
try {
stop.click();
Expand All @@ -307,7 +323,25 @@ export class ChatGPTController {
}

async #typeHuman(text) {
for (const ch of String(text)) {
const value = String(text);
// Human-paced typing is fine for short prompts, but at 12-45ms/char a
// large prompt takes half an hour — during which a stray Enter can send a
// PARTIAL message and the eventual clickSend hits already_generating.
// Large prompts are inserted in bulk (like a paste), with a short typed
// tail to keep the composer's input events realistic.
if (value.length > 1500) {
const head = value.slice(0, -20);
const tail = value.slice(-20);
await this.page.insertText(head);
await sleep(jitter(80, 200));
for (const ch of tail) {
this.#throwIfStopRequested();
await this.page.insertText(ch);
await sleep(jitter(12, 45));
}
return;
}
for (const ch of value) {
this.#throwIfStopRequested();
await this.page.insertText(ch);
await sleep(jitter(12, 45));
Expand Down Expand Up @@ -430,7 +464,8 @@ export class ChatGPTController {
const style = window.getComputedStyle(n);
return r.width > 0 && r.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
};
const stopVisible = Array.from(document.querySelectorAll(${stopSel})).some(visible);
const inScope = (n) => !n.closest('nav, aside, [role="navigation"], [data-testid*="history" i]');
const stopVisible = Array.from(document.querySelectorAll(${stopSel})).filter(inScope).some(visible);
const send = Array.from(document.querySelectorAll(${sendSel})).find(visible);
const sendDisabled = !!send && !!send.disabled;

Expand Down Expand Up @@ -469,7 +504,11 @@ export class ChatGPTController {
const sendSel = JSON.stringify(this.selectors.sendButton);
const stopSel = JSON.stringify(this.selectors.stopButton);
const res = await this.#eval(`(() => {
const stop = Array.from(document.querySelectorAll(${stopSel})).find((n) => {
// Scope stop detection to the content area: sidebar history rows can
// match loose selectors (e.g. aria-label*="cancel" vs a conversation
// titled "…Cancellations") and must never count as "generating".
const inScope = (n) => !n.closest('nav, aside, [role="navigation"], [data-testid*="history" i]');
const stop = Array.from(document.querySelectorAll(${stopSel})).filter(inScope).find((n) => {
const r = n.getBoundingClientRect();
const style = window.getComputedStyle(n);
return r.width > 0 && r.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
Expand Down Expand Up @@ -706,6 +745,60 @@ export class ChatGPTController {
await this.page.setFileInputFiles(absFiles);
}

// Providers disable send while attachment uploads are in flight. Sending
// before they settle races the composer: every send path fails and the run
// dies with send_not_triggered while the file tiles are still spinning.
// Ready = a visible, enabled send button and no in-flight upload indicator
// in the composer, held stable across polls. Budget scales with bytes.
async #waitForUploadsSettled({ attachments = [], timeoutMs = null, pollMs = 500 } = {}) {
if (!attachments?.length) return;
let totalBytes = 0;
for (const f of attachments) {
try {
totalBytes += (await fs.stat(path.resolve(f))).size;
} catch {}
}
const budget = timeoutMs || Math.min(15 * 60_000, 120_000 + Math.ceil(totalBytes / 1_000_000) * 60_000);
await this.#emitProgress({ phase: 'uploading_files' });
const sendSel = JSON.stringify(this.selectors.sendButton);
const start = Date.now();
let readySince = null;
let lastSnap = null;
while (Date.now() - start < budget) {
this.#throwIfStopRequested();
const snap = await this.#eval(`(() => {
const visible = (n) => {
if (!n) return false;
const r = n.getBoundingClientRect();
const style = window.getComputedStyle(n);
return r.width > 0 && r.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
};
const send = Array.from(document.querySelectorAll(${sendSel})).find(visible) || null;
const sendEnabled = send ? !(send.disabled || String(send.getAttribute('aria-disabled') || '').toLowerCase() === 'true') : false;
const scope = send?.closest('form') || document.querySelector('main form') || document.querySelector('main') || document.body;
const uploading = !!scope.querySelector('[role=\"progressbar\"], progress, [aria-busy=\"true\"]');
const failed = /failed to upload|upload failed|couldn't upload|could not upload|error uploading/i.test((scope.innerText || '').slice(0, 4000));
return { sendEnabled, uploading, failed };
})()`).catch(() => null);
lastSnap = snap;
if (snap?.failed) {
const err = new Error('attachment_upload_failed');
err.data = { attachments: attachments.length, totalBytes };
throw err;
}
if (snap && snap.sendEnabled && !snap.uploading) {
if (readySince == null) readySince = Date.now();
else if (Date.now() - readySince >= 900) return;
} else {
readySince = null;
}
await sleep(pollMs);
}
const err = new Error('attachment_upload_timeout');
err.data = { attachments: attachments.length, totalBytes, waitedMs: Date.now() - start, last: lastSnap };
throw err;
}

async #waitForAssistantStable({ timeoutMs = 5 * 60_000, stableMs = 1500, pollMs = 400 } = {}) {
await this.#emitProgress({ phase: 'waiting_for_response', blocked: false, blockedKind: null, blockedTitle: null });
const assistantSel = JSON.stringify(this.selectors.assistantMessage);
Expand All @@ -716,11 +809,13 @@ export class ChatGPTController {
let lastChange = Date.now();
let stopGoneAt = null;
let continueClicks = 0;
let baseCount = null;

while (Date.now() - start < timeoutMs) {
this.#throwIfStopRequested();
const snap = await this.#eval(`(() => {
const stop = !!document.querySelector(${stopSel});
const inScope = (n) => !n.closest('nav, aside, [role="navigation"], [data-testid*="history" i]');
const stop = Array.from(document.querySelectorAll(${stopSel})).some(inScope);
const send = Array.from(document.querySelectorAll(${sendSel})).find((n) => {
const r = n.getBoundingClientRect();
const style = window.getComputedStyle(n);
Expand All @@ -734,18 +829,24 @@ export class ChatGPTController {
const hasContinue = Array.from(document.querySelectorAll('button, a')).some(b => /continue generating/i.test((b.textContent||'').trim()));
const hasRegenerate = Array.from(document.querySelectorAll('button, a')).some(b => /regenerate/i.test((b.textContent||'').trim()));
const hasError = /something went wrong|try again|error/i.test(txt) && txt.length < 500;
return { stop, sendEnabled, txt, count: nodes.length, usedFallback: !lastNode, hasError, hasContinue, hasRegenerate };
return { stop, sendVisible: !!send, sendEnabled, txt, count: nodes.length, usedFallback: !lastNode, hasError, hasContinue, hasRegenerate };
})()`);

const txt = String(snap?.txt || '');
if (txt !== last) {
last = txt;
lastChange = Date.now();
}

// Some providers expose unrelated visible "stop/cancel" controls.
// Treat "generating" as stop-visible only when send is not enabled.
const generating = !!snap?.stop && !snap?.sendEnabled;
// The assistant-message count when we started waiting; a real reply is a
// NEW node beyond this baseline (reasoning models can "think" for minutes
// before any text appears — pre-existing page text must not count).
if (baseCount == null && snap) baseCount = snap.count || 0;
const newMsg = snap ? (snap.count || 0) > (baseCount || 0) : false;

// Generating whenever a stop control is visible and no enabled send
// button is; providers replace send with stop while responding, so
// "no send button" must NOT read as send-enabled.
const generating = !!snap?.stop && !(snap?.sendVisible && snap?.sendEnabled);
if (generating) stopGoneAt = null;
else if (stopGoneAt == null) stopGoneAt = Date.now();

Expand All @@ -763,11 +864,15 @@ export class ChatGPTController {
continue;
}

const readyByNodes = (snap?.count || 0) > 0;
const fallbackWaited = !!snap?.usedFallback && (Date.now() - start >= 2500);
// A reply counts when a NEW assistant node appeared; the old count>0 test
// accepted pre-existing thread content as "the reply". Escape hatches:
// selector-less providers (usedFallback) after a long grace, and a
// baseline-glitch guard after 2 minutes of stable, non-generating quiet.
const readyByNodes = newMsg || ((snap?.count || 0) > 0 && Date.now() - start >= 120_000);
const fallbackWaited = !!snap?.usedFallback && (Date.now() - start >= 45_000);
const fallbackStableLongEnough = txt.length > 0 && (Date.now() - lastChange >= Math.max(dynamicStableMs, 5000));
const done =
(!generating && stopGoneLongEnough && snap?.sendEnabled && stable && txt.length > 0 && (readyByNodes || fallbackWaited)) ||
(!generating && stopGoneLongEnough && stable && txt.length > 0 && (readyByNodes || fallbackWaited)) ||
(!generating && fallbackStableLongEnough && (readyByNodes || fallbackWaited));
if (done) {
const extra = await this.#eval(`(() => {
Expand Down Expand Up @@ -800,8 +905,9 @@ export class ChatGPTController {
await this.ensureReady({ timeoutMs });
await this.#attachFiles(attachments);
await this.#typePrompt(prompt);
await this.#waitForUploadsSettled({ attachments });
await this.#clickSend();
return await this.#waitForAssistantStable({ timeoutMs: Math.min(timeoutMs, 8 * 60_000) });
return await this.#waitForAssistantStable({ timeoutMs }); // reasoning models can think for an hour
} finally {
if (this.currentRun === run) this.currentRun = null;
}
Expand Down
34 changes: 33 additions & 1 deletion chrome-cdp-backend.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,20 @@ class ChromeCdpPageAdapter {
}

async setFileInputFiles(files) {
// Pick the input whose `accept` is compatible with the files. Sites ship
// several file inputs (e.g. chatgpt.com: #upload-files, #upload-photos
// accept="image/*", #upload-camera accept="image/*"); feeding documents to
// an image-only input routes them into the site's image pipeline, whose
// decode fails and leaves the attachment stuck at 0% forever.
const isImagePath = (p) => /\.(png|jpe?g|gif|webp|bmp|heic|heif|svg|avif|tiff?)$/i.test(String(p));
const allImages = files.every(isImagePath);
const acceptCompatible = (accept) => {
const a = String(accept || '').trim().toLowerCase();
if (!a || a === '*' || a.includes('*/*')) return true;
const imageOnly = a.split(',').every((part) => part.trim().startsWith('image/') || /^\.(png|jpe?g|gif|webp|bmp|heic|heif|svg|avif|tiff?)$/.test(part.trim()));
return imageOnly ? allImages : true;
};

let lastNodeIds = [];
for (let attempt = 0; attempt < 10; attempt++) {
const { root } = await this.client.send('DOM.getDocument', { depth: 12, pierce: true }, this.sessionId);
Expand All @@ -414,8 +428,22 @@ class ChromeCdpPageAdapter {
continue;
}

const infos = [];
for (const nodeId of nodeIds) {
let accept = '';
try {
const attrs = await this.client.send('DOM.getAttributes', { nodeId }, this.sessionId);
const list = Array.isArray(attrs?.attributes) ? attrs.attributes : [];
for (let i = 0; i + 1 < list.length; i += 2) {
if (list[i] === 'accept') accept = list[i + 1];
}
} catch {}
infos.push({ nodeId, accept });
}
const ordered = [...infos.filter((i) => acceptCompatible(i.accept)), ...infos.filter((i) => !acceptCompatible(i.accept))];

let lastErr = null;
for (const nodeId of [...nodeIds].reverse()) {
for (const { nodeId } of ordered) {
try {
await this.client.send('DOM.setFileInputFiles', { nodeId, files }, this.sessionId);
lastErr = null;
Expand Down Expand Up @@ -594,6 +622,10 @@ export class ChromeCdpBrowserBackend {
} catch {}
this.onChanged?.();
});
// Chrome only emits Target.targetDestroyed when discovery is enabled.
// Without this, closed tabs were never pruned and later queries hung
// on dead sessions until their full timeout.
await this.client.send('Target.setDiscoverTargets', { discover: true }).catch(() => {});
this.started = true;
return this.getState();
} catch (error) {
Expand Down
29 changes: 28 additions & 1 deletion electron-browser-backend.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ class ElectronPageAdapter {
throw err;
}

// Pick the input whose `accept` is compatible with the files — feeding
// documents to an image-only input (e.g. chatgpt.com's #upload-camera)
// routes them into the site's image pipeline, whose decode fails and
// leaves the attachment stuck at 0% forever.
const isImagePath = (p) => /\.(png|jpe?g|gif|webp|bmp|heic|heif|svg|avif|tiff?)$/i.test(String(p));
const allImages = files.every(isImagePath);
const acceptCompatible = (accept) => {
const a = String(accept || '').trim().toLowerCase();
if (!a || a === '*' || a.includes('*/*')) return true;
const imageOnly = a.split(',').every((part) => part.trim().startsWith('image/') || /^\.(png|jpe?g|gif|webp|bmp|heic|heif|svg|avif|tiff?)$/.test(part.trim()));
return imageOnly ? allImages : true;
};

try {
let lastNodeIds = [];
for (let attempt = 0; attempt < 10; attempt++) {
Expand All @@ -88,8 +101,22 @@ class ElectronPageAdapter {
continue;
}

const infos = [];
for (const nodeId of nodeIds) {
let accept = '';
try {
const attrs = await wc.debugger.sendCommand('DOM.getAttributes', { nodeId });
const list = Array.isArray(attrs?.attributes) ? attrs.attributes : [];
for (let i = 0; i + 1 < list.length; i += 2) {
if (list[i] === 'accept') accept = list[i + 1];
}
} catch {}
infos.push({ nodeId, accept });
}
const ordered = [...infos.filter((i) => acceptCompatible(i.accept)), ...infos.filter((i) => !acceptCompatible(i.accept))];

let lastErr = null;
for (const nodeId of [...nodeIds].reverse()) {
for (const { nodeId } of ordered) {
try {
await wc.debugger.sendCommand('DOM.setFileInputFiles', { nodeId, files });
lastErr = null;
Expand Down
3 changes: 2 additions & 1 deletion http-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,8 @@ export function startHttpApi({

if (url.pathname === '/query' && req.method === 'POST') {
const body = await parseBody(req, { maxBytes: 5_000_000 });
const timeoutMs = positiveIntOr(body.timeoutMs, 10 * 60_000, 30 * 60_000);
// Reasoning models can take an hour before responding; allow up to 2h.
const timeoutMs = positiveIntOr(body.timeoutMs, 10 * 60_000, 2 * 60 * 60_000);
const prompt = String(body.prompt || '');
if (!prompt.trim()) throw new Error('missing_prompt');
if (prompt.length > 200_000) throw new Error('prompt_too_large');
Expand Down
Loading