diff --git a/chatgpt-controller.mjs b/chatgpt-controller.mjs index 8e32af6..739d776 100644 --- a/chatgpt-controller.mjs +++ b/chatgpt-controller.mjs @@ -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; @@ -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(); @@ -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)); @@ -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; @@ -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'; @@ -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); @@ -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); @@ -734,7 +829,7 @@ 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 || ''); @@ -742,10 +837,16 @@ export class ChatGPTController { 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(); @@ -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(`(() => { @@ -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; } diff --git a/chrome-cdp-backend.mjs b/chrome-cdp-backend.mjs index e84f609..c477a64 100644 --- a/chrome-cdp-backend.mjs +++ b/chrome-cdp-backend.mjs @@ -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); @@ -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; @@ -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) { diff --git a/electron-browser-backend.mjs b/electron-browser-backend.mjs index ff99f11..1546bfc 100644 --- a/electron-browser-backend.mjs +++ b/electron-browser-backend.mjs @@ -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++) { @@ -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; diff --git a/http-api.mjs b/http-api.mjs index 620c341..289ab79 100644 --- a/http-api.mjs +++ b/http-api.mjs @@ -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'); diff --git a/mcp-lib.mjs b/mcp-lib.mjs index 4167430..94e30d2 100644 --- a/mcp-lib.mjs +++ b/mcp-lib.mjs @@ -57,31 +57,70 @@ async function validateConn({ conn, fetchImpl }) { if (!health.ok) return { ok: false, reason: 'health_not_ok' }; if (conn.serverId && healthData?.serverId && conn.serverId !== healthData.serverId) return { ok: false, reason: 'server_id_mismatch' }; - // 2) Authenticated status: ensures token matches and the server is ours. + // 2) Authenticated status: only AUTH failures invalidate a live server — + // /status can carry tab-level errors (e.g. tab_not_found before any default + // tab exists) while the server is healthy and ours. const status = await fetchImpl(`${conn.baseUrl}/status`, { headers: { authorization: `Bearer ${conn.token}` } }); + if (status.status === 401 || status.status === 403) return { ok: false, reason: 'unauthorized' }; const statusData = await status.json().catch(() => ({})); - if (!status.ok) return { ok: false, reason: 'status_not_ok', status: status.status }; if (statusData?.error === 'unauthorized') return { ok: false, reason: 'unauthorized' }; - if (statusData?.ok !== true) return { ok: false, reason: 'unexpected_status_payload' }; return { ok: true, serverId: healthData?.serverId || null }; } -export async function requestJson({ baseUrl, token, method, path: pth, body, fetchImpl = fetch }) { - const res = await fetchImpl(`${baseUrl}${pth}`, { - method, - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${token}` - }, - body: body ? JSON.stringify(body) : undefined - }); - const data = await res.json().catch(() => ({})); - if (!res.ok || data?.error) { - const err = new Error(data?.message || data?.error || `http_${res.status}`); - err.data = { status: res.status, body: data }; - throw err; +// Default transport is node:http, NOT fetch: Node's built-in fetch (undici) +// enforces a ~5-minute headers timeout, which kills long-blocking calls like +// /query while a reasoning model thinks for up to an hour. Passing a custom +// fetchImpl (tests) keeps the fetch-shaped path. +export async function requestJson({ baseUrl, token, method, path: pth, body, fetchImpl = null, timeoutMs = 0 }) { + if (fetchImpl) { + const res = await fetchImpl(`${baseUrl}${pth}`, { + method, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}` + }, + body: body ? JSON.stringify(body) : undefined + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.error) { + const err = new Error(data?.message || data?.error || `http_${res.status}`); + err.data = { status: res.status, body: data }; + throw err; + } + return data; } - return data; + + const { request } = await import('node:http'); + return await new Promise((resolve, reject) => { + const payload = body ? JSON.stringify(body) : null; + const u = new URL(baseUrl + pth); + const req = request({ + host: u.hostname, + port: u.port, + path: u.pathname + u.search, + method, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + ...(payload ? { 'content-length': Buffer.byteLength(payload) } : {}) + } + }, (res) => { + let data = ''; + res.on('data', (d) => { data += d; }); + res.on('end', () => { + let parsed = {}; + try { parsed = JSON.parse(data); } catch {} + if (res.statusCode >= 200 && res.statusCode < 300 && !parsed?.error) return resolve(parsed); + const err = new Error(parsed?.message || parsed?.error || `http_${res.statusCode}`); + err.data = { status: res.statusCode, body: parsed }; + reject(err); + }); + }); + if (timeoutMs > 0) req.setTimeout(timeoutMs, () => req.destroy(new Error(`client_timeout_${timeoutMs}ms`))); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); } export async function ensureDesktopRunning({ diff --git a/tests/http-api.test.mjs b/tests/http-api.test.mjs index b676d18..d145964 100644 --- a/tests/http-api.test.mjs +++ b/tests/http-api.test.mjs @@ -2262,7 +2262,7 @@ test('http-api: oversized numeric overrides are clamped to bounded ceilings', as } }); assert.equal(queried.res.status, 200); - assert.equal(seen.query[0], 30 * 60_000); + assert.equal(seen.query[0], 2 * 60 * 60_000); assert.equal(queried.data.packedContextBudget.maxContextChars, 500_000); assert.equal(queried.data.packedContextBudget.maxFiles, 500); assert.equal(queried.data.packedContextBudget.maxFileChars, 100_000);