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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ apps/pi-extension/review-core.ts
# Claude Code session-local runtime state (lock files, scheduled-task state).
# Machine-specific; never belongs in the repo.
.claude/
.playwright-cli/
*.ntvs*
*.njsproj
*.sln
Expand Down
57 changes: 56 additions & 1 deletion apps/codex/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Plannotator for Codex

Code review and markdown annotation are supported today. Plan mode is not yet supported — it requires hooks to intercept the agent's plan submission, which Codex does not currently expose.
Code review, markdown annotation, and plan review are supported in Codex.

Plan review uses Codex's experimental `Stop` hook. This is a post-render review flow: when a turn stops, Plannotator reads the current rollout transcript, extracts the latest plan, and opens the normal plan review UI. If you deny the plan, Plannotator returns continuation feedback so Codex revises the plan in the same turn.

## Install

Expand All @@ -16,8 +18,61 @@ curl -fsSL https://plannotator.ai/install.sh | bash
irm https://plannotator.ai/install.ps1 | iex
```

## Enable Codex hooks

Codex hooks are currently experimental and require a feature flag.

Add this to `~/.codex/config.toml` or `<repo>/.codex/config.toml`:

```toml
[features]
codex_hooks = true
```

Then create `~/.codex/hooks.json` or `<repo>/.codex/hooks.json`:

```json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "plannotator",
"timeout": 345600
}
]
}
]
}
}
```

Notes:

- Codex loads `hooks.json` next to active config layers, so either the global `~/.codex` or repo-local `.codex` location works.
- This currently depends on Codex hooks, which are experimental and disabled on Windows in the current official docs.
- Because this uses `Stop`, the review happens after Codex renders the plan turn, not at a dedicated `ExitPlanMode` interception point.

## Usage

### Plan Review

Once hooks are enabled, plan review opens automatically whenever a Codex turn ends with a plan. Approving keeps the turn completed. Sending feedback returns a `Stop` continuation reason so Codex revises the plan and Plannotator shows version history and diffs across revisions.

### Local End-to-End Harness

From the repo root, you can run a disposable local E2E flow against a real Codex session:

```bash
./tests/manual/local/test-codex-plan-review-e2e.sh --keep
```

This uses a temporary `HOME`, sample git repo, repo-local Codex CLI, and repo-local `plannotator` wrapper so it
doesn't modify your installed Codex or Plannotator state. If you want to automate the opened review UI with Playwright,
set `PLANNOTATOR_BROWSER=/usr/bin/true` before running the script.

### Code Review

Run `!plannotator review` to open the code review UI for your current changes:
Expand Down
201 changes: 198 additions & 3 deletions apps/hook/server/codex-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
* Uses synthetic JSONL fixtures matching the real Codex rollout format.
*/

import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { describe, expect, test, afterEach } from "bun:test";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getLastCodexMessage } from "./codex-session";
import { getLastCodexMessage, getLatestCodexPlan } from "./codex-session";

// --- Fixture Helpers ---

Expand Down Expand Up @@ -86,6 +86,57 @@ function eventMsg(type: string): string {
});
}

function turnStarted(turnId: string): string {
return JSON.stringify({
timestamp: new Date().toISOString(),
type: "event_msg",
payload: {
type: "task_started",
turn_id: turnId,
},
});
}

function turnCompleted(turnId: string): string {
return JSON.stringify({
timestamp: new Date().toISOString(),
type: "event_msg",
payload: {
type: "task_complete",
turn_id: turnId,
},
});
}

function completedPlanItem(text: string, turnId: string): string {
return JSON.stringify({
timestamp: new Date().toISOString(),
type: "event_msg",
payload: {
type: "item_completed",
turn_id: turnId,
item: {
type: "Plan",
id: `plan_${crypto.randomUUID().slice(0, 12)}`,
text,
},
},
});
}

function hookPrompt(text: string): string {
return rolloutLine("response_item", {
type: "message",
role: "user",
content: [
{
type: "input_text",
text: `<hook_prompt hook_run_id="${crypto.randomUUID()}">${text}</hook_prompt>`,
},
],
});
}

function buildRollout(...lines: string[]): string {
return lines.join("\n");
}
Expand Down Expand Up @@ -243,3 +294,147 @@ describe("getLastCodexMessage", () => {
expect(result!.text).toBe("Valid message");
});
});

describe("getLatestCodexPlan", () => {
test("prefers the latest persisted plan item for the current turn", () => {
const turnId = "turn-plan-item";
const path = writeTempRollout(
buildRollout(
sessionMeta(),
turnStarted(turnId),
assistantMessage("<proposed_plan>\nFallback text\n</proposed_plan>"),
completedPlanItem("Authoritative plan item", turnId)
)
);

const result = getLatestCodexPlan(path, { turnId });
expect(result).toEqual({
text: "Authoritative plan item",
source: "plan-item",
});
});

test("falls back to raw proposed_plan blocks for plan-only assistant replies", () => {
const turnId = "turn-plan-only";
const path = writeTempRollout(
buildRollout(
sessionMeta(),
turnStarted(turnId),
assistantMessage("<proposed_plan>\n- First\n- Second\n</proposed_plan>")
)
);

const result = getLatestCodexPlan(path, { turnId });
expect(result).toEqual({
text: "- First\n- Second",
source: "assistant-message",
});
});

test("extracts plan blocks surrounded by assistant prose", () => {
const turnId = "turn-prose";
const path = writeTempRollout(
buildRollout(
sessionMeta(),
turnStarted(turnId),
assistantMessage(
[
"Here is the plan I recommend.",
"",
"<proposed_plan>",
"1. Inspect hook payloads",
"2. Launch Plannotator",
"</proposed_plan>",
"",
"I can revise it if needed.",
].join("\n")
)
)
);

const result = getLatestCodexPlan(path, { turnId });
expect(result).toEqual({
text: "1. Inspect hook payloads\n2. Launch Plannotator",
source: "assistant-message",
});
});

test("ignores plans from older turns when the current turn has none", () => {
const oldTurnId = "turn-old";
const currentTurnId = "turn-current";
const path = writeTempRollout(
buildRollout(
sessionMeta(),
turnStarted(oldTurnId),
completedPlanItem("Old plan", oldTurnId),
turnCompleted(oldTurnId),
turnStarted(currentTurnId),
assistantMessage("Just answering a regular question.")
)
);

const result = getLatestCodexPlan(path, { turnId: currentTurnId });
expect(result).toBeNull();
});

test("returns null when Stop re-entry has no revised plan after the hook prompt", () => {
const turnId = "turn-stop-no-revision";
const path = writeTempRollout(
buildRollout(
sessionMeta(),
turnStarted(turnId),
completedPlanItem("Original plan", turnId),
hookPrompt("Please revise the plan."),
assistantMessage("I will think through the feedback.")
)
);

const result = getLatestCodexPlan(path, {
turnId,
stopHookActive: true,
});
expect(result).toBeNull();
});

test("returns null when Stop re-entry repeats the same plan", () => {
const turnId = "turn-stop-duplicate";
const path = writeTempRollout(
buildRollout(
sessionMeta(),
turnStarted(turnId),
completedPlanItem("Original plan", turnId),
hookPrompt("Please revise the plan."),
completedPlanItem("Original plan", turnId)
)
);

const result = getLatestCodexPlan(path, {
turnId,
stopHookActive: true,
});
expect(result).toBeNull();
});

test("returns the revised plan after a denied Stop review", () => {
const turnId = "turn-stop-revised";
const path = writeTempRollout(
buildRollout(
sessionMeta(),
turnStarted(turnId),
completedPlanItem("Original plan", turnId),
hookPrompt("Please revise the plan."),
assistantMessage("<proposed_plan>\nRevised fallback plan\n</proposed_plan>"),
completedPlanItem("Revised authoritative plan", turnId)
)
);

const result = getLatestCodexPlan(path, {
turnId,
stopHookActive: true,
});
expect(result).toEqual({
text: "Revised authoritative plan",
source: "plan-item",
});
});
});
Loading