Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
21ddb9a
feat: add /plannotator-last command to annotate last assistant message
backnotprop Mar 17, 2026
825d820
chore: remove 3 redundant real-world scenario tests
backnotprop Mar 17, 2026
3621587
feat: add /plannotator-last command to Pi extension
backnotprop Mar 17, 2026
9e77858
feat: add /plannotator-last to OpenCode plugin + extract command hand…
backnotprop Mar 18, 2026
94ca058
feat: context-aware UI labels for annotate-last mode
backnotprop Mar 18, 2026
8395860
feat: add Codex support to annotate-last command
backnotprop Mar 18, 2026
ecedf8f
fix: context-aware feedback title + top spacing for paragraph-first c…
backnotprop Mar 18, 2026
9f2dd32
chore: add sandbox scripts for Pi and Codex testing
backnotprop Mar 18, 2026
48ed7d2
fix: add hook build step to opencode sandbox script
backnotprop Mar 18, 2026
a757869
fix: remove command body from plannotator-last to prevent agent response
backnotprop Mar 18, 2026
1cbfb54
fix: use command.execute.before hook for OpenCode annotate-last
backnotprop Mar 18, 2026
66a570e
fix: add Codex to origin type and agent name mapping
backnotprop Mar 18, 2026
8d54a78
fix: remote share link, plan-specific prose, and codex type unions
backnotprop Mar 18, 2026
792c435
merge: resolve conflict with main, adopt runGitDiffWithContext
backnotprop Mar 18, 2026
fb4fbfb
fix: correct JSDoc for projectSlugFromCwd (leading dash is kept, not …
backnotprop Mar 18, 2026
e24ecc6
Merge remote-tracking branch 'origin/main' into feat/annotate-last
backnotprop Mar 18, 2026
e5f8c30
refactor: use RenderedMessage type instead of inline structural type
backnotprop Mar 18, 2026
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
12 changes: 12 additions & 0 deletions apps/hook/commands/plannotator-last.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
description: Annotate the last rendered assistant message
allowed-tools: Bash(plannotator:*)
---

## Message Annotations

!`plannotator annotate-last`

## Your task

Address the annotation feedback above. The user has reviewed your last message and provided specific annotations and comments.
245 changes: 245 additions & 0 deletions apps/hook/server/codex-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
/**
* Codex Session Parser Tests
*
* Run: bun test apps/hook/server/codex-session.test.ts
*
* 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 { tmpdir } from "node:os";
import { join } from "node:path";
import { getLastCodexMessage } from "./codex-session";

// --- Fixture Helpers ---

function rolloutLine(type: string, payload: Record<string, unknown>): string {
return JSON.stringify({
timestamp: new Date().toISOString(),
type,
payload,
});
}

function assistantMessage(text: string): string {
return rolloutLine("response_item", {
type: "message",
role: "assistant",
content: [{ type: "output_text", text }],
});
}

function userMessage(text: string): string {
return rolloutLine("response_item", {
type: "message",
role: "user",
content: [{ type: "input_text", text }],
});
}

function developerMessage(text: string): string {
return rolloutLine("response_item", {
type: "message",
role: "developer",
content: [{ type: "input_text", text }],
});
}

function functionCall(name: string, args: string): string {
return rolloutLine("response_item", {
type: "function_call",
name,
arguments: args,
call_id: `call_${crypto.randomUUID().slice(0, 12)}`,
});
}

function functionOutput(callId: string, output: string): string {
return rolloutLine("response_item", {
type: "function_call_output",
call_id: callId,
output,
});
}

function sessionMeta(): string {
return rolloutLine("session_meta", {
id: crypto.randomUUID(),
cwd: "/tmp/test",
model_provider: "openai",
});
}

function turnContext(): string {
return rolloutLine("turn_context", {
cwd: "/tmp/test",
model: "o3",
});
}

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

function buildRollout(...lines: string[]): string {
return lines.join("\n");
}

// --- Temp file helpers ---

let tempFiles: string[] = [];

function writeTempRollout(content: string): string {
const dir = mkdtempSync(join(tmpdir(), "plannotator-codex-test-"));
const path = join(dir, "rollout.jsonl");
writeFileSync(path, content);
tempFiles.push(dir);
return path;
}

afterEach(() => {
for (const dir of tempFiles.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});

// --- Tests ---

describe("getLastCodexMessage", () => {
test("finds last assistant message", () => {
const path = writeTempRollout(
buildRollout(
sessionMeta(),
userMessage("Hello"),
assistantMessage("Hi there!"),
userMessage("Thanks"),
assistantMessage("You're welcome.")
)
);
const result = getLastCodexMessage(path);
expect(result).not.toBeNull();
expect(result!.text).toBe("You're welcome.");
});

test("skips function_call entries", () => {
const path = writeTempRollout(
buildRollout(
sessionMeta(),
userMessage("Fix the bug"),
assistantMessage("Let me look into that."),
functionCall("exec_command", '{"cmd":"ls"}'),
functionOutput("call_123", "file1.ts\nfile2.ts"),
assistantMessage("Found the issue.")
)
);
const result = getLastCodexMessage(path);
expect(result).not.toBeNull();
expect(result!.text).toBe("Found the issue.");
});

test("skips developer and user messages", () => {
const path = writeTempRollout(
buildRollout(
sessionMeta(),
developerMessage("System instructions..."),
userMessage("Do something"),
assistantMessage("The actual response"),
developerMessage("More instructions"),
userMessage("Another user message")
)
);
const result = getLastCodexMessage(path);
expect(result).not.toBeNull();
expect(result!.text).toBe("The actual response");
});

test("extracts multiple output_text blocks", () => {
const path = writeTempRollout(
buildRollout(
sessionMeta(),
rolloutLine("response_item", {
type: "message",
role: "assistant",
content: [
{ type: "output_text", text: "First part." },
{ type: "output_text", text: "Second part." },
],
})
)
);
const result = getLastCodexMessage(path);
expect(result).not.toBeNull();
expect(result!.text).toBe("First part.\nSecond part.");
});

test("skips event_msg and turn_context entries", () => {
const path = writeTempRollout(
buildRollout(
sessionMeta(),
turnContext(),
userMessage("Hello"),
assistantMessage("Response here"),
eventMsg("task_started"),
turnContext(),
eventMsg("token_count")
)
);
const result = getLastCodexMessage(path);
expect(result).not.toBeNull();
expect(result!.text).toBe("Response here");
});

test("skips assistant messages with empty text", () => {
const path = writeTempRollout(
buildRollout(
sessionMeta(),
assistantMessage("Good response"),
rolloutLine("response_item", {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: " " }],
})
)
);
const result = getLastCodexMessage(path);
expect(result).not.toBeNull();
expect(result!.text).toBe("Good response");
});

test("returns null when no assistant messages exist", () => {
const path = writeTempRollout(
buildRollout(
sessionMeta(),
developerMessage("Instructions"),
userMessage("Hello"),
functionCall("exec_command", '{"cmd":"pwd"}')
)
);
const result = getLastCodexMessage(path);
expect(result).toBeNull();
});

test("returns null for empty file", () => {
const path = writeTempRollout("");
const result = getLastCodexMessage(path);
expect(result).toBeNull();
});

test("skips malformed JSON lines", () => {
const path = writeTempRollout(
buildRollout(
assistantMessage("Valid message"),
"not valid json",
"{broken"
)
);
const result = getLastCodexMessage(path);
expect(result).not.toBeNull();
expect(result!.text).toBe("Valid message");
});
});
129 changes: 129 additions & 0 deletions apps/hook/server/codex-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Codex Session Parser
*
* Extracts the last rendered assistant message from a Codex rollout file.
* Codex stores sessions at ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl
*
* Detection: Codex injects CODEX_THREAD_ID into every spawned process.
* The thread ID is the UUID in the rollout filename.
*
* Rollout format (JSONL, one object per line):
* {"timestamp":"...","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"..."}]}}
* {"timestamp":"...","type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"...","call_id":"..."}}
*/

import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";

// --- Types ---

interface RolloutEntry {
timestamp?: string;
type: string;
payload?: {
type?: string;
role?: string;
content?: { type: string; text?: string }[];
[key: string]: unknown;
};
}

// --- Rollout File Discovery ---

/**
* Find the Codex rollout file for a given thread ID.
* The thread ID is the UUID portion of the filename:
* rollout-<timestamp>-<uuid>.jsonl
*
* Scans ~/.codex/sessions/ directory tree for a matching file.
*/
export function findCodexRolloutByThreadId(threadId: string): string | null {
const sessionsDir = join(homedir(), ".codex", "sessions");

try {
// Walk YYYY/MM/DD directories in reverse order (most recent first)
const years = readdirSync(sessionsDir).sort().reverse();
for (const year of years) {
const yearDir = join(sessionsDir, year);
if (!isDir(yearDir)) continue;

const months = readdirSync(yearDir).sort().reverse();
for (const month of months) {
const monthDir = join(yearDir, month);
if (!isDir(monthDir)) continue;

const days = readdirSync(monthDir).sort().reverse();
for (const day of days) {
const dayDir = join(monthDir, day);
if (!isDir(dayDir)) continue;

const files = readdirSync(dayDir);
for (const file of files) {
if (file.endsWith(".jsonl") && file.includes(threadId)) {
return join(dayDir, file);
}
}
}
}
}
} catch {
return null;
}

return null;
}

function isDir(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}

// --- Message Extraction ---

/**
* Extract the last assistant message from a Codex rollout file.
*
* Walks backward through the JSONL, finds the last entry where:
* type === "response_item"
* payload.type === "message"
* payload.role === "assistant"
*
* Extracts output_text blocks from payload.content.
*/
export function getLastCodexMessage(
rolloutPath: string
): { text: string } | null {
const content = readFileSync(rolloutPath, "utf-8");
const lines = content.trim().split("\n");

// Walk backward
for (let i = lines.length - 1; i >= 0; i--) {
let entry: RolloutEntry;
try {
entry = JSON.parse(lines[i]);
} catch {
continue;
}

if (entry.type !== "response_item") continue;
if (entry.payload?.type !== "message") continue;
if (entry.payload?.role !== "assistant") continue;

const contentBlocks = entry.payload?.content;
if (!Array.isArray(contentBlocks)) continue;

const textParts = contentBlocks
.filter((b) => b.type === "output_text" && b.text?.trim())
.map((b) => b.text!);

if (textParts.length === 0) continue;

return { text: textParts.join("\n") };
}

return null;
}
Loading