Skip to content
Merged
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
29 changes: 28 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ claude --plugin-dir ./apps/hook
| `PLANNOTATOR_SHARE` | Set to `disabled` to turn off URL sharing entirely. Default: enabled. |
| `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. |
| `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. |
| `PLANNOTATOR_ORIGIN` | Explicit agent-origin override at the top of the detection chain. Valid values: `claude-code`, `opencode`, `codex`, `copilot-cli`, `gemini-cli`. Invalid values silently fall through to env-based detection. Unset by default. |
| `PLANNOTATOR_ORIGIN` | Explicit agent-origin override at the top of the detection chain. Valid values: `claude-code`, `opencode`, `codex`, `copilot-cli`, `gemini-cli`, `pi`. Invalid values silently fall through to env-based detection. Unset by default. |
| `PLANNOTATOR_JINA` | Set to `0` / `false` to disable Jina Reader for URL annotation, or `1` / `true` to enable. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "jina": false }`) or per-invocation via `--no-jina`. |
| `JINA_API_KEY` | Optional Jina Reader API key for higher rate limits (500 RPM vs 20 RPM unauthenticated). Free keys include 10M tokens. |
| `PLANNOTATOR_VERIFY_ATTESTATION` | **Read by the install scripts only**, not by the runtime binary. Set to `1` / `true` to have `scripts/install.sh` / `install.ps1` / `install.cmd` run `gh attestation verify` on every install. Off by default. Can also be set persistently via `~/.plannotator/config.json` (`{ "verifyAttestation": true }`) or per-invocation via `--verify-attestation`. Requires `gh` installed and authenticated. |
Expand Down Expand Up @@ -173,6 +173,21 @@ Send Feedback → feedback sent to agent session
Approve → "LGTM" sent to agent session
```

## Ask AI Provider Defaults

Ask AI providers are detected independently from installed/authenticated local CLIs, then the UI picks a default from the detected Plannotator origin. The mapping lives in `packages/shared/agents.ts` and is applied by `packages/ui/utils/aiProvider.ts`:

| Origin | Preferred Ask AI provider |
|--------|---------------------------|
| `claude-code` | `claude-agent-sdk` |
| `codex` | `codex-sdk` |
| `opencode` | `opencode-sdk` |
| `pi` | `pi-sdk` |
| `copilot-cli` | no dedicated provider; fallback to saved/server default |
| `gemini-cli` | no dedicated provider; fallback to saved/server default |

Per-origin choices are persisted in cookies, so a user can override the automatic match for one agent without changing the default for another.

## Annotate Flow

```
Expand Down Expand Up @@ -235,6 +250,12 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
| `/api/editor-annotations` | GET | List editor annotations (VS Code only) |
| `/api/editor-annotation` | POST/DELETE | Add or remove an editor annotation (VS Code only) |
| `/api/ai/capabilities` | GET | Check if AI features are available |
| `/api/ai/session` | POST | Create or fork an AI session |
| `/api/ai/query` | POST | Send a message and stream the response (SSE) |
| `/api/ai/abort` | POST | Abort the current query |
| `/api/ai/permission` | POST | Respond to a permission request |
| `/api/ai/sessions` | GET | List active sessions |
| `/api/external-annotations/stream` | GET | SSE stream for real-time external annotations |
| `/api/external-annotations` | GET | Snapshot of external annotations (polling fallback, `?since=N` for version gating) |
| `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) |
Expand Down Expand Up @@ -293,6 +314,12 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| `/api/doc` | GET | Serve linked .md/.mdx/.html file or code file (`?path=<path>&base=<dir>`) |
| `/api/doc/exists` | POST | Batch-validate code-file paths (body: `{ paths: string[], base?: string }`) |
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
| `/api/ai/capabilities` | GET | Check if AI features are available |
| `/api/ai/session` | POST | Create or fork an AI session |
| `/api/ai/query` | POST | Send a message and stream the response (SSE) |
| `/api/ai/abort` | POST | Abort the current query |
| `/api/ai/permission` | POST | Respond to a permission request |
| `/api/ai/sessions` | GET | List active sessions |
| `/api/external-annotations/stream` | GET | SSE stream for real-time external annotations |
| `/api/external-annotations` | GET | Snapshot of external annotations (polling fallback, `?since=N` for version gating) |
| `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) |
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ Interactive Plan & Code Review for AI Coding Agents. Mark up and refine your pla
### Features

<table>
<tr><td><strong>Visual Plan Review</strong></td><td>Built-in hook</td><td>Approve or deny agent plans with inline annotations</td></tr>
<tr><td><strong>Visual Plan Review</strong></td><td>Built-in hook</td><td>Approve or deny agent plans with inline annotations and Ask AI side chat</td></tr>
<tr><td><strong>Plan Diff</strong></td><td>Automatic</td><td>See what changed when the agent revises a plan</td></tr>
<tr><td><strong>Code Review</strong></td><td><code>/plannotator-review</code></td><td>View git diffs or remote PRs. Package annotations and ask AI about the code as you review.</td></tr>
<tr><td><strong>Annotate Any File</strong></td><td><code>/plannotator-annotate &lt;file|folder|url&gt;</code></td><td>Annotate markdown, HTML, URLs, or folders and send feedback to your agent</td></tr>
<tr><td><strong>Annotate Any File</strong></td><td><code>/plannotator-annotate &lt;file|folder|url&gt;</code></td><td>Annotate markdown, HTML, URLs, or folders, ask AI about the active document, and send feedback to your agent</td></tr>
<tr><td><strong>Annotate Last Message</strong></td><td><code>/plannotator-last</code></td><td>Annotate the agent's last response and send structured feedback</td></tr>
</table>

Expand Down Expand Up @@ -255,8 +255,9 @@ When your AI agent finishes planning, Plannotator:

1. Opens the Plannotator UI in your browser
2. Lets you annotate the plan visually (delete, insert, replace, comment)
3. **Approve** → Agent proceeds with implementation
4. **Request changes** → Your annotations are sent back as structured feedback
3. Lets you ask AI about the plan or a highlighted selection when a provider is available
4. **Approve** → Agent proceeds with implementation
5. **Request changes** → Your annotations are sent back as structured feedback

(Similar flow for code review, except you can also comment on specific lines of code diffs)

Expand Down
10 changes: 7 additions & 3 deletions apps/marketing/src/content/docs/guides/ai-features.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
---
title: AI Features
description: "How to use Plannotator's inline AI chat during code review — provider setup, model selection, and how it works."
description: "How to use Plannotator's AI chat during plan review, annotate, and code review — provider setup, model selection, and how it works."
sidebar:
order: 25
section: "Guides"
---

Plannotator embeds an AI chat sidebar directly in the code review UI. You can select lines in a diff, ask questions, and get streaming responses. The AI sees the full diff context automatically, so you can ask things like "explain this change" or "is this safe?" without copy-pasting code.
Plannotator embeds an AI chat sidebar directly in live review sessions. In plan review and annotate, you can ask a general question about the current plan or document, or select text, open the comment popover, and choose **Ask AI**. In code review, you can select lines in a diff and ask questions about the code.

The AI sees the relevant review context automatically: the current plan and previous plan version for plan review, the active document and source metadata for annotate, or the full diff for code review. AI chat history stays separate from approve, deny, and send-annotations output unless you manually copy text into normal feedback.

## Supported providers

Expand Down Expand Up @@ -49,6 +51,8 @@ OpenCode supports session forking, resuming, and runtime permission approvals

Provider and model selection is available in **Settings > AI**. These persist via cookies across sessions.

By default, Plannotator prefers the provider that matches the detected agent origin: Claude Code uses Claude, Codex uses Codex, OpenCode uses OpenCode, and Pi uses Pi when those providers are available. GitHub Copilot CLI and Gemini CLI do not have dedicated Ask AI providers yet, so they fall back to your saved provider or the server default.

You can also override the provider and model per-session using the config bar at the bottom of the AI sidebar. Changing the provider or model starts a new session — old messages stay visible but the conversation resets.

## How it works
Expand All @@ -63,7 +67,7 @@ A session is created lazily on your first question. Until then, no resources are

**OpenCode sessions** pass the review context via the `system` field on the prompt API. OpenCode supports forking from a parent session and resuming previous sessions. Permission requests work the same as Claude — approval cards appear inline.

**Diff context handling:** Large diffs are truncated at roughly 40k characters to stay within context limits. However, when you select specific lines and ask a question, the selected code is always sent alongside the question regardless of truncation.
**Context handling:** Large plans, documents, and diffs are truncated to stay within context limits. When you ask from a selection, the selected text or selected code is always sent alongside the question regardless of truncation. In folder annotation mode, Ask AI is scoped to the currently opened document only.

## Permission requests

Expand Down
16 changes: 8 additions & 8 deletions apps/pi-extension/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ describe("pi review server", () => {

const vcsContext = await getVcsContext(repoDir);
expect(vcsContext.vcsType).toBe("jj");
const expectedJjBase = vcsContext.defaultBranch;
const prepared = await prepareLocalReviewDiff({
cwd: repoDir,
requestedDiffType: "merge-base",
Expand All @@ -519,7 +520,7 @@ describe("pi review server", () => {
});
expect(prepared.gitContext.vcsType).toBe("jj");
expect(prepared.diffType).toBe("jj-current");
expect(prepared.base).toBe("main@git");
expect(prepared.base).toBe(expectedJjBase);

const forcedGit = await prepareLocalReviewDiff({
cwd: repoDir,
Expand Down Expand Up @@ -578,14 +579,13 @@ describe("pi review server", () => {
gitContext?: { vcsType?: string; diffOptions: Array<{ id: string }> };
};
expect(initial.diffType).toBe("jj-current");
expect(initial.base).toBe("main@git");
expect(initial.base).toBe(expectedJjBase);
expect(initial.gitContext?.vcsType).toBe("jj");
expect(initial.gitContext?.diffOptions.map((option) => option.id)).toEqual([
"jj-current",
"jj-last",
"jj-line",
"jj-all",
]);
const optionIds = initial.gitContext?.diffOptions.map((option) => option.id) ?? [];
expect(optionIds).toContain("jj-current");
expect(optionIds).toContain("jj-last");
expect(optionIds).toContain("jj-line");
expect(optionIds).toContain("jj-all");
expect(initial.rawPatch).toContain("tracked.txt");
expect(initial.rawPatch).toContain("+after");

Expand Down
171 changes: 171 additions & 0 deletions apps/pi-extension/server/ai-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { execFileSync } from "node:child_process";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";

import { json, toWebRequest } from "./helpers.js";

export interface PiAIRuntime {
endpoints: Record<string, (req: Request) => Promise<Response>>;
dispose: () => void;
}

interface CreatePiAIRuntimeOptions {
cwd?: string;
getCwd?: () => string;
}

function whichCmd(cmd: string): string | null {
try {
const bin = process.platform === "win32" ? "where" : "which";
const output = execFileSync(bin, [cmd], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return output
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? null;
} catch {
return null;
}
}

export async function createPiAIRuntime(options: CreatePiAIRuntimeOptions = {}): Promise<PiAIRuntime | null> {
try {
const ai = await import("../generated/ai/index.js");
const cwd = options.cwd ?? process.cwd();
const registry = new ai.ProviderRegistry();
const sessionManager = new ai.SessionManager();
const modelDiscovery: Promise<void>[] = [];

try {
await import("../generated/ai/providers/claude-agent-sdk.js");
const claudePath = whichCmd("claude");
const provider = await ai.createProvider({
type: "claude-agent-sdk",
cwd,
...(claudePath && { claudeExecutablePath: claudePath }),
});
registry.register(provider);
} catch {
// Claude SDK not available.
}

try {
await import("../generated/ai/providers/codex-sdk.js");
await import("@openai/codex-sdk");
const codexPath = whichCmd("codex");
const provider = await ai.createProvider({
type: "codex-sdk",
cwd,
...(codexPath && { codexExecutablePath: codexPath }),
});
registry.register(provider);
} catch {
// Codex SDK not available.
}

try {
await import("../generated/ai/providers/pi-sdk-node.js");
const piPath = whichCmd("pi");
if (piPath) {
const provider = await ai.createProvider({
type: "pi-sdk",
cwd,
piExecutablePath: piPath,
} as any);
if (provider && "fetchModels" in provider) {
modelDiscovery.push(
(provider as { fetchModels: () => Promise<void> })
.fetchModels()
.catch(() => {}),
);
}
registry.register(provider);
}
} catch {
// Pi not available.
}

try {
await import("../generated/ai/providers/opencode-sdk.js");
const opencodePath = whichCmd("opencode");
if (opencodePath) {
const provider = await ai.createProvider({
type: "opencode-sdk",
cwd,
});
if (provider && "fetchModels" in provider) {
modelDiscovery.push(
(provider as { fetchModels: () => Promise<void> })
.fetchModels()
.catch(() => {}),
);
}
registry.register(provider);
}
} catch {
// OpenCode not available.
}

return {
endpoints: ai.createAIEndpoints({
registry,
sessionManager,
getCwd: options.getCwd,
beforeCapabilities: async () => {
await Promise.allSettled(modelDiscovery);
},
}),
dispose: () => {
sessionManager.disposeAll();
registry.disposeAll();
},
};
} catch {
return null;
}
}

export async function handlePiAIRequest(
req: IncomingMessage,
res: ServerResponse,
url: URL,
runtime: PiAIRuntime | null,
): Promise<boolean> {
if (!url.pathname.startsWith("/api/ai/")) return false;

if (!runtime) {
if (url.pathname === "/api/ai/capabilities" && req.method === "GET") {
json(res, { available: false, providers: [] });
return true;
}
json(res, { error: "AI backend not available" }, 503);
return true;
}

const handler = runtime.endpoints[url.pathname];
if (!handler) {
json(res, { error: "Not found" }, 404);
return true;
}

try {
const webReq = toWebRequest(req);
const webRes = await handler(webReq);
const headers: Record<string, string> = {};
webRes.headers.forEach((value, key) => {
headers[key] = value;
});
res.writeHead(webRes.status, headers);
if (webRes.body) {
Readable.fromWeb(webRes.body as any).pipe(res);
} else {
res.end();
}
} catch (err) {
json(res, { error: err instanceof Error ? err.message : "AI endpoint error" }, 500);
}

return true;
}
8 changes: 7 additions & 1 deletion apps/pi-extension/server/serverAnnotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
handleUploadRequest,
} from "./handlers.js";
import { html, json, parseBody, requestUrl } from "./helpers.js";
import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.js";

import { listenOnPort } from "./network.js";

Expand Down Expand Up @@ -86,11 +87,13 @@ export async function startAnnotateServer(options: {
const repoInfo = getRepoInfo();

const externalAnnotations = createExternalAnnotationHandler("plan");
const aiRuntime = await createPiAIRuntime();

const server = createServer(async (req, res) => {
const url = requestUrl(req);

if (await externalAnnotations.handle(req, res, url)) return;
if (url.pathname.startsWith("/api/ai/") && await handlePiAIRequest(req, res, url, aiRuntime)) return;

if (url.pathname === "/api/plan" && req.method === "GET") {
json(res, {
Expand Down Expand Up @@ -180,6 +183,9 @@ export async function startAnnotateServer(options: {
portSource,
url: `http://localhost:${port}`,
waitForDecision: () => decisionPromise,
stop: () => server.close(),
stop: () => {
aiRuntime?.dispose();
server.close();
},
};
}
9 changes: 8 additions & 1 deletion apps/pi-extension/server/serverPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
handleUploadRequest,
} from "./handlers.js";
import { html, json, parseBody, requestUrl } from "./helpers.js";
import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.js";
import { openEditorDiff } from "./ide.js";
import {
type BearConfig,
Expand Down Expand Up @@ -156,6 +157,7 @@ export async function startPlanReviewServer(options: {
// Editor annotations (in-memory, VS Code integration — skip in archive mode)
const editorAnnotations = options.mode !== "archive" ? createEditorAnnotationHandler() : null;
const externalAnnotations = options.mode !== "archive" ? createExternalAnnotationHandler("plan") : null;
const aiRuntime = options.mode !== "archive" ? await createPiAIRuntime() : null;

// Lazy cache for in-session archive tab
let cachedArchivePlans: ArchivedPlan[] | null = null;
Expand Down Expand Up @@ -267,6 +269,8 @@ export async function startPlanReviewServer(options: {
return;
} else if (externalAnnotations && (await externalAnnotations.handle(req, res, url))) {
return;
} else if (url.pathname.startsWith("/api/ai/") && await handlePiAIRequest(req, res, url, aiRuntime)) {
return;
} else if (url.pathname === "/api/doc" && req.method === "GET") {
await handleDocRequest(res, url);
} else if (url.pathname === "/api/doc/exists" && req.method === "POST") {
Expand Down Expand Up @@ -493,6 +497,9 @@ export async function startPlanReviewServer(options: {
};
},
...(donePromise && { waitForDone: () => donePromise }),
stop: () => server.close(),
stop: () => {
aiRuntime?.dispose();
server.close();
},
};
}
Loading
Loading