Files
backnotprop__plannotator/apps/opencode-plugin/index.ts
T
Michael Ramos 6b775ea1ed feat: /plannotator-last — annotate the last agent message (#325)
* feat: add /plannotator-last command to annotate last assistant message

Adds a new slash command that extracts the last rendered assistant message
from Claude Code's session log and opens it in the annotation UI.

Session log parser (apps/hook/server/session-log.ts):
- Parses Claude Code JSONL logs at ~/.claude/projects/{slug}/*.jsonl
- Finds the last assistant message.id with text content blocks
- Skips noise entries (progress, system, file-history-snapshot, queue-operation)
- Filters system-generated user messages by prefix to avoid false turn boundaries
- Walks backward through empty turns when back-to-back user messages exist
- No anchoring — reads from end of log since <command-message> isn't written
  until after the binary completes

New files:
- apps/hook/commands/plannotator-last.md — slash command definition
- apps/hook/server/session-log.ts — Claude-Code-specific log parser
- apps/hook/server/session-log.test.ts — 30 tests covering streaming chunks,
  tool call turns, sub-agent noise, stop hooks, thinking blocks, and edge cases

Modified:
- apps/hook/server/index.ts — annotate-last subcommand

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove 3 redundant real-world scenario tests

These duplicated coverage already provided by focused unit tests:
- "full conversation" → covered by "grabs last message.id in multi-tool turn"
- "stop hook interrupted" → covered by "skips progress and system noise"
- "long tool-only sequence" → covered by "skips tool-only assistant entries"

Kept the thinking block test (unique coverage). 27 tests remain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add /plannotator-last command to Pi extension

Uses Pi's session manager API to find the last assistant message —
walks backward through ctx.sessionManager.getEntries(), finds the
last entry with role "assistant" and text content, opens it in the
annotation UI. Reuses existing isAssistantMessage(), getTextContent(),
startAnnotateServer(), and runBrowserReview() from the extension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add /plannotator-last to OpenCode plugin + extract command handlers

Adds annotate-last command that fetches session messages via
client.session.messages(), finds the last assistant message with text
parts, and opens it in the annotation UI.

Refactors command handling: extracts review, annotate, and annotate-last
handlers from the inline event hook into commands.ts module. Reduces
index.ts by ~120 lines and makes adding future commands cleaner.

New files:
- apps/opencode-plugin/commands.ts — extracted command handlers
- apps/opencode-plugin/commands/plannotator-last.md — command metadata

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: context-aware UI labels for annotate-last mode

Adds "annotate-last" mode to the annotate server, passed through to the
UI via /api/plan response. The editor uses this to show "Copy message"
instead of "Copy plan", and "annotations on the message" in the
completion overlay.

- packages/server/annotate.ts: new `mode` option on AnnotateServerOptions
- packages/editor/App.tsx: annotateSource state derived from mode
- packages/ui/components/Viewer.tsx: copyLabel prop for button text
- All three harnesses pass mode: "annotate-last" in their callers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Codex support to annotate-last command

Detects Codex via CODEX_THREAD_ID env var (injected by Codex into every
spawned process). Uses the thread ID to find the rollout file in
~/.codex/sessions/, parses the Codex rollout JSONL format to extract
the last assistant message.

Also adds `plannotator last` alias for shorter usage in Codex bang
commands (!plannotator last).

New files:
- apps/hook/server/codex-session.ts — Codex rollout parser
- apps/hook/server/codex-session.test.ts — 9 tests

Modified:
- apps/hook/server/index.ts — Codex detection + `last` alias

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: context-aware feedback title + top spacing for paragraph-first content

- exportAnnotations now accepts a title param: "Message Feedback" for
  annotate-last, "File Feedback" for file annotation, "Plan Feedback"
  for plan review (default)
- Adds top spacer when content starts with a paragraph (not a heading)
  and has no frontmatter, fixing tight spacing in annotate-last mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add sandbox scripts for Pi and Codex testing

- sandbox-pi.sh: builds extension, creates temp project, installs via
  `pi install`, launches Pi with sample files
- sandbox-codex.sh: compiles binary, creates temp project, launches
  Codex. Test with `!plannotator last`

Both follow the same pattern as sandbox-opencode.sh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add hook build step to opencode sandbox script

The opencode build copies HTML from hook/dist/ — without building hook
first, the sandbox could use stale HTML. Pi and Codex sandboxes already
had this step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove command body from plannotator-last to prevent agent response

The .md body was being sent to the agent as a prompt, causing it to
respond with "Opening annotation UI..." before the event handler could
fetch messages. That response became the "last message" instead of the
actual one. Empty body = agent stays silent, event handler intercepts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use command.execute.before hook for OpenCode annotate-last

Moves plannotator-last from the passive event hook to the
command.execute.before hook. This intercepts the command before the
agent sees it, clears output.parts so the agent stays silent, fetches
session messages, opens the annotation UI, then sends feedback via
client.session.prompt() — same pattern as review/annotate.

Previously the agent would respond to the command body before the
event handler could fetch messages, polluting the session history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add Codex to origin type and agent name mapping

Origin "codex" was falling through to the default "Coding Agent" label.
Added "codex" to the origin union type across annotate server, editor,
and removed the `as any` cast in the hook.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remote share link, plan-specific prose, and codex type unions

- Add writeRemoteShareLink to annotate-last onReady callback so remote
  sessions get a reachable URL
- Add subject parameter to exportAnnotations so feedback says "message"
  or "file" instead of "plan" when appropriate
- Add 'codex' to origin type unions in useAgents, Settings, UpdateBanner,
  and App.tsx fetch handler

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct JSDoc for projectSlugFromCwd (leading dash is kept, not stripped)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: use RenderedMessage type instead of inline structural type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:49:34 -07:00

446 lines
16 KiB
TypeScript

/**
* Plannotator Plugin for OpenCode
*
* Provides iterative planning with interactive browser-based plan review.
*
* When the agent is in plan mode:
* - Injects planning prompt directing the agent to write plans in $XDG_DATA_HOME/opencode/plans/
* - Agent creates a uniquely-named plan file, revises it on feedback
* - submit_plan(path) reads the plan from disk and opens browser UI
* - plan_exit suppressed in favor of submit_plan (experimental mode compatibility)
*
* Environment variables:
* PLANNOTATOR_REMOTE - Set to "1" or "true" for remote mode (devcontainer, SSH)
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
* PLANNOTATOR_PLAN_TIMEOUT_SECONDS - Max wait for submit_plan approval (default: 345600, set 0 to disable)
* PLANNOTATOR_ALLOW_SUBAGENTS - Set to "1" to allow subagents to see submit_plan tool
*
* @packageDocumentation
*/
import { type Plugin, tool } from "@opencode-ai/plugin";
import {
startPlannotatorServer,
handleServerReady,
} from "@plannotator/server";
import {
startReviewServer,
handleReviewServerReady,
} from "@plannotator/server/review";
import {
startAnnotateServer,
handleAnnotateServerReady,
} from "@plannotator/server/annotate";
import { writeRemoteShareLink } from "@plannotator/server/share-url";
import {
handleReviewCommand,
handleAnnotateCommand,
handleAnnotateLastCommand,
type CommandDeps,
} from "./commands";
import { planDenyFeedback } from "@plannotator/shared/feedback-templates";
import {
getPlanDirectory,
validatePlanPath,
stripConflictingPlanModeRules,
} from "./plan-mode";
// @ts-ignore - Bun import attribute for text
import indexHtml from "./plannotator.html" with { type: "text" };
const htmlContent = indexHtml as unknown as string;
// @ts-ignore - Bun import attribute for text
import reviewHtml from "./review-editor.html" with { type: "text" };
const reviewHtmlContent = reviewHtml as unknown as string;
const DEFAULT_PLAN_TIMEOUT_SECONDS = 345_600; // 96 hours
// ── Planning prompt ───────────────────────────────────────────────────────
function getPlanningPrompt(planDir: string): string {
return `## Plannotator — Iterative Planning
Your plan files must live in this directory:
${planDir}
You must not edit the codebase during planning. The only files you may create or edit are plan markdown files inside that directory. Do not run destructive shell commands (rm, git push, npm install, etc.).
### Step 1 — Explore
Before writing anything, understand the task and the code it touches.
- Read the relevant source files. Trace call paths, data flow, and dependencies.
- Look at existing patterns, utilities, and conventions in the codebase — your plan should reuse them.
- Check related tests to understand expected behavior and edge cases.
- Scale depth to the task: a vague feature request needs deep exploration; a focused bug fix may only need a few files.
Do not jump to writing a plan or asking questions until you have the context you need. If the conversation already provided sufficient context, or the task is greenfield with no code to explore, move on to the next step.
### Step 2 — Ask (if needed)
If there are things only the user can answer — requirements, preferences, tradeoffs, edge-case priorities — use the \`question\` tool. Do not ask via plain text output.
- Never ask what you could find out by reading the code.
- Batch related questions into a single \`question\` call.
- For greenfield tasks, this may be your first step.
### Step 3 — Write the plan
Once you understand the task, create exactly one markdown plan file in the directory above with a unique, descriptive filename (e.g. \`auth-refactor.md\`, \`fix-upload-timeout.md\`). Do not overwrite or reuse filenames from existing plans.
Structure the plan with:
- **Context** — Why this change is being made.
- **Approach** — Your recommended approach only, not all alternatives considered.
- **Files to modify** — List the critical file paths that will be changed.
- **Reuse** — Reference existing functions and utilities you found, with their file paths.
- **Steps** — Implementation checklist with \`- [ ]\` items.
- **Verification** — How to test the changes end-to-end.
Keep it concise enough to scan quickly, but detailed enough to execute effectively.
### Step 4 — Submit
Call \`submit_plan(path: "/absolute/path/to/your-plan.md")\` to open the plan in a visual review UI. Do not submit plan text directly — submit the file path.
### If the user requests changes
1. Read the same plan file you previously submitted.
2. Edit that same file to address the feedback.
3. Call \`submit_plan\` again with the same \`path\`.
4. Never create a new file in response to feedback — always revise the existing one.
### Ending your turn
Your turn should only end by either:
- Using the question tool to ask the user for information.
- Calling submit_plan when the plan is ready for review.
Do not end your turn without doing one of these two things.`;
}
// ── Plugin ────────────────────────────────────────────────────────────────
export const PlannotatorPlugin: Plugin = async (ctx) => {
// Agents list is static for the session lifetime — fetch once and cache
let cachedAgents: any[] | null = null;
// Helper to determine if sharing is enabled (lazy evaluation)
// Priority: OpenCode config > env var > default (enabled)
async function getSharingEnabled(): Promise<boolean> {
try {
const response = await ctx.client.config.get({ query: { directory: ctx.directory } });
// @ts-ignore - share config may exist
const share = response?.data?.share;
if (share !== undefined) {
return share !== "disabled";
}
} catch {
// Config read failed, fall through to env var
}
return process.env.PLANNOTATOR_SHARE !== "disabled";
}
function getShareBaseUrl(): string | undefined {
return process.env.PLANNOTATOR_SHARE_URL || undefined;
}
function getPlanTimeoutSeconds(): number | null {
const raw = process.env.PLANNOTATOR_PLAN_TIMEOUT_SECONDS?.trim();
if (!raw) return DEFAULT_PLAN_TIMEOUT_SECONDS;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
console.error(
`[Plannotator] Invalid PLANNOTATOR_PLAN_TIMEOUT_SECONDS="${raw}". Using default ${DEFAULT_PLAN_TIMEOUT_SECONDS}s.`
);
return DEFAULT_PLAN_TIMEOUT_SECONDS;
}
if (parsed === 0) return null;
return parsed;
}
function allowSubagents(): boolean {
const val = process.env.PLANNOTATOR_ALLOW_SUBAGENTS?.trim();
return val === "1" || val === "true";
}
return {
// Register submit_plan as primary-only tool (hidden from sub-agents by default)
config: async (opencodeConfig) => {
if (allowSubagents()) return;
const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? [];
if (!existingPrimaryTools.includes("submit_plan")) {
opencodeConfig.experimental = {
...opencodeConfig.experimental,
primary_tools: [...existingPrimaryTools, "submit_plan"],
};
}
},
// Suppress plan_exit in favor of submit_plan
"tool.definition": async (input, output) => {
if (input.toolID === "plan_exit") {
output.description =
"Do not call this tool. Use submit_plan instead — it opens a visual review UI for plan approval.";
}
},
// Inject planning instructions into system prompt
"experimental.chat.system.transform": async (input, output) => {
// Skip for title generation requests
const systemText = output.system.join("\n");
if (systemText.toLowerCase().includes("title generator") || systemText.toLowerCase().includes("generate a title")) {
return;
}
let lastUserAgent: string | undefined;
try {
// Fetch session messages to determine current agent
const messagesResponse = await ctx.client.session.messages({
// @ts-ignore - sessionID exists on input
path: { id: input.sessionID }
});
const messages = messagesResponse.data;
// Find last user message (reverse iteration)
if (messages) {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.info.role === "user") {
// @ts-ignore - UserMessage has agent field
lastUserAgent = msg.info.agent;
break;
}
}
}
// Skip if agent detection fails (safer)
if (!lastUserAgent) return;
// Hardcoded exclusion: build agent
if (lastUserAgent === "build") return;
// Agents list is static — cache after first fetch
if (!cachedAgents) {
const agentsResponse = await ctx.client.app.agents({
query: { directory: ctx.directory }
});
cachedAgents = agentsResponse.data ?? [];
}
const agent = cachedAgents.find((a: { name: string }) => a.name === lastUserAgent);
// Skip if agent is a sub-agent
// @ts-ignore - Agent has mode field
if (agent?.mode === "subagent") return;
} catch {
// Skip injection on any error (safer)
return;
}
// Plan agent: inject full iterative planning prompt
if (lastUserAgent === "plan") {
const planDir = getPlanDirectory();
output.system = stripConflictingPlanModeRules(output.system);
output.system.push(getPlanningPrompt(planDir));
return;
}
// Other primary agents: inject minimal submission reminder
output.system.push(`
## Plan Submission
When you have completed your plan, you MUST call the \`submit_plan\` tool to submit it for user review.
The user will be able to:
- Review your plan visually in a dedicated UI
- Annotate specific sections with feedback
- Approve the plan to proceed with implementation
- Request changes with detailed feedback
If your plan is rejected, you will receive the user's annotated feedback. Revise your plan
based on their feedback and call submit_plan again.
Do NOT proceed with implementation until your plan is approved.
`);
},
// Intercept plannotator-last before the agent sees the command
"command.execute.before": async (input, output) => {
if (input.command !== "plannotator-last") return;
// Clear parts so the agent doesn't respond to the command body
output.parts = [];
const deps: CommandDeps = {
client: ctx.client,
htmlContent,
reviewHtmlContent,
getSharingEnabled,
getShareBaseUrl,
directory: ctx.directory,
};
// Fetch last message, run annotation server, get feedback
const feedback = await handleAnnotateLastCommand(
{ properties: { sessionID: input.sessionID } },
deps
);
// Send feedback as a new prompt — same pattern as review/annotate
if (feedback) {
try {
await ctx.client.session.prompt({
path: { id: input.sessionID },
body: {
parts: [{
type: "text",
text: `# Message Annotations\n\n${feedback}\n\nPlease address the annotation feedback above.`,
}],
},
});
} catch {
// Session may not be available
}
}
},
// Listen for slash commands (review + annotate)
event: async ({ event }) => {
const isCommandEvent =
event.type === "command.executed" ||
event.type === "tui.command.execute";
if (!isCommandEvent) return;
// @ts-ignore - Event structure varies
const commandName = event.properties?.name || event.command || event.payload?.name;
const deps: CommandDeps = {
client: ctx.client,
htmlContent,
reviewHtmlContent,
getSharingEnabled,
getShareBaseUrl,
directory: ctx.directory,
};
if (commandName === "plannotator-review")
return handleReviewCommand(event, deps);
if (commandName === "plannotator-annotate")
return handleAnnotateCommand(event, deps);
},
tool: {
submit_plan: tool({
description:
"Submit your plan file for interactive user review. The user can annotate, approve, or request changes in a visual browser UI. Pass the absolute path to your plan file.",
args: {
path: tool.schema
.string()
.describe("Absolute path to the plan markdown file on disk."),
},
async execute(args, context) {
const planDir = getPlanDirectory();
const validation = validatePlanPath(args.path, planDir);
if (!validation.ok) {
return `Error: ${validation.error}`;
}
const planContent = validation.content;
const sharingEnabled = await getSharingEnabled();
const server = await startPlannotatorServer({
plan: planContent,
origin: "opencode",
sharingEnabled,
shareBaseUrl: getShareBaseUrl(),
htmlContent,
opencodeClient: ctx.client,
onReady: async (url, isRemote, port) => {
handleServerReady(url, isRemote, port);
if (isRemote && sharingEnabled) {
await writeRemoteShareLink(planContent, getShareBaseUrl(), "review the plan", "plan only").catch(() => {});
}
},
});
const timeoutSeconds = getPlanTimeoutSeconds();
const timeoutMs = timeoutSeconds === null ? null : timeoutSeconds * 1000;
const result = timeoutMs === null
? await server.waitForDecision()
: await new Promise<Awaited<ReturnType<typeof server.waitForDecision>>>((resolve) => {
const timeoutId = setTimeout(
() =>
resolve({
approved: false,
feedback: `[Plannotator] No response within ${timeoutSeconds} seconds. Port released automatically. Please call submit_plan again.`,
}),
timeoutMs
);
server.waitForDecision().then((r) => {
clearTimeout(timeoutId);
resolve(r);
});
});
await Bun.sleep(1500);
server.stop();
if (result.approved) {
// Check agent switch setting
const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== 'disabled';
const targetAgent = result.agentSwitch || 'build';
if (shouldSwitchAgent) {
try {
await ctx.client.tui.executeCommand({
body: { command: "agent_cycle" },
});
} catch {
// Silently fail
}
try {
await ctx.client.session.prompt({
path: { id: context.sessionID },
body: {
agent: targetAgent,
noReply: true,
parts: [{ type: "text", text: "Proceed with implementation" }],
},
});
} catch {
// Silently fail if session is busy
}
}
if (result.feedback) {
return `Plan approved with notes!
${result.savedPath ? `Saved to: ${result.savedPath}` : ""}
## Implementation Notes
The user approved your plan but added the following notes to consider during implementation:
${result.feedback}
Proceed with implementation, incorporating these notes where applicable.`;
}
return `Plan approved!${result.savedPath ? ` Saved to: ${result.savedPath}` : ""}`;
} else {
return planDenyFeedback(result.feedback || "", "submit_plan", { planFilePath: args.path });
}
},
}),
},
};
};
export default PlannotatorPlugin;