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
10 changes: 6 additions & 4 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ async function executeTaskRun(request) {
rendered,
summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)),
jobTitle: taskMetadata.title,
jobClass: "task",
jobClass: taskMetadata.jobClass,
write: Boolean(request.write)
};
}
Expand All @@ -541,15 +541,17 @@ function buildTaskRunMetadata({ prompt, resumeLast = false }) {
if (!resumeLast && String(prompt ?? "").includes(STOP_REVIEW_TASK_MARKER)) {
return {
title: "Codex Stop Gate Review",
summary: "Stop-gate review of previous Claude turn"
summary: "Stop-gate review of previous Claude turn",
jobClass: "stop-review"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid classifying user prompts by marker text

When a normal /codex:rescue prompt happens to quote Run a stop-gate review of the previous Claude turn.—for example, while editing or discussing the gate prompt—it is now stored with jobClass: "stop-review". Both findLatestResumableTaskJob and the active-task check only consider jobClass === "task", so --resume-last and task-resume-candidate silently omit that user's rescue thread. Pass an explicit internal stop-review flag from the hook instead of deriving resumability from user-controlled prompt content.

Useful? React with 👍 / 👎.

};
}

const title = resumeLast ? "Codex Resume" : "Codex Task";
const fallbackSummary = resumeLast ? DEFAULT_CONTINUE_PROMPT : "Task";
return {
title,
summary: shorten(prompt || fallbackSummary)
summary: shorten(prompt || fallbackSummary),
jobClass: "task"
};
}

Expand Down Expand Up @@ -595,7 +597,7 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) {
kind: "task",
title: taskMetadata.title,
workspaceRoot,
jobClass: "task",
jobClass: taskMetadata.jobClass,
summary: taskMetadata.summary,
write
});
Expand Down
3 changes: 3 additions & 0 deletions plugins/codex/scripts/lib/app-server-protocol.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type {
ReviewStartResponse,
ReviewTarget,
Thread,
ThreadArchiveParams,
ThreadArchiveResponse,
ThreadItem,
ThreadListParams,
ThreadListResponse,
Expand Down Expand Up @@ -61,6 +63,7 @@ export interface AppServerMethodMap {
"externalAgentConfig/import": { params: ExternalAgentConfigImportParams; result: ExternalAgentConfigImportResponse };
"thread/start": { params: ThreadStartParams; result: ThreadStartResponse };
"thread/resume": { params: ThreadResumeParams; result: ThreadResumeResponse };
"thread/archive": { params: ThreadArchiveParams; result: ThreadArchiveResponse };
"thread/name/set": { params: ThreadSetNameParams; result: ThreadSetNameResponse };
"thread/list": { params: ThreadListParams; result: ThreadListResponse };
"review/start": { params: ReviewStartParams; result: ReviewStartResponse };
Expand Down
69 changes: 69 additions & 0 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,75 @@ export async function interruptAppServerTurn(cwd, { threadId, turnId }) {
}
}

export async function archiveAppServerThread(cwd, { threadId }) {
if (!threadId) {
return {
attempted: false,
archived: false,
transport: null,
detail: "missing threadId"
};
}

const availability = getCodexAvailability(cwd);
if (!availability.available) {
return {
attempted: false,
archived: false,
transport: null,
detail: availability.detail
};
}

try {
const result = await withAppServer(cwd, async (client) => {
try {
await client.request("thread/archive", { threadId });
return {
archived: true,
transport: client.transport,
detail: `Archived ${threadId}.`
};
} catch (archiveError) {
let cursor = null;
try {
do {
const response = await client.request("thread/list", {
archived: true,
cursor,
limit: 100,
sortKey: "updated_at",
sourceKinds: ["appServer"]
});
if (response.data.some((thread) => thread.id === threadId)) {
return {
archived: true,
transport: client.transport,
detail: `${threadId} was already archived.`
};
}
cursor = response.nextCursor ?? null;
} while (cursor);
} catch {
// Preserve the original archive error so broker failures can retry directly.
}
throw archiveError;
}
});
return {
attempted: true,
...result
};
} catch (error) {
return {
attempted: true,
archived: false,
transport: null,
detail: error instanceof Error ? error.message : String(error)
};
}
}

export async function runAppServerReview(cwd, options = {}) {
const availability = getCodexAvailability(cwd);
if (!availability.available) {
Expand Down
32 changes: 22 additions & 10 deletions plugins/codex/scripts/stop-review-gate-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

import { getCodexAvailability } from "./lib/codex.mjs";
import { archiveAppServerThread, getCodexAvailability } from "./lib/codex.mjs";
import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs";
import { getConfig, listJobs } from "./lib/state.mjs";
import { sortJobsNewestFirst } from "./lib/job-control.mjs";
Expand Down Expand Up @@ -71,31 +71,34 @@ function parseStopReviewOutput(rawOutput) {
if (!text) {
return {
ok: false,
recognized: false,
reason:
"The stop-time Codex review task returned no final output. Run /codex:review --wait manually or bypass the gate."
};
}

const firstLine = text.split(/\r?\n/, 1)[0].trim();
if (firstLine.startsWith("ALLOW:")) {
return { ok: true, reason: null };
return { ok: true, recognized: true, reason: null };
}
if (firstLine.startsWith("BLOCK:")) {
const reason = firstLine.slice("BLOCK:".length).trim() || text;
return {
ok: false,
recognized: true,
reason: `Codex stop-time review found issues that still need fixes before ending the session: ${reason}`
};
}

return {
ok: false,
recognized: false,
reason:
"The stop-time Codex review task returned an unexpected answer. Run /codex:review --wait manually or bypass the gate."
};
}

function runStopReview(cwd, input = {}) {
async function runStopReview(cwd, input = {}) {
const scriptPath = path.join(SCRIPT_DIR, "codex-companion.mjs");
const prompt = buildStopReviewPrompt(input);
const childEnv = {
Expand Down Expand Up @@ -129,7 +132,18 @@ function runStopReview(cwd, input = {}) {

try {
const payload = JSON.parse(result.stdout);
return parseStopReviewOutput(payload?.rawOutput);
const review = parseStopReviewOutput(payload?.rawOutput);
if (review.recognized && payload?.threadId) {
const archive = await archiveAppServerThread(cwd, { threadId: payload.threadId });
if (!archive.archived) {
logNote(
`Codex stop-time review completed, but its thread could not be archived${
archive.detail ? `: ${archive.detail}` : "."
}`
);
}
}
return review;
} catch {
return {
ok: false,
Expand All @@ -139,7 +153,7 @@ function runStopReview(cwd, input = {}) {
}
}

function main() {
async function main() {
const input = readHookInput();
const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
const workspaceRoot = resolveWorkspaceRoot(cwd);
Expand All @@ -163,7 +177,7 @@ function main() {
return;
}

const review = runStopReview(cwd, input);
const review = await runStopReview(cwd, input);
if (!review.ok) {
emitDecision({
decision: "block",
Expand All @@ -175,10 +189,8 @@ function main() {
logNote(runningTaskNote);
}

try {
main();
} catch (error) {
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
}
});
37 changes: 37 additions & 0 deletions tests/fake-codex-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ function nextThread(state, cwd, ephemeral) {
name: null,
preview: "",
ephemeral: Boolean(ephemeral),
archived: false,
createdAt: now(),
updatedAt: now()
};
Expand Down Expand Up @@ -234,6 +235,9 @@ function structuredReviewPayload(prompt) {

function taskPayload(prompt, resume) {
if (prompt.includes("<task>") && prompt.includes("Only review the work from the previous Claude turn.")) {
if (BEHAVIOR === "stop-unexpected-output") {
return "MAYBE: Review completed without a valid gate decision.";
}
if (BEHAVIOR === "adversarial-clean") {
return "ALLOW: No blocking issues found in the previous turn.";
}
Expand Down Expand Up @@ -329,6 +333,9 @@ rl.on("line", (line) => {

case "thread/list": {
let threads = state.threads.slice();
threads = threads.filter((thread) =>
message.params.archived === true ? thread.archived === true : thread.archived !== true
);
if (message.params.cwd) {
threads = threads.filter((thread) => thread.cwd === message.params.cwd);
}
Expand All @@ -340,6 +347,30 @@ rl.on("line", (line) => {
break;
}

case "thread/archive": {
const thread = ensureThread(state, message.params.threadId);
if (BEHAVIOR === "archive-fails") {
send({
id: message.id,
error: { code: -32600, message: "archive unavailable for " + thread.id }
});
break;
}
thread.archived = true;
thread.updatedAt = now();
saveState(state);
if (BEHAVIOR === "archive-already-reported") {
send({
id: message.id,
error: { code: -32600, message: "no rollout found for thread id " + thread.id }
});
break;
}
send({ id: message.id, result: {} });
send({ method: "thread/archived", params: { threadId: thread.id } });
break;
}

case "thread/resume": {
if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) {
throw new Error("thread/resume.persistFullHistory requires experimentalApi capability");
Expand Down Expand Up @@ -458,6 +489,12 @@ rl.on("line", (line) => {
? structuredReviewPayload(prompt)
: taskPayload(prompt, thread.name && thread.name.startsWith("Codex Companion Task") && prompt.includes("Continue from the current thread state"));

if (BEHAVIOR === "stop-turn-fails" && prompt.includes("Only review the work from the previous Claude turn.")) {
send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } });
send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "failed") } });
break;
}

if (
BEHAVIOR === "with-subagent" ||
BEHAVIOR === "with-late-subagent-message" ||
Expand Down
Loading