import { assertChatGptCapabilitiesAvailable } from './chatgpt-dom-snapshot-shared.js'; import { mkdir, rename, rm, writeFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; import path from 'node:path'; import process from 'node:process'; import { spawn, type ChildProcess } from 'node:child_process'; import { closeSync, existsSync, openSync, readFileSync } from 'node:fs'; import { DEFAULT_BROWSER_ENDPOINT, assistantSnapshotLooksIncomplete, assistantSnapshotLooksTerminal, closeTarget, closeThreadTarget, completeThreadCaptureIdentity, downloadThreadAttachment, extractAssistantArtifactLabels, extractAssistantDownloadTargets, exportThreadSnapshot, snapshotBusyReason, snapshotIndicatesBusy, sleep, type CdpTargetLease, type ThreadCaptureIdentity, type ThreadTargetLifecycle, type ThreadSnapshot, } from './chatgpt-thread-lib.mjs'; import { findCodexSessionLog, formatCodexHomeForDisplay, formatPathForDisplay, resolveCodexBin, type ResolvedCodexHome, resolveCodexHomeForSession, } from './codex-session-lib.mjs'; import { buildRecursiveFollowupScript, buildRecursiveWakeInfo, buildRecursiveWakeInstructions, buildWakeReplayCommands, type WakeRecursiveInfo, } from './chatgpt-thread-wake-recursive-lib.mjs'; export type { WakeRecursiveInfo } from './chatgpt-thread-wake-recursive-lib.mjs'; export type WakeOptions = { browserEndpoint?: string; captureIdentity?: ThreadCaptureIdentity; captureMetadataPath?: string; chatUrl: string; codexHome?: string; delayMs: number; downloadTimeoutMs?: number; fullAuto?: boolean; outputDir: string; pollJitterMs?: number; pollIntervalMs?: number; pollTimeoutMs?: number; pollUntilComplete?: boolean; recursiveDepth?: number; recursivePrompt?: string; repoDir: string; resumePrompt?: string; sessionId?: string; skipResume?: boolean; tabLifecycle?: WakeTabLifecycle; }; export type WakeTabLifecycle = ThreadTargetLifecycle | 'close-harvested'; export type WakeCompletionStatus = 'checked-once' | 'completed'; export type WakeHandoffKind = 'artifact' | 'text' | 'none'; export type WakeAssistantResponseSource = 'latest-assistant' | 'none'; export type WakeResult = { attemptCount: number; assistantResponseMetaPath?: string; assistantResponsePath?: string; assistantResponseSource?: WakeAssistantResponseSource; assistantResponseTextLength?: number; childSessionPersistence?: 'pending' | 'verified'; childSessionId?: string; childRolloutPath?: string; completionStatus: WakeCompletionStatus; codexBin?: string; codexHome?: string; downloadErrors?: string[]; downloadedArtifacts?: string[]; downloadedPatches: string[]; eventsPath?: string; exportPath: string; handoffKind?: WakeHandoffKind; launcherPid?: number; outputDir: string; recursive?: WakeRecursiveInfo; replayCommandsPath?: string; repoDir: string; stderrPath?: string; resumeOutputPath?: string; sessionId?: string; statusPath?: string; }; const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000; const DEFAULT_CHILD_LAUNCH_TIMEOUT_MS = 15_000; const DEFAULT_CHILD_SESSION_POLL_MS = 250; const DEFAULT_CHILD_TERMINATION_GRACE_MS = 5_000; const DEFAULT_INITIAL_POLL_JITTER_CAP_MS = 15_000; const DEFAULT_MAX_CONSECUTIVE_EXPORT_FAILURES = 3; const DEFAULT_STABLE_IDLE_POLLS_REQUIRED = 2; const DEFAULT_STALE_SNAPSHOT_POLLS_BEFORE_RELOAD = 3; const DEFAULT_HARD_REFRESH_INTERVAL_MS = 10 * 60 * 1000; const DEFAULT_POLL_JITTER_MS = 60_000; const DEFAULT_POLL_INTERVAL_MS = 60_000; const DEFAULT_DOWNLOAD_ATTEMPTS = 2; const DOWNLOAD_RETRY_DELAY_MS = 1_000; type WakeCandidateKind = 'artifact' | 'terminal-no-artifact' | 'partial' | 'empty'; type WakeState = 'waiting' | 'downloading' | 'spawning' | 'running' | 'succeeded' | 'failed'; type WakeStatus = { attemptCount: number; assistantFailureTexts?: string[]; assistantResponseMetaPath?: string; assistantResponsePath?: string; assistantResponseSignature?: string; assistantResponseSource?: WakeAssistantResponseSource; assistantResponseTextLength?: number; assistantResponseUpdatedAt?: string; chatUrl: string; childSessionPersistence?: 'pending' | 'verified'; childSessionId?: string; childRolloutPath?: string; codexBin?: string; codexHome?: string; completionStatus?: WakeCompletionStatus; downloadErrors?: string[]; downloadedArtifacts?: string[]; downloadedPatches: string[]; eventsPath?: string; exportPath: string; handoffKind?: WakeHandoffKind; launcherPid?: number; lastError?: string; lastArtifactLabels?: string[]; lastAssistantPreview?: string; lastBusyReason?: string; lastPatchLabels?: string[]; lastSnapshotSummary?: string; forceReloadNextExport?: boolean; forcedReloadCount?: number; outputDir: string; recursive?: WakeRecursiveInfo; replayCommandsPath?: string; repoDir: string; stderrPath?: string; sessionId?: string; bestCandidateKind?: WakeCandidateKind; currentCandidateKind?: WakeCandidateKind; stallPolls?: number; staleSnapshotPolls?: number; staleSnapshotThreshold?: number; state: WakeState; resumeOutputPath?: string; updatedAt: string; }; type CodexChildSessionLaunch = { childSessionPersistence?: 'pending' | 'verified'; childSessionId?: string; childRolloutPath?: string; eventsPath?: string; launcherPid?: number; resumeOutputPath?: string; stderrPath?: string; }; type WakeDependencies = { closeTarget: typeof closeTarget; closeThreadTarget: typeof closeThreadTarget; downloadThreadAttachment: typeof downloadThreadAttachment; exportThreadSnapshot: typeof exportThreadSnapshot; log: (message: string) => void; mkdir: typeof mkdir; now: () => number; random: () => number; resolveCodexBin: typeof resolveCodexBin; resolveCodexHomeForSession: typeof resolveCodexHomeForSession; runCodexChildSession: typeof runCodexChildSession; sleep: typeof sleep; writeCaptureIdentity: typeof writeCaptureIdentityAtomically; writeFile: typeof writeFile; }; async function writeCaptureIdentityAtomically( filePath: string, captureIdentity: ThreadCaptureIdentity, ): Promise { const temporaryPath = path.join( path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`, ); await mkdir(path.dirname(filePath), { recursive: true }); try { await writeFile(temporaryPath, `${JSON.stringify(captureIdentity)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600, }); await rename(temporaryPath, filePath); } finally { await rm(temporaryPath, { force: true }); } } type WakeCandidate = { artifactSignature: string; assistantTurnCount: number; finalSignature: string; finalText: string; kind: WakeCandidateKind; textLength: number; }; function resolveChildSessionPersistence(codexHome: string, childSessionId: string): { childRolloutPath?: string; childSessionPersistence: 'pending' | 'verified'; } { const childRolloutPath = findCodexSessionLog(codexHome, childSessionId)?.filePath; return { childRolloutPath, childSessionPersistence: childRolloutPath ? 'verified' : 'pending', }; } const DEFAULT_WAKE_DEPENDENCIES: WakeDependencies = { closeTarget, closeThreadTarget, downloadThreadAttachment, exportThreadSnapshot, log: (message) => { process.stderr.write(message); }, mkdir, now: Date.now, random: Math.random, resolveCodexBin, resolveCodexHomeForSession, runCodexChildSession, sleep, writeCaptureIdentity: writeCaptureIdentityAtomically, writeFile, }; function childHasExited(child: ChildProcess): boolean { return child.exitCode !== null || child.signalCode !== null; } function signalOwnedChildTree(child: ChildProcess, signal: NodeJS.Signals): boolean { const childPid = child.pid; if (!childPid) return true; try { process.kill(process.platform === 'win32' ? childPid : -childPid, signal); return false; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ESRCH') return true; throw error; } } async function waitForOwnedChildExit(child: ChildProcess, timeoutMs: number): Promise { if (childHasExited(child)) return true; return await new Promise((resolve) => { let settled = false; const finish = (exited: boolean) => { if (settled) return; settled = true; clearTimeout(timer); child.removeListener('exit', onExit); resolve(exited); }; const onExit = () => finish(true); const timer = setTimeout(() => finish(childHasExited(child)), timeoutMs); child.once('exit', onExit); }); } async function terminateOwnedChildTree(child: ChildProcess, graceMs: number): Promise { if (childHasExited(child)) return; const disappearedBeforeTerm = signalOwnedChildTree(child, 'SIGTERM'); if (disappearedBeforeTerm || await waitForOwnedChildExit(child, graceMs)) return; const disappearedBeforeKill = signalOwnedChildTree(child, 'SIGKILL'); if (disappearedBeforeKill || await waitForOwnedChildExit(child, graceMs)) return; throw new Error('Owned Codex child did not exit after SIGTERM and SIGKILL.'); } export function runCodexChildSession( command: string, args: string[], options: { codexHome: string; cwd?: string; env?: NodeJS.ProcessEnv; eventsPath?: string; launchTimeoutMs?: number; resumeOutputPath?: string; stderrPath?: string; terminationGraceMs?: number; }, ): Promise { return new Promise((resolve, reject) => { const cwd = options.cwd ?? process.cwd(); const eventsPath = options.eventsPath ?? path.join(cwd, 'child-events.jsonl'); const stderrPath = options.stderrPath ?? path.join(cwd, 'child-stderr.log'); const eventFd = openSync(eventsPath, 'a'); const stderrFd = openSync(stderrPath, 'a'); const child = spawn(command, args, { cwd: options.cwd ?? process.cwd(), env: options.env ?? process.env, detached: true, stdio: ['ignore', eventFd, stderrFd], }); const launchStartedAt = Date.now(); const launchTimeoutMs = options.launchTimeoutMs ?? DEFAULT_CHILD_LAUNCH_TIMEOUT_MS; const terminationGraceMs = options.terminationGraceMs ?? DEFAULT_CHILD_TERMINATION_GRACE_MS; let settled = false; let stoppingAfterLaunchFailure = false; let sessionDiscoveryTimer: NodeJS.Timeout | undefined; const cleanup = () => { closeSync(eventFd); closeSync(stderrFd); if (sessionDiscoveryTimer) { clearTimeout(sessionDiscoveryTimer); sessionDiscoveryTimer = undefined; } child.removeListener('error', onError); child.removeListener('exit', onExit); }; const fail = (error: Error) => { if (settled) { return; } settled = true; cleanup(); reject(error); }; const succeed = (launch: CodexChildSessionLaunch) => { if (settled) { return; } settled = true; cleanup(); child.unref(); resolve(launch); }; const onError = (error: Error) => { fail(error); }; const onExit = (code: number | null, signal: NodeJS.Signals | null) => { if (stoppingAfterLaunchFailure) return; const detail = code !== null ? `code ${code}` : signal ? `signal ${signal}` : 'an unknown status'; fail(new Error(`codex-exec ${command} exited before handoff with ${detail}`)); }; const readJsonlEvents = (): unknown[] => { if (!existsSync(eventsPath)) { return []; } try { const raw = readFileSync(eventsPath, 'utf8'); return raw .split(/\r?\n/u) .map((line) => line.trim()) .filter(Boolean) .map((line) => JSON.parse(line) as unknown); } catch { return []; } }; const summarizeRecentEvents = (events: unknown[]): string => { const summaries = events .slice(-4) .map((event) => { if (!event || typeof event !== 'object') { return null; } const type = typeof (event as { type?: unknown }).type === 'string' ? (event as { type: string }).type : 'unknown'; const message = typeof (event as { message?: unknown }).message === 'string' ? (event as { message: string }).message : typeof (event as { thread_id?: unknown }).thread_id === 'string' ? `thread=${(event as { thread_id: string }).thread_id}` : undefined; return message ? `${type}:${message}` : type; }) .filter(Boolean); return summaries.length > 0 ? summaries.join(' | ') : 'no recent events'; }; const readRecentStderr = (): string => { if (!existsSync(stderrPath)) { return ''; } try { const raw = readFileSync(stderrPath, 'utf8').trim(); if (!raw) { return ''; } return raw.split(/\r?\n/u).slice(-4).join(' | '); } catch { return ''; } }; const waitForLaunchEvidence = () => { if (settled) { return; } const events = readJsonlEvents(); const threadStarted = events.find((event) => { if (!event || typeof event !== 'object') { return false; } return (event as { type?: unknown }).type === 'thread.started' && typeof (event as { thread_id?: unknown }).thread_id === 'string'; }) as { thread_id?: string } | undefined; const childSessionId = typeof threadStarted?.thread_id === 'string' ? threadStarted.thread_id : undefined; const sawTurnStarted = events.some((event) => event && typeof event === 'object' && (event as { type?: unknown }).type === 'turn.started'); if (childSessionId && sawTurnStarted) { const { childRolloutPath, childSessionPersistence } = resolveChildSessionPersistence(options.codexHome, childSessionId); succeed({ childSessionPersistence, childSessionId, childRolloutPath, eventsPath, launcherPid: child.pid ?? undefined, resumeOutputPath: options.resumeOutputPath, stderrPath, }); return; } if (Date.now() - launchStartedAt >= launchTimeoutMs) { const eventSummary = summarizeRecentEvents(events); const stderrSummary = readRecentStderr(); const launchError = new Error( `codex-exec ${command} did not produce launch events within ${launchTimeoutMs}ms (events: ${eventSummary}${stderrSummary ? `; stderr: ${stderrSummary}` : ''}; eventsPath: ${eventsPath}; stderrPath: ${stderrPath})`, ); stoppingAfterLaunchFailure = true; void terminateOwnedChildTree(child, terminationGraceMs).then( () => fail(launchError), (cleanupError) => fail(new Error( `${launchError.message} Exact owned-child cleanup also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, { cause: launchError }, )), ); return; } sessionDiscoveryTimer = setTimeout(waitForLaunchEvidence, DEFAULT_CHILD_SESSION_POLL_MS); sessionDiscoveryTimer.unref?.(); }; child.on('error', onError); child.on('exit', onExit); waitForLaunchEvidence(); }); } export function chatIdFromUrl(chatUrl: string): string { const lastSegment = new URL(chatUrl).pathname.split('/').filter(Boolean).at(-1); return lastSegment ?? 'chat'; } export function parseWakeDelayToMs(rawValue: string): number { const normalized = rawValue.trim(); const compact = normalized.replace(/\s+/gu, ''); if (!compact) { throw new Error('Delay cannot be empty.'); } if (/^\d+$/u.test(compact)) { return Number.parseInt(compact, 10); } const matches = [...compact.matchAll(/(\d+)(ms|s|m|h|d)/giu)]; if (matches.length === 0 || matches.map((match) => match[0]).join('') !== compact) { throw new Error('Unsupported delay format. Use values like 300s, 70m, 1h, or 1h30m.'); } const unitMs: Record = { d: 86_400_000, h: 3_600_000, m: 60_000, ms: 1, s: 1_000, }; const totalMs = matches.reduce((sum, match) => { const value = Number.parseInt(match[1] ?? '0', 10); const unit = String(match[2] ?? '').toLowerCase(); return sum + value * (unitMs[unit] ?? 0); }, 0); if (!Number.isFinite(totalMs) || totalMs < 0) { throw new Error('Delay must resolve to a non-negative duration.'); } return totalMs; } function requirePositiveDuration(value: number | undefined, label: string): number | undefined { if (value === undefined) { return undefined; } if (!Number.isFinite(value) || value <= 0) { throw new Error(`${label} must resolve to a positive duration.`); } return value; } function requireNonNegativeDuration(value: number | undefined, label: string): number | undefined { if (value === undefined) { return undefined; } if (!Number.isFinite(value) || value < 0) { throw new Error(`${label} must resolve to a non-negative duration.`); } return value; } function computeWakePollDelay( pollIntervalMs: number, pollJitterMs: number, random: () => number, ): number { if (pollJitterMs <= 0) { return pollIntervalMs; } const randomValue = random(); const jitterFactor = Number.isFinite(randomValue) ? Math.min(Math.max(randomValue, 0), 1) : 0; return pollIntervalMs + Math.floor(jitterFactor * pollJitterMs); } function computeWakeStartupJitterDelay( pollUntilComplete: boolean, pollJitterMs: number, random: () => number, ): number { if (!pollUntilComplete || pollJitterMs <= 0) { return 0; } const startupJitterCapMs = Math.min(pollJitterMs, DEFAULT_INITIAL_POLL_JITTER_CAP_MS); if (startupJitterCapMs <= 0) { return 0; } const randomValue = random(); const jitterFactor = Number.isFinite(randomValue) ? Math.min(Math.max(randomValue, 0), 1) : 0; return Math.floor(jitterFactor * startupJitterCapMs); } function formatWakePollDelay( delayMs: number, pollIntervalMs: number, pollJitterMs: number, ): string { if (pollJitterMs <= 0) { return `${delayMs}ms`; } return `${delayMs}ms (${pollIntervalMs}ms base + up to ${pollJitterMs}ms jitter)`; } export function formatWakePollSummary( snapshot: ThreadSnapshot, downloadTargetCount: number, options: { busy?: boolean; busyReason?: string; } = {}, ): string { const statusSummary = snapshot.statusTexts .map((value) => value.trim()) .find((value) => value.length > 0 && value.toLowerCase() !== 'deep research') ?? 'none'; const lastAssistantPreview = summarizeAssistantPreview(snapshot); const busyReason = options.busyReason ?? snapshotBusyReason(snapshot); const busy = options.busy ?? snapshotIndicatesBusy(snapshot); return [ `busy=${busy ? 'yes' : 'no'}`, `attachments=${downloadTargetCount}`, `assistantTurns=${snapshot.assistantSnapshots.length}`, `status=${JSON.stringify(statusSummary)}`, `reason=${JSON.stringify(busyReason)}`, `lastAssistant=${JSON.stringify(lastAssistantPreview)}`, ].join(', '); } function latestAssistantSnapshotsForWake(snapshot: Pick): ThreadSnapshot['assistantSnapshots'] { return snapshot.assistantSnapshots.some((assistantSnapshot) => typeof assistantSnapshot.afterLastUserMessage === 'boolean') ? snapshot.assistantSnapshots.filter((assistantSnapshot) => assistantSnapshot.afterLastUserMessage === true) : snapshot.assistantSnapshots; } function summarizeAssistantPreview(snapshot: Pick): string { const latestRequestSnapshots = latestAssistantSnapshotsForWake(snapshot); const value = String(latestRequestSnapshots.at(-1)?.text ?? '') .replace(/\s+/gu, ' ') .trim(); if (value.length === 0) { return 'none'; } return value.length > 96 ? `${value.slice(0, 93)}...` : value; } function extractAssistantResponseForWake(snapshot: ThreadSnapshot): { assistantTurnCount: number; signature?: string; source: WakeAssistantResponseSource; text: string; textLength: number; } { const latestRequestSnapshots = latestAssistantSnapshotsForWake(snapshot); const finalAssistantSnapshot = latestRequestSnapshots.at(-1); const text = String(finalAssistantSnapshot?.text ?? '').trimEnd(); return { assistantTurnCount: latestRequestSnapshots.length, signature: finalAssistantSnapshot?.signature, source: text.length > 0 ? 'latest-assistant' : 'none', text, textLength: text.length, }; } function normalizeArtifactSearchText(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9]+/gu, ' ') .replace(/\s+/gu, ' ') .trim(); } function declaredPatchArtifactNames(responseText: string): string[] { return [...new Set( [...responseText.matchAll( /(?:^|\n)\s*patch artifact\s*:\s*`?([^\s`]+\.(?:patch|diff|patched))`?/giu, )] .map((match) => String(match[1] ?? '').trim()) .filter((name) => name.length > 0), )]; } function declaredPatchArtifactCaptureFailure(responseText: string, artifactLabels: string[]): string | undefined { const searchableLabels = normalizeArtifactSearchText(artifactLabels.join(' ')); const missingArtifacts = declaredPatchArtifactNames(responseText).filter((artifactName) => { const normalizedName = path.basename(artifactName).toLowerCase(); const lastDotIndex = normalizedName.lastIndexOf('.'); const stem = lastDotIndex > 0 ? normalizedName.slice(0, lastDotIndex) : normalizedName; const extension = lastDotIndex > 0 ? normalizedName.slice(lastDotIndex + 1) : ''; const comparableStem = normalizeArtifactSearchText(stem); const comparableExtension = normalizeArtifactSearchText(extension); if (!comparableStem) { return true; } const pattern = comparableExtension ? `${comparableStem}(?: \\d+(?: \\d+)?)? ${comparableExtension}` : comparableStem; return !new RegExp(`(?:^|\\s)${pattern}(?:\\s|$)`, 'u').test(searchableLabels); }); if (missingArtifacts.length === 0) { return undefined; } const capturedSummary = artifactLabels.length > 0 ? `Captured assistant attachments: ${artifactLabels.join(', ')}.` : 'No downloadable assistant attachments were captured.'; return `Assistant response declared patch artifact ${missingArtifacts.join(', ')}, but it was not present among the downloadable assistant attachments. ${capturedSummary} The response was preserved, but the wake handoff is incomplete.`; } function generationFailureMessage(snapshot: Pick): string | undefined { const failureText = (snapshot.assistantFailureTexts ?? []).find((value) => value.trim().length > 0); return failureText ? `ChatGPT generation failed: ${failureText}` : undefined; } function snapshotHasIndependentTerminalSignal(snapshot: ThreadSnapshot): boolean { return latestAssistantSnapshotsForWake(snapshot).at(-1)?.hasCopyButton === true; } function classifyWakeCandidate( snapshot: ThreadSnapshot, downloadTargets: Array<{ href?: string | null; label: string }>, ): WakeCandidate { const latestRequestSnapshots = latestAssistantSnapshotsForWake(snapshot); const finalAssistantSnapshot = latestRequestSnapshots.at(-1); const finalText = String(finalAssistantSnapshot?.text ?? '') .replace(/\s+/gu, ' ') .trim(); const candidateBase = { artifactSignature: downloadTargets .map((target) => `${target.label}\u0000${target.href ?? ''}`) .join('\u0001'), assistantTurnCount: latestRequestSnapshots.length, finalSignature: String(finalAssistantSnapshot?.signature ?? ''), finalText, textLength: finalText.length, }; if (downloadTargets.length > 0 && !snapshot.statusBusy && !snapshot.stopVisible) { return { ...candidateBase, kind: 'artifact', }; } if (!snapshot.statusBusy && !snapshot.stopVisible && assistantSnapshotLooksTerminal(snapshot)) { return { ...candidateBase, kind: 'terminal-no-artifact', }; } if (snapshotIndicatesBusy(snapshot) || finalText.length > 0) { return { ...candidateBase, kind: 'partial', }; } return { ...candidateBase, kind: 'empty', }; } function wakeCandidateRank(kind: WakeCandidateKind): number { switch (kind) { case 'artifact': return 4; case 'terminal-no-artifact': return 3; case 'partial': return 2; case 'empty': default: return 1; } } function compareWakeCandidates(left: WakeCandidate, right: WakeCandidate): number { const rankDelta = wakeCandidateRank(left.kind) - wakeCandidateRank(right.kind); if (rankDelta !== 0) { return rankDelta; } if (left.assistantTurnCount !== right.assistantTurnCount) { return left.assistantTurnCount - right.assistantTurnCount; } if (left.textLength !== right.textLength) { return left.textLength - right.textLength; } return 0; } function wakeCandidatesMatch(left: WakeCandidate, right: WakeCandidate): boolean { return ( left.kind === right.kind && left.artifactSignature === right.artifactSignature && left.assistantTurnCount === right.assistantTurnCount && left.textLength === right.textLength && left.finalSignature === right.finalSignature && left.finalText === right.finalText ); } function expandResumePromptTemplate( template: string, input: { chatUrl: string; }, ): string { return template .replaceAll('{{chat_url}}', input.chatUrl) .replaceAll('{{chat_id}}', chatIdFromUrl(input.chatUrl)); } export function buildWakeFollowupPrompt(input: { assistantResponsePath?: string; assistantResponseTextLength?: number; artifactLabels?: string[]; chatUrl: string; downloadErrors?: string[]; downloadedArtifacts: string[]; exportPath: string; replayCommandsPath?: string; recursive?: WakeRecursiveInfo; resumePrompt?: string; repoDir: string; }): string { const relativeToRepo = (targetPath: string) => path.relative(input.repoDir, targetPath) || '.'; const assistantResponseLine = input.assistantResponsePath ? input.assistantResponseTextLength && input.assistantResponseTextLength > 0 ? `- The assistant text response was retained at ${relativeToRepo(input.assistantResponsePath)}. Read it first; it is present even when artifacts were also downloaded.` : `- The assistant text response file is ${relativeToRepo(input.assistantResponsePath)}, but no assistant text was captured for the latest request.` : '- No assistant text response file was retained.'; const lines = [ 'Wake-up task:', `- The watched ChatGPT thread URL is ${input.chatUrl}.`, `- Read the exported ChatGPT thread JSON at ${relativeToRepo(input.exportPath)}.`, assistantResponseLine, input.downloadedArtifacts.length > 0 ? `- Inspect the downloaded assistant artifacts already on disk at: ${input.downloadedArtifacts.map((filePath) => relativeToRepo(filePath)).join(', ')}.` : '- No assistant artifacts were downloaded; use the retained assistant text response and thread export as the returned review.', input.artifactLabels && input.artifactLabels.length > 0 ? `- The latest assistant artifact labels were: ${input.artifactLabels.join(', ')}.` : '- No assistant artifact labels were detected in the latest request.', input.downloadErrors && input.downloadErrors.length > 0 ? `- Some artifact downloads failed: ${input.downloadErrors.join(' | ')}.` : '- No artifact download errors were recorded.', input.replayCommandsPath ? `- If you need to refresh the thread export or re-download an attachment, run bash ${relativeToRepo(input.replayCommandsPath)} instead of pnpm exec so stale workspace installs do not block you.` : '- If you need to refresh the thread export or re-download an attachment, invoke the review-gpt CLI directly instead of relying on pnpm exec in the consumer repo.', '- Implement the returned changes in this repository if they are applicable.', '- Run the repo-required verification commands and report any unrelated blockers separately.', '- Keep changes scoped to the retained assistant response and any downloaded artifacts.', ]; const extraPrompt = input.resumePrompt?.trim(); if (extraPrompt) { lines.push( '', 'Additional instructions:', expandResumePromptTemplate(extraPrompt, { chatUrl: input.chatUrl, }), ); } lines.push( ...buildRecursiveWakeInstructions({ recursive: input.recursive, repoDir: input.repoDir, }), ); return lines.join('\n'); } function resolveWakeCodexHome( options: WakeOptions, dependencies: Pick, ): ResolvedCodexHome | undefined { if (options.skipResume) { return undefined; } if (!options.sessionId) { throw new Error('Session ID is required unless --skip-resume is set.'); } return dependencies.resolveCodexHomeForSession(options.sessionId, { codexHome: options.codexHome, }); } export async function runWakeFlow( options: WakeOptions, dependencies: Partial = {}, ): Promise { const wakeDependencies: WakeDependencies = { ...DEFAULT_WAKE_DEPENDENCIES, ...dependencies, }; const resolvedRepoDir = path.resolve(options.repoDir); const resolvedOutputDir = path.resolve(options.outputDir); const statusPath = path.join(resolvedOutputDir, 'status.json'); const exportPath = path.join(resolvedOutputDir, 'thread.json'); const assistantResponsePath = path.join(resolvedOutputDir, 'assistant-response.md'); const assistantResponseMetaPath = path.join(resolvedOutputDir, 'assistant-response.meta.json'); const downloadDir = path.join(resolvedOutputDir, 'downloads'); const replayCommandsPath = path.join(resolvedOutputDir, 'wake-commands.sh'); const recursive = options.skipResume ? undefined : buildRecursiveWakeInfo({ outputDir: resolvedOutputDir, recursiveDepth: options.recursiveDepth ?? 0, }); const browserEndpoint = options.browserEndpoint ?? DEFAULT_BROWSER_ENDPOINT; const downloadTimeoutMs = options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS; const pollJitterMs = requireNonNegativeDuration(options.pollJitterMs ?? DEFAULT_POLL_JITTER_MS, 'Poll jitter') ?? DEFAULT_POLL_JITTER_MS; const pollIntervalMs = requirePositiveDuration(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, 'Poll interval') ?? DEFAULT_POLL_INTERVAL_MS; const pollTimeoutMs = requirePositiveDuration(options.pollTimeoutMs, 'Poll timeout'); const pollUntilComplete = options.pollUntilComplete !== false; const startupJitterCapMs = pollUntilComplete ? Math.min(pollJitterMs, DEFAULT_INITIAL_POLL_JITTER_CAP_MS) : 0; let resolvedCodexBin: string | undefined; let resolvedCodexHome: ResolvedCodexHome | undefined; let childSessionPersistence: 'pending' | 'verified' | undefined; let childSessionId: string | undefined; let childRolloutPath: string | undefined; let eventsPath: string | undefined; let launcherPid: number | undefined; let resumeOutputPath: string | undefined; let stderrPath: string | undefined; let consecutiveExportFailures = 0; let attemptCount = 0; let completionStatus: WakeCompletionStatus = 'checked-once'; const downloadErrors: string[] = []; const downloadedArtifacts: string[] = []; const downloadedPatches: string[] = []; let lastSuccessfulSnapshot: ThreadSnapshot | undefined; let lastSuccessfulArtifactLabels: string[] = []; let lastSuccessfulDownloadTargetCount = 0; let assistantFailureTexts: string[] = []; let bestCandidate: WakeCandidate | undefined; let currentCandidate: WakeCandidate | undefined; let previousCandidate: WakeCandidate | undefined; let consecutiveCompletionPolls = 0; let stallPolls = 0; let forceReloadNextExport = false; let exactReloadFallbackUsed = false; let forcedReloadCount = 0; let assistantResponseSignature: string | undefined; let assistantResponseSource: WakeAssistantResponseSource = 'none'; let assistantResponseTextLength = 0; let assistantResponseUpdatedAt: string | undefined; let handoffKind: WakeHandoffKind = 'none'; let lastAssistantPreview: string | undefined; let lastBusyReason: string | undefined; let lastSnapshotSummary: string | undefined; let currentTargetId = ''; const createdTargetIds = new Set(); const rehydratedTargetIds = new Set(); const validatedRehydratedTargetIds = new Set(); const rememberTargetLease = (lease: CdpTargetLease) => { const targetId = String(lease.target.id ?? '').trim(); if (!targetId) { return; } currentTargetId = targetId; if (lease.created) { createdTargetIds.add(targetId); if (lease.rehydrated) { rehydratedTargetIds.add(targetId); } } }; const closeExactTargets = async (targetIds: Iterable, context: string) => { for (const targetId of new Set(targetIds)) { try { await wakeDependencies.closeTarget(browserEndpoint, targetId); createdTargetIds.delete(targetId); rehydratedTargetIds.delete(targetId); validatedRehydratedTargetIds.delete(targetId); if (currentTargetId === targetId) { currentTargetId = ''; } wakeDependencies.log(`Closed ${context} ChatGPT target.\n`); } catch (error) { const message = error instanceof Error ? error.message : String(error); wakeDependencies.log(`Could not close ${context} ChatGPT target: ${message}.\n`); } } }; const writeAssistantResponseFiles = async (currentSnapshot: ThreadSnapshot & { capturedAt?: string }) => { const assistantResponse = extractAssistantResponseForWake(currentSnapshot); assistantResponseSignature = assistantResponse.signature; assistantResponseSource = assistantResponse.source; assistantResponseTextLength = assistantResponse.textLength; assistantResponseUpdatedAt = new Date().toISOString(); await wakeDependencies.writeFile( assistantResponsePath, assistantResponse.text.length > 0 ? `${assistantResponse.text}${assistantResponse.text.endsWith('\n') ? '' : '\n'}` : '', 'utf8', ); await wakeDependencies.writeFile( assistantResponseMetaPath, `${JSON.stringify( { assistantTurnCount: assistantResponse.assistantTurnCount, capturedAt: currentSnapshot.capturedAt, chatUrl: options.chatUrl, signature: assistantResponse.signature, source: assistantResponse.source, textLength: assistantResponse.textLength, updatedAt: assistantResponseUpdatedAt, }, null, 2, )}\n`, 'utf8', ); }; const writeWakeStatus = async (state: WakeState, extra: Partial = {}) => { const status: WakeStatus = { attemptCount, assistantFailureTexts, assistantResponseMetaPath, assistantResponsePath, assistantResponseSignature, assistantResponseSource, assistantResponseTextLength, assistantResponseUpdatedAt, chatUrl: options.chatUrl, childSessionPersistence, childSessionId, childRolloutPath, codexBin: resolvedCodexBin, codexHome: resolvedCodexHome?.homePath, completionStatus, downloadErrors, downloadedArtifacts, downloadedPatches, eventsPath, exportPath, handoffKind, launcherPid, lastAssistantPreview, lastArtifactLabels: lastSuccessfulArtifactLabels, lastBusyReason, outputDir: resolvedOutputDir, lastPatchLabels: lastSuccessfulArtifactLabels, replayCommandsPath, recursive, repoDir: resolvedRepoDir, resumeOutputPath, sessionId: options.sessionId, stderrPath, bestCandidateKind: bestCandidate?.kind, currentCandidateKind: currentCandidate?.kind, stallPolls, staleSnapshotPolls: stallPolls, staleSnapshotThreshold: DEFAULT_STALE_SNAPSHOT_POLLS_BEFORE_RELOAD, forceReloadNextExport, forcedReloadCount, lastSnapshotSummary, state, updatedAt: new Date().toISOString(), ...extra, }; await wakeDependencies.writeFile(statusPath, `${JSON.stringify(status, null, 2)}\n`, 'utf8'); }; await wakeDependencies.mkdir(downloadDir, { recursive: true }); await writeWakeStatus('waiting'); let snapshot!: ThreadSnapshot; let captureIdentity = options.captureIdentity; let artifactLabels: string[] = []; let downloadTargets: Array<{ artifactIndex: number; artifactIndexInAssistantTurn?: number; assistantTurnId?: string; assistantTurnIndex?: number; href?: string | null; label: string; }> = []; try { if (!options.skipResume) { resolvedCodexBin = wakeDependencies.resolveCodexBin(); resolvedCodexHome = resolveWakeCodexHome(options, wakeDependencies); await writeWakeStatus('waiting'); } wakeDependencies.log( [ `Sleeping for ${options.delayMs}ms before checking ${options.chatUrl}.`, `Repo dir: ${formatPathForDisplay(resolvedRepoDir, resolvedRepoDir)}`, `Output dir: ${formatPathForDisplay(resolvedOutputDir, resolvedRepoDir)}`, resolvedCodexBin ? `Codex bin: ${formatPathForDisplay(resolvedCodexBin, resolvedRepoDir)}` : 'Codex bin: skipped', resolvedCodexHome ? `Codex home: ${formatCodexHomeForDisplay(resolvedCodexHome.homePath)} (${resolvedCodexHome.resolution})` : 'Codex resume: skipped', options.skipResume ? 'Child launch mode: skipped' : 'Child launch mode: codex exec --json', options.sessionId ? `Session ID: ${options.sessionId}` : 'Session ID: (none)', pollUntilComplete ? `Polling: enabled (${pollIntervalMs}ms interval${pollJitterMs > 0 ? `, +0-${pollJitterMs}ms jitter` : ''}${startupJitterCapMs > 0 ? `, +0-${startupJitterCapMs}ms startup spread` : ''}${pollTimeoutMs ? `, ${pollTimeoutMs}ms timeout` : ''}, ${DEFAULT_MAX_CONSECUTIVE_EXPORT_FAILURES} transient export retries)` : 'Polling: disabled', ].join('\n') + '\n', ); await wakeDependencies.sleep(options.delayMs); const startupJitterDelayMs = computeWakeStartupJitterDelay(pollUntilComplete, pollJitterMs, wakeDependencies.random); if (startupJitterDelayMs > 0) { wakeDependencies.log( `Applying ${startupJitterDelayMs}ms startup jitter before the first thread export so simultaneous wake runs spread out.\n`, ); await wakeDependencies.sleep(startupJitterDelayMs); } const pollStartedAt = Date.now(); let lastHardRefreshAt = wakeDependencies.now(); for (;;) { attemptCount += 1; const refreshNow = wakeDependencies.now(); const periodicHardRefreshDue = pollUntilComplete && refreshNow - lastHardRefreshAt >= DEFAULT_HARD_REFRESH_INTERVAL_MS; const stallFallbackReload = captureIdentity ? forceReloadNextExport && !exactReloadFallbackUsed : forceReloadNextExport; const initialCompatibilityReload = !captureIdentity && attemptCount === 1; const forceReloadCurrentExport = initialCompatibilityReload || periodicHardRefreshDue || stallFallbackReload; forceReloadNextExport = false; try { if (forceReloadCurrentExport) { if (captureIdentity && stallFallbackReload && !periodicHardRefreshDue) { exactReloadFallbackUsed = true; } forcedReloadCount += 1; lastHardRefreshAt = refreshNow; wakeDependencies.log( initialCompatibilityReload ? `Wake check ${attemptCount}: forcing a same-tab reload before the first export to avoid stale hydrated thread state.\n` : periodicHardRefreshDue ? `Wake check ${attemptCount}: hard-refreshing the same tab after 10 minutes to recover from a stuck page.\n` : `Wake check ${attemptCount}: forcing a same-tab reload before export after stalled or regressed no-artifact snapshots.\n`, ); } snapshot = await wakeDependencies.exportThreadSnapshot(browserEndpoint, options.chatUrl, exportPath, { captureIdentity, forceReload: forceReloadCurrentExport, onTargetLease: rememberTargetLease, targetLifecycle: 'keep', }); if ( captureIdentity && currentTargetId && captureIdentity.targetId !== currentTargetId ) { captureIdentity = { ...captureIdentity, targetId: currentTargetId, }; if (options.captureMetadataPath) { await wakeDependencies.writeCaptureIdentity(options.captureMetadataPath, captureIdentity); } wakeDependencies.log( 'Rebound exact capture metadata after the replacement target passed thread and turn validation.\n', ); } if (currentTargetId && rehydratedTargetIds.has(currentTargetId)) { validatedRehydratedTargetIds.add(currentTargetId); } } catch (error) { const captureFailure = error instanceof Error && /(?:capture metadata|captured assistant|captured committed|exact captured)/iu.test(error.message); if (captureFailure) { if (captureIdentity && !exactReloadFallbackUsed && !forceReloadCurrentExport) { forceReloadNextExport = true; wakeDependencies.log( `Wake check ${attemptCount}: retained hydrated exact-target evidence did not validate; allowing one same-tab reload fallback before failing closed.\n`, ); continue; } throw error; } if (!pollUntilComplete) { throw error; } consecutiveExportFailures += 1; const errorMessage = error instanceof Error ? error.message : String(error); if (pollTimeoutMs !== undefined && Date.now() - pollStartedAt >= pollTimeoutMs) { throw new Error( `Timed out waiting for ${options.chatUrl} to finish after ${attemptCount} checks because thread export kept failing. Last error: ${errorMessage}`, ); } if (!lastSuccessfulSnapshot && consecutiveExportFailures >= DEFAULT_MAX_CONSECUTIVE_EXPORT_FAILURES) { throw new Error( `Failed to export ${options.chatUrl} after ${consecutiveExportFailures} consecutive polling errors. Last error: ${errorMessage}`, ); } const nextDelayMs = computeWakePollDelay(pollIntervalMs, pollJitterMs, wakeDependencies.random); wakeDependencies.log( `Wake check ${attemptCount}: export failed (${consecutiveExportFailures}/${DEFAULT_MAX_CONSECUTIVE_EXPORT_FAILURES} transient retries used): ${errorMessage}.\n`, ); if (lastSuccessfulSnapshot) { wakeDependencies.log( `Preserving the last successful snapshot while export is flaky. Last good export: ${formatWakePollSummary(lastSuccessfulSnapshot, lastSuccessfulDownloadTargetCount)}.\n`, ); } wakeDependencies.log( `Thread export failed; polling again in ${formatWakePollDelay(nextDelayMs, pollIntervalMs, pollJitterMs)}.\n`, ); await wakeDependencies.sleep(nextDelayMs); continue; } assertChatGptCapabilitiesAvailable(snapshot); consecutiveExportFailures = 0; downloadTargets = extractAssistantDownloadTargets(snapshot); artifactLabels = extractAssistantArtifactLabels(snapshot); lastSuccessfulSnapshot = snapshot; lastSuccessfulArtifactLabels = artifactLabels; lastSuccessfulDownloadTargetCount = downloadTargets.length; assistantFailureTexts = snapshot.assistantFailureTexts; await writeAssistantResponseFiles(snapshot); const failureMessage = generationFailureMessage(snapshot); if (failureMessage) { lastAssistantPreview = summarizeAssistantPreview(snapshot); lastBusyReason = 'generation-failed'; lastSnapshotSummary = formatWakePollSummary(snapshot, downloadTargets.length, { busy: false, busyReason: 'generation-failed', }); throw new Error(failureMessage); } const hasDownloadTargets = downloadTargets.length > 0; currentCandidate = classifyWakeCandidate(snapshot, downloadTargets); const priorBestCandidate = bestCandidate; const bestComparison = priorBestCandidate ? compareWakeCandidates(currentCandidate, priorBestCandidate) : 1; const bestAdvanced = !priorBestCandidate || bestComparison > 0; const completionCandidateChanged = Boolean( priorBestCandidate && (currentCandidate.kind === 'artifact' || currentCandidate.kind === 'terminal-no-artifact') && (priorBestCandidate.kind === 'artifact' || priorBestCandidate.kind === 'terminal-no-artifact') && !wakeCandidatesMatch(currentCandidate, priorBestCandidate), ); const regressedSnapshot = Boolean(priorBestCandidate) && bestComparison < 0 && !completionCandidateChanged; if (bestAdvanced || completionCandidateChanged) { bestCandidate = currentCandidate; stallPolls = 1; } else { stallPolls += 1; } const completionCandidate = currentCandidate.kind === 'artifact' || currentCandidate.kind === 'terminal-no-artifact'; consecutiveCompletionPolls = completionCandidate ? previousCandidate && wakeCandidatesMatch(currentCandidate, previousCandidate) ? consecutiveCompletionPolls + 1 : 1 : 0; previousCandidate = currentCandidate; const stableCompletionPolls = consecutiveCompletionPolls; const stableCompletionReady = completionCandidate && stableCompletionPolls >= DEFAULT_STABLE_IDLE_POLLS_REQUIRED; const independentArtifactTerminalSignal = currentCandidate.kind === 'artifact' && snapshotHasIndependentTerminalSignal(snapshot); const busy = !stableCompletionReady && !independentArtifactTerminalSignal; let busyReason: 'assistant-settling' | 'idle' | 'status-busy' | 'stop-visible' = busy ? snapshot.statusBusy ? 'status-busy' : snapshot.stopVisible ? 'stop-visible' : snapshotBusyReason(snapshot) : 'idle'; if (busy && (currentCandidate.kind === 'terminal-no-artifact' || currentCandidate.kind === 'empty') && busyReason === 'idle') { busyReason = 'assistant-settling'; } lastAssistantPreview = summarizeAssistantPreview(snapshot); lastBusyReason = busyReason; lastSnapshotSummary = formatWakePollSummary(snapshot, downloadTargets.length, { busy, busyReason, }); await writeWakeStatus('waiting'); wakeDependencies.log( `Wake check ${attemptCount}: ${lastSnapshotSummary}${ currentCandidate.kind === 'terminal-no-artifact' ? `, stableIdle=${stableCompletionPolls}/${DEFAULT_STABLE_IDLE_POLLS_REQUIRED}` : currentCandidate.kind === 'artifact' ? `, stableArtifact=${stableCompletionPolls}/${DEFAULT_STABLE_IDLE_POLLS_REQUIRED}` : busy && !hasDownloadTargets ? `, stall=${stallPolls}/${DEFAULT_STALE_SNAPSHOT_POLLS_BEFORE_RELOAD}` : '' }.\n`, ); if (downloadTargets.length > 0) { const displayLabels = downloadTargets.map((target) => target.label).filter((label) => label.length > 0); wakeDependencies.log(`Wake check ${attemptCount}: assistant download targets: ${displayLabels.join(' | ')}.\n`); } if (!pollUntilComplete) { break; } if (!busy) { completionStatus = 'completed'; break; } if (regressedSnapshot && !hasDownloadTargets && !assistantSnapshotLooksIncomplete(snapshot)) { forceReloadNextExport = !captureIdentity || !exactReloadFallbackUsed; stallPolls = 0; if (forceReloadNextExport) { wakeDependencies.log( `Wake check ${attemptCount}: current snapshot regressed below prior best evidence without artifacts; forcing a same-tab reload on the next export.\n`, ); } } else if ( !hasDownloadTargets && stallPolls >= DEFAULT_STALE_SNAPSHOT_POLLS_BEFORE_RELOAD ) { forceReloadNextExport = !captureIdentity || !exactReloadFallbackUsed; stallPolls = 0; if (forceReloadNextExport) { wakeDependencies.log( `Wake check ${attemptCount}: no stronger assistant state appeared for ${DEFAULT_STALE_SNAPSHOT_POLLS_BEFORE_RELOAD} polls without artifacts; forcing a same-tab reload on the next export.\n`, ); } } if (pollTimeoutMs !== undefined && Date.now() - pollStartedAt >= pollTimeoutMs) { throw new Error( `Timed out waiting for ${options.chatUrl} to finish after ${attemptCount} checks. Last export: ${formatPathForDisplay(exportPath, resolvedRepoDir)}`, ); } const nextDelayMs = computeWakePollDelay(pollIntervalMs, pollJitterMs, wakeDependencies.random); wakeDependencies.log( `Thread still looks busy; polling again in ${formatWakePollDelay(nextDelayMs, pollIntervalMs, pollJitterMs)}.\n`, ); await wakeDependencies.sleep(nextDelayMs); } if ( captureIdentity && !captureIdentity.assistantResponse && (downloadTargets.length > 0 || (!snapshotIndicatesBusy(snapshot) && assistantSnapshotLooksTerminal(snapshot))) ) { captureIdentity = completeThreadCaptureIdentity(captureIdentity, snapshot); if (options.captureMetadataPath) { await wakeDependencies.writeCaptureIdentity(options.captureMetadataPath, captureIdentity); } wakeDependencies.log('Persisted the exact completed assistant response and artifact identity.\n'); } await writeWakeStatus('downloading'); const declaredArtifactFailure = declaredPatchArtifactCaptureFailure( extractAssistantResponseForWake(snapshot).text, downloadTargets.map((target) => target.label).filter((label) => label.length > 0), ); if (declaredArtifactFailure) { throw new Error(declaredArtifactFailure); } for (const target of downloadTargets) { let downloadedFile: string | null = null; let lastDownloadErrorMessage = ''; const targetLabel = target.label || `artifact #${target.artifactIndex}`; try { for (let attemptIndex = 1; attemptIndex <= DEFAULT_DOWNLOAD_ATTEMPTS; attemptIndex += 1) { try { downloadedFile = await wakeDependencies.downloadThreadAttachment( browserEndpoint, options.chatUrl, target.label, downloadDir, downloadTimeoutMs, { artifactIndex: target.artifactIndex, artifactIndexInAssistantTurn: target.artifactIndexInAssistantTurn, assistantTurnId: target.assistantTurnId, assistantTurnIndex: target.assistantTurnIndex, href: target.href, }, { captureIdentity, onTargetLease: rememberTargetLease, targetLifecycle: 'keep', }, ); break; } catch (error) { lastDownloadErrorMessage = error instanceof Error ? error.message : String(error); if (attemptIndex >= DEFAULT_DOWNLOAD_ATTEMPTS) { throw error; } wakeDependencies.log( `Assistant artifact download attempt ${attemptIndex} failed for ${JSON.stringify(targetLabel)}: ${lastDownloadErrorMessage}. Retrying.\n`, ); await wakeDependencies.sleep(DOWNLOAD_RETRY_DELAY_MS); } } if (!downloadedFile) { throw new Error(lastDownloadErrorMessage || 'Download click produced no file'); } downloadedArtifacts.push(downloadedFile); downloadedPatches.push(downloadedFile); wakeDependencies.log( `Downloaded assistant artifact ${JSON.stringify(target.label || `artifact #${target.artifactIndex}`)} to ${formatPathForDisplay(downloadedFile, resolvedRepoDir)}.\n`, ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); downloadErrors.push(`${targetLabel}: ${errorMessage}`); wakeDependencies.log(`Assistant artifact download failed for ${JSON.stringify(targetLabel)}: ${errorMessage}.\n`); } await writeWakeStatus('downloading'); } handoffKind = downloadedArtifacts.length > 0 ? 'artifact' : assistantResponseTextLength > 0 ? 'text' : 'none'; await wakeDependencies.writeFile( replayCommandsPath, buildWakeReplayCommands({ downloadTargets, browserEndpoint, captureMetadataPath: options.captureMetadataPath ? path.relative(resolvedRepoDir, options.captureMetadataPath) || '.' : undefined, chatUrl: options.chatUrl, downloadDir, exportPath, }), 'utf8', ); if (recursive) { await wakeDependencies.writeFile( recursive.followupScriptPath, buildRecursiveFollowupScript({ chatUrl: options.chatUrl, fullAuto: options.fullAuto, pollIntervalMs, pollJitterMs, recursivePrompt: options.recursivePrompt, pollTimeoutMs, pollUntilComplete, recursive, repoDir: resolvedRepoDir, tabLifecycle: options.tabLifecycle, }), 'utf8', ); } if (downloadErrors.length > 0) { throw new Error( `Assistant artifact download failed: ${downloadErrors.join(' | ')}. The exact validated thread target was retained for retry.`, ); } const shouldCloseHarvestedTarget = options.tabLifecycle === 'close-harvested' && downloadErrors.length === 0 && (downloadTargets.length > 0 || assistantSnapshotLooksTerminal(snapshot)); if (shouldCloseHarvestedTarget) { try { const exactTargetId = captureIdentity?.targetId || currentTargetId || undefined; const closed = await wakeDependencies.closeThreadTarget( browserEndpoint, options.chatUrl, exactTargetId, ); if (closed && exactTargetId) { createdTargetIds.delete(exactTargetId); rehydratedTargetIds.delete(exactTargetId); validatedRehydratedTargetIds.delete(exactTargetId); if (currentTargetId === exactTargetId) { currentTargetId = ''; } } wakeDependencies.log( closed ? 'Closed the harvested ChatGPT thread tab.\n' : 'The response was harvested, but no matching ChatGPT thread tab remained to close.\n', ); } catch (error) { const message = error instanceof Error ? error.message : String(error); wakeDependencies.log(`The response was harvested, but its ChatGPT thread tab could not be closed: ${message}.\n`); } } else if (options.tabLifecycle === 'close-created') { await closeExactTargets(createdTargetIds, 'the wake-created'); } if (options.skipResume) { await writeWakeStatus('succeeded'); return { attemptCount, assistantResponseMetaPath, assistantResponsePath, assistantResponseSource, assistantResponseTextLength, childRolloutPath, completionStatus, codexBin: resolvedCodexBin, downloadErrors, downloadedArtifacts, downloadedPatches, exportPath, handoffKind, launcherPid, outputDir: resolvedOutputDir, recursive, replayCommandsPath, repoDir: resolvedRepoDir, statusPath, }; } if (!resolvedCodexHome || !options.sessionId) { throw new Error('Resolved Codex home and session ID are required before starting the child Codex run.'); } eventsPath = path.join(resolvedOutputDir, 'child-events.jsonl'); resumeOutputPath = path.join(resolvedOutputDir, 'child-last-message.txt'); stderrPath = path.join(resolvedOutputDir, 'child-stderr.log'); const childArgs = ['exec', '--json', '--output-last-message', resumeOutputPath, '-C', resolvedRepoDir]; if (options.fullAuto === true) { childArgs.push('--full-auto'); } const followupPrompt = buildWakeFollowupPrompt({ assistantResponsePath, assistantResponseTextLength, artifactLabels, chatUrl: options.chatUrl, downloadErrors, downloadedArtifacts, exportPath, replayCommandsPath, recursive, resumePrompt: options.resumePrompt, repoDir: resolvedRepoDir, }); childArgs.push(followupPrompt); await writeWakeStatus('spawning'); await writeWakeStatus('running'); const childLaunch = (await wakeDependencies.runCodexChildSession( resolvedCodexBin ?? 'codex', childArgs, { codexHome: resolvedCodexHome.homePath, cwd: resolvedRepoDir, env: { ...process.env, CODEX_HOME: resolvedCodexHome.homePath, }, eventsPath, resumeOutputPath, stderrPath, }, )) ?? {}; childSessionPersistence = childLaunch.childSessionPersistence; childSessionId = childLaunch.childSessionId; childRolloutPath = childLaunch.childRolloutPath; eventsPath = childLaunch.eventsPath ?? eventsPath; launcherPid = childLaunch.launcherPid; resumeOutputPath = childLaunch.resumeOutputPath ?? resumeOutputPath; stderrPath = childLaunch.stderrPath ?? stderrPath; wakeDependencies.log( `Wake child launch verified${childSessionId ? ` with child session ${childSessionId}` : ''}${launcherPid ? ` (launcher pid ${launcherPid})` : ''}${eventsPath ? `, events at ${formatPathForDisplay(eventsPath, resolvedRepoDir)}` : ''}${stderrPath ? `, stderr at ${formatPathForDisplay(stderrPath, resolvedRepoDir)}` : ''}.\n`, ); if (childSessionId && childSessionPersistence === 'pending') { wakeDependencies.log( `Wake child session ${childSessionId} started before session-home evidence was discoverable; persistence is still pending.\n`, ); } await writeWakeStatus('succeeded'); return { attemptCount, assistantResponseMetaPath, assistantResponsePath, assistantResponseSource, assistantResponseTextLength, childSessionPersistence, childSessionId, childRolloutPath, completionStatus, codexBin: resolvedCodexBin, codexHome: resolvedCodexHome.homePath, downloadErrors, downloadedArtifacts, downloadedPatches, eventsPath, exportPath, handoffKind, launcherPid, outputDir: resolvedOutputDir, recursive, replayCommandsPath, repoDir: resolvedRepoDir, resumeOutputPath, sessionId: options.sessionId, stderrPath, statusPath, }; } catch (error) { if (options.tabLifecycle === 'close-created' || options.tabLifecycle === 'close-harvested') { const failedCreatedTargetIds = new Set( [...createdTargetIds].filter((targetId) => !validatedRehydratedTargetIds.has(targetId)), ); await closeExactTargets(failedCreatedTargetIds, 'the failed wake-created'); } await writeWakeStatus('failed', { lastError: error instanceof Error ? error.message : String(error), }); throw error; } }