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
138 changes: 130 additions & 8 deletions src/host/codex-host.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const codexSpawner = createCodexSpawner({ hostDir: HOST_DIR, nodePath: process.e

// Bumped on every change the panel needs to know about. Reported in
// `agentReady`. Claude's own HOST_VERSION is separate and untouched.
const CODEX_HOST_VERSION = 8;
const CODEX_HOST_VERSION = 9;

// The browser bridge numbers its requests from here so the router can tell our
// `browserResult` replies from claude's by value alone, and never has to parse
Expand Down Expand Up @@ -666,6 +666,9 @@ function makeSession(id, cwd) {
// is up, rather than being answered with "this session isn't running".
opening: true,
pending: [],
rewinding: false,
rewindStop: null,
discardedTurns: new Set(),
};
}

Expand Down Expand Up @@ -880,6 +883,15 @@ function sessionFor(params) {

function handleNotification(method, params) {
const s = sessionFor(params);
const eventTurnId = params?.turnId || params?.turn?.id;
if (s?.discardedTurns.has(eventTurnId)) return;
if (s?.rewinding) {
if (method === "turn/completed" && params.turn?.id === s.turnId) {
s.running = false;
s.rewindStop?.resolve();
}
return;
}
if (s && s.running) touchTurn(s);
if (TRACE && method !== "item/agentMessage/delta" && !method.startsWith("mcpServer/")) {
const it = params && params.item;
Expand Down Expand Up @@ -1223,6 +1235,7 @@ function endTurnWith(s, isError, message) {

function handleServerRequest(reqId, method, params) {
const s = sessionFor(params);
if (s?.rewinding || s?.discardedTurns.has(params?.turnId)) { rpcReplyError(reqId, "turn is being edited"); return; }
if (s) touchTurn(s);
if (!s) {
// No chat owns this thread — refuse rather than leave Codex waiting.
Expand Down Expand Up @@ -1661,7 +1674,8 @@ async function sendPrompt(msg) {
return;
}

const input = [];
if (s.rewinding) return;
const input = msg.rewindInput ? [...msg.rewindInput] : [];
const text = String(msg.text || "");
if (text) input.push({ type: "text", text });
for (const img of msg.images || []) {
Expand Down Expand Up @@ -1696,6 +1710,7 @@ async function sendPrompt(msg) {
...turnProfile(s.mode),
});
s.turnId = (res && res.turn && res.turn.id) || null;
if (msg.messageId && s.turnId) send({ type: "promptAccepted", id: s.id, messageId: msg.messageId, turnId: s.turnId });
s.running = true;
touchTurn(s);
} catch (err) {
Expand Down Expand Up @@ -1730,6 +1745,7 @@ async function interrupt(msg) {
function closeSession(id, opts) {
const s = sessions.get(id);
if (!s) return;
s.rewindStop?.reject(new Error("The chat was closed."));
cancelBrowserWorkflows(s.browserSession);
if (s.silenceTimer) clearTimeout(s.silenceTimer);
for (const reqId of s.asks.keys()) {
Expand All @@ -1747,6 +1763,108 @@ function closeSession(id, opts) {
if (!opts || !opts.quiet) send({ type: "exit", id, code: 0 });
}

// Stop before changing stored history. An interrupt reply alone does not mean
// the turn has finished writing its last items.
async function stopForRewind(s) {
if (!s.running) return;
if (!s.turnId) throw new Error("Wait for ChatGPT to start, then try again.");
let timer;
const stopped = new Promise((resolve, reject) => {
s.rewindStop = { resolve, reject };
timer = setTimeout(() => reject(new Error("Couldn't confirm that ChatGPT stopped. Try again once it finishes.")), 20000);
});
try {
await Promise.all([rpc("turn/interrupt", { threadId: s.threadId, turnId: s.turnId }, 15000), stopped]);
} finally {
clearTimeout(timer);
s.rewindStop = null;
}
}

async function rewindSession(msg) {
const s = sessions.get(msg.id);
const reply = (extra) => send({ type: "rewindResult", id: msg.id, requestId: msg.requestId, ...extra });
if (!s?.threadId || s.opening || s.rewinding || !msg.turnId || typeof msg.text !== "string") {
reply({ ok: false, running: !!s?.running, error: "This chat isn't ready to edit. Wait for it to open, then try again." });
return;
}
s.rewinding = true;
if (s.silenceTimer) { clearTimeout(s.silenceTimer); s.silenceTimer = null; }
try {
cancelBrowserWorkflows(s.browserSession);
await stopForRewind(s);
if (sessions.get(s.id) !== s) throw new Error("The session changed. Reopen the chat before editing again.");
// Count from the end using stable turn IDs, including unloaded history.
// A panel's visible message index is not a Codex turn index.
let cursor = null, target, removed = [];
const cursors = new Set();
do {
const page = await transcriptPage({ sessionId: s.threadId, cursor });
for (const turn of page.turns.slice().reverse()) {
removed.push(turn.id);
if (turn.id === msg.turnId) { target = turn; break; }
}
if (target) break;
cursor = page.nextCursor;
const key = JSON.stringify(cursor);
if (cursor && cursors.has(key)) throw new Error("Couldn't read the chat history. Reopen the chat and try again.");
cursors.add(key);
} while (cursor);
if (!target) throw new Error("This message is no longer in the chat. Reopen the chat and try again.");
const users = (target.items || []).filter((item) => item.type === "userMessage");
const original = users[0];
if (!original || (msg.itemId && original.id !== msg.itemId)) {
throw new Error("Edit the first message in this turn to restart it.");
}
// Keep the original page/file context and attachments, also after reload.
const originalText = (original.content || []).filter((part) => part.type === "text").map((part) => part.text).join("\n");
const context = (originalText.match(/\u200b{3}[\s\S]*?\u200c{3}\n*/g) || []).join("");
const input = (original.content || []).filter((part) => part.type !== "text");
if (!msg.text.trim() && !input.length) throw new Error("Enter a message before sending.");
if (sessions.get(s.id) !== s) throw new Error("The session changed. Reopen the chat before editing again.");
const rollback = await rpc("thread/rollback", { threadId: s.threadId, numTurns: removed.length }, 60000);
if (sessions.get(s.id) !== s) throw new Error("The session changed. Reopen the chat before editing again.");
for (const id of removed) s.discardedTurns.add(id);
for (const reqId of s.asks.keys()) {
send({ type: "permissionCancel", id: s.id, requestId: reqId });
rpcReplyError(reqId, "turn was edited");
}
s.asks.clear();
s.openTools.clear();
s.execOut.clear();
s.streamMsgId = null;
s.streamText = "";
s.planToolId = null;
s.turnId = null;
s.usage = null;
s.running = false;
// The panel only removes old rows after the server confirms the rollback.
reply({ ok: true, previousTurnId: rollback?.thread?.turns?.at(-1)?.id || null });
s.rewinding = false;
await sendPrompt({ id: s.id, text: context + msg.text, rewindInput: input, messageId: msg.requestId });
} catch (err) {
reply({ ok: false, running: s.running, error: `Couldn't edit this message: ${err?.message || err}` });
} finally {
s.rewinding = false;
if (s.running) touchTurn(s);
else {
// A failed rollback can still follow a successful stop. Retire requests
// and stream state from that stopped turn before another prompt arrives.
for (const reqId of s.asks.keys()) {
send({ type: "permissionCancel", id: s.id, requestId: reqId });
rpcReplyError(reqId, "turn ended");
}
s.asks.clear();
s.openTools.clear();
s.execOut.clear();
s.streamMsgId = null;
s.streamText = "";
s.planToolId = null;
s.turnId = null;
}
}
}

async function restartSession(msg) {
const s = sessions.get(msg.id);
if (!s) return;
Expand Down Expand Up @@ -1792,8 +1910,9 @@ async function transcriptPage(msg) {
}
const res = await rpc("thread/read", { threadId, includeTurns: true }, 60000);
const turns = res?.thread?.turns || [];
const end = msg.cursor?.kind === "legacy" ? turns.findIndex((t) => t.id === msg.cursor.before) : turns.length;
if (end < 0) throw new Error("History changed. Reopen the chat to reload it.");
const boundary = msg.cursor?.kind === "legacy" ? turns.findIndex((t) => t.id === (msg.cursor.through || msg.cursor.before)) : turns.length;
if (boundary < 0) throw new Error("History changed. Reopen the chat to reload it.");
const end = msg.cursor?.through ? boundary + 1 : boundary;
const start = Math.max(0, end - 5);
return { turns: turns.slice(start, end), nextCursor: start > 0 ? { kind: "legacy", before: turns[start].id } : null };
}
Expand Down Expand Up @@ -1829,7 +1948,7 @@ async function loadTranscript(msg) {
for (const turn of page.turns) {
for (const item of turn.items || []) {
const replay = replayItem(s, item);
if (replay) events.push(...replay.map((event) => ({ ...event, historyItemId: item.id,
if (replay) events.push(...replay.map((event) => ({ ...event, historyItemId: item.id, historyTurnId: turn.id,
timestamp: turn.startedAt ? new Date(turn.startedAt * 1000).toISOString() : undefined })));
}
}
Expand All @@ -1846,8 +1965,11 @@ function replayItem(s, item) {
switch (item.type) {
case "userMessage": {
const text = (item.content || []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
if (!text) return null;
return [{ type: "user", message: { role: "user", content: [{ type: "text", text }] } }];
const hasAttachments = (item.content || []).some((part) => part.type !== "text");
if (!text && !hasAttachments) return null;
const attachments = (item.content || []).filter((part) => part.type === "image" && /^data:image\//.test(part.url || ""))
.map((part) => ({ dataUrl: part.url, mediaType: part.url.slice(5).split(";")[0] }));
return [{ type: "user", hasAttachments, attachments, message: { role: "user", content: [{ type: "text", text }] } }];
}
case "agentMessage":
if (!item.text) return null;
Expand Down Expand Up @@ -1895,7 +2017,7 @@ function handle(msg) {
case "listSkills":
startAppServer().then(() => loadSkills({ id: msg.id, cwd: msg.cwd || homedir() })).catch((err) => send({ type: "commands", id: msg.id, agent: "codex", cwd: msg.cwd, list: [], skills: [], error: err.message }));
break;
case "rewind": send({ type: "error", id: msg.id, message: "Editing past messages is not supported in ChatGPT chats." }); break;
case "rewind": rewindSession(msg); break;
case "remoteControl": send({ type: "remoteControl", id: msg.id, ok: false, error: "Remote Control is only available in Claude Code chats." }); break;
case "authCode": send({ type: "authDone", id: msg.id, ok: false, message: "Complete sign-in on the ChatGPT sign-in page." }); break;
case "authLogin": startLogin(msg.id); break;
Expand Down
Loading