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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) |
| `/api/external-annotations` | PATCH | Update fields on a single annotation (`?id=`) |
| `/api/external-annotations` | DELETE | Remove by `?id=`, `?source=`, or clear all |
| `/api/agents/capabilities` | GET | Check available agent providers (claude, codex, tour, guide, cursor, opencode, pi) |
| `/api/agents/capabilities` | GET | Check available agent providers (claude, codex, tour, guide, cursor, opencode, pi, copilot) |
| `/api/agents/review-profiles` | GET | List launchable review profiles (enabled skills + builtin default) |
| `/api/agents/skills` | GET | List all discovered skills for the add-a-review picker (each flagged `enabled`) |
| `/api/agents/review-skills` | POST | Enable a skill as a review (body: `{ name }`); writes `review-skills.json` |
Expand Down
10 changes: 6 additions & 4 deletions apps/pi-extension/server/agent-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
MARKER_ENGINES,
formatMarkerLogEvent,
type MarkerEngine,
type MarkerEngineId,
type MarkerModel,
} from "../generated/marker-review.js";
import { json, parseBody } from "./helpers.js";
Expand All @@ -51,6 +52,7 @@ const SERVER_BUILT_PROVIDERS: ReadonlySet<string> = new Set([
"cursor",
"opencode",
"pi",
"copilot",
]);

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -179,7 +181,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
const markerModelsCache = new Map<string, MarkerModel[]>();
async function buildCapabilitiesResponse(): Promise<AgentCapabilities> {
const providers = await Promise.all(capabilities.map(async (c) => {
const engine = MARKER_ENGINES[c.id as "cursor" | "opencode" | "pi"];
const engine = MARKER_ENGINES[c.id as MarkerEngineId];
if (!engine || !c.available) return c;
let models = markerModelsCache.get(engine.id);
if (!models) {
Expand Down Expand Up @@ -294,8 +296,8 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
// Guide jobs keep provider: "guide" and carry the marker engine on
// spawnOptions.engine instead — fall back to that lookup so guide
// logs get the same readable formatting as review jobs.
const markerEngine = MARKER_ENGINES[provider as "cursor" | "opencode" | "pi"]
?? (spawnOptions?.engine ? MARKER_ENGINES[spawnOptions.engine as "cursor" | "opencode" | "pi"] : undefined);
const markerEngine = MARKER_ENGINES[provider as MarkerEngineId]
?? (spawnOptions?.engine ? MARKER_ENGINES[spawnOptions.engine as MarkerEngineId] : undefined);
if (markerEngine) {
const formatted = formatMarkerLogEvent(line, markerEngine);
if (formatted !== null) broadcast({ type: "job:log", jobId: id, delta: formatted + '\n' });
Expand Down Expand Up @@ -390,7 +392,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
// that fail-closed rule too: both are single-shot, all-or-nothing
// outputs with nothing meaningful partially ingested, so an
// unexpected throw means the whole result is unusable.
if (MARKER_ENGINES[provider as "cursor" | "opencode" | "pi"]) {
if (MARKER_ENGINES[provider as MarkerEngineId]) {
entry.info.status = "failed";
entry.info.error = err instanceof Error ? err.message : `${provider} result ingestion failed`;
} else if (provider === "tour" || provider === "guide") {
Expand Down
7 changes: 4 additions & 3 deletions apps/pi-extension/server/serverReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ import {
transformMarkerFindings,
makeMarkerNonce,
extractMarkerNonce,
type MarkerEngineId,
} from "../generated/marker-review.js";
import {
WorkspaceReviewSession,
Expand Down Expand Up @@ -939,7 +940,7 @@ export async function startReviewServer(options: {
// id itself for claude/codex.
const failedEngine = typeof config?.engine === "string" && config.engine ? config.engine : undefined;
const failedEngineBinary = failedEngine
? MARKER_ENGINES[failedEngine as "cursor" | "opencode" | "pi"]?.binary ?? failedEngine
? MARKER_ENGINES[failedEngine as MarkerEngineId]?.binary ?? failedEngine
: undefined;
const repairEngine =
failedEngine && commandExists(failedEngineBinary!)
Expand Down Expand Up @@ -1027,7 +1028,7 @@ export async function startReviewServer(options: {
// no cwd flag — it always uses the process's actual cwd, which spawnJob
// already sets from this same cwd).
// captureStdout is required: the marker block comes back on stdout NDJSON.
const markerEngine = MARKER_ENGINES[provider as "cursor" | "opencode" | "pi"];
const markerEngine = MARKER_ENGINES[provider as MarkerEngineId];
if (markerEngine) {
const model = typeof config?.model === "string" && config.model ? config.model : undefined;
const thinking = typeof config?.thinking === "string" && config.thinking ? config.thinking : undefined;
Expand Down Expand Up @@ -1141,7 +1142,7 @@ export async function startReviewServer(options: {
// an exit-0 job marked done). Mirrors the Tour fail-closed pattern below.
// Findings carry nullable file/line, classified into line/whole-file/
// general by transformMarkerFindings — nothing is dropped (same as Claude).
const markerEngine = MARKER_ENGINES[job.provider as "cursor" | "opencode" | "pi"];
const markerEngine = MARKER_ENGINES[job.provider as MarkerEngineId];
if (markerEngine) {
// Recover the per-job nonce embedded in the prompt; without it no block
// can be trusted, so parse fails closed below.
Expand Down
6 changes: 6 additions & 0 deletions packages/review-editor/components/guide/GuideDiffSection.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import type { GuideDiffRef } from '@plannotator/shared/guide';
import { renderInlineMarkdown } from '../../utils/renderInlineMarkdown';
import { DiffViewer } from '../DiffViewer';
import { useReviewState } from '../../dock/ReviewStateContext';
import { annotationMatchesPrScope } from '../../utils/annotationScope';
Expand Down Expand Up @@ -80,6 +81,11 @@ export const GuideDiffSection: React.FC<GuideDiffSectionProps> = ({ diffRef, isF

return (
<div onPointerEnter={onFocus} onFocus={onFocus}>
{diffRef.summary && (
<p className="mb-1.5 px-1 text-xs leading-relaxed text-muted-foreground">
{renderInlineMarkdown(diffRef.summary)}
</p>
)}
{/* DiffViewer is `h-full flex flex-col` internally (FileHeader + its own
scrolling body) — it fills whatever height its parent gives it rather
than growing to fit content. The guide page is one continuous scroll
Expand Down
22 changes: 18 additions & 4 deletions packages/review-editor/components/guide/GuideEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const GUIDE_ENGINES = Object.keys(REVIEW_ENGINE_LABEL) as ReviewEngine[];
const CURSOR_FALLBACK = [{ value: 'auto', label: 'Auto' }];
const OPENCODE_FALLBACK = [{ value: '', label: 'Default' }];
const PI_FALLBACK = [{ value: '', label: 'Default' }];
const COPILOT_FALLBACK = [{ value: '', label: 'Default' }];

type Option = { value: string; label: string };

Expand Down Expand Up @@ -169,6 +170,7 @@ export const GuideEmptyState: React.FC<GuideEmptyStateProps> = ({ capabilities,
guideOpencodeModel,
guidePiModel,
guidePiThinking,
guideCopilotModel,
setGuideEngine,
setGuideClaudeModel,
setGuideClaudeEffort,
Expand All @@ -178,6 +180,7 @@ export const GuideEmptyState: React.FC<GuideEmptyStateProps> = ({ capabilities,
setGuideOpencodeModel,
setGuidePiModel,
setGuidePiThinking,
setGuideCopilotModel,
} = useAgentSettings();

const [launching, setLaunching] = useState(false);
Expand Down Expand Up @@ -277,7 +280,7 @@ export const GuideEmptyState: React.FC<GuideEmptyStateProps> = ({ capabilities,
// saved-default user with a blank pill and no way back after picking a
// concrete model. Cursor REPLACES: its discovered list natively includes
// 'auto', so prepending would duplicate it.
const markerModels = (id: 'cursor' | 'opencode' | 'pi', fallback: Option[]): Option[] => {
const markerModels = (id: 'cursor' | 'opencode' | 'pi' | 'copilot', fallback: Option[]): Option[] => {
const models = capabilities?.providers.find((p) => p.id === id)?.models;
if (!models || models.length === 0) return fallback;
const discovered = models.map((m) => ({ value: m.id, label: m.label }));
Expand All @@ -289,6 +292,7 @@ export const GuideEmptyState: React.FC<GuideEmptyStateProps> = ({ capabilities,
const cursorOptions = markerModels('cursor', CURSOR_FALLBACK);
const opencodeOptions = markerModels('opencode', OPENCODE_FALLBACK);
const piOptions = markerModels('pi', PI_FALLBACK);
const copilotOptions = markerModels('copilot', COPILOT_FALLBACK);

// AgentsTab reconciles the saved guide Cursor/OpenCode/Pi model back to the
// catalog head via effects when the live catalog no longer contains it (see
Expand All @@ -307,6 +311,7 @@ export const GuideEmptyState: React.FC<GuideEmptyStateProps> = ({ capabilities,
const effectiveCursorModel = effectiveModel(guideCursorModel, cursorOptions);
const effectiveOpencodeModel = effectiveModel(guideOpencodeModel, opencodeOptions);
const effectivePiModel = effectiveModel(guidePiModel, piOptions);
const effectiveCopilotModel = effectiveModel(guideCopilotModel, copilotOptions);

const modelPicker: { value: string; options: Option[]; onChange: (v: string) => void } =
engine === 'claude'
Expand All @@ -317,7 +322,9 @@ export const GuideEmptyState: React.FC<GuideEmptyStateProps> = ({ capabilities,
? { value: effectiveCursorModel, options: cursorOptions, onChange: setGuideCursorModel }
: engine === 'opencode'
? { value: effectiveOpencodeModel, options: opencodeOptions, onChange: setGuideOpencodeModel }
: { value: effectivePiModel, options: piOptions, onChange: setGuidePiModel };
: engine === 'copilot'
? { value: effectiveCopilotModel, options: copilotOptions, onChange: setGuideCopilotModel }
: { value: effectivePiModel, options: piOptions, onChange: setGuidePiModel };

const canLaunch = guideAvailable && availableEngines.length > 0 && !launching;

Expand Down Expand Up @@ -350,7 +357,14 @@ export const GuideEmptyState: React.FC<GuideEmptyStateProps> = ({ capabilities,
...(effectivePiModel ? { model: effectivePiModel } : {}),
thinking: guidePiThinking,
}
: {
: engine === 'copilot'
? {
provider: 'guide',
label: 'Guided Review',
engine: 'copilot',
...(effectiveCopilotModel ? { model: effectiveCopilotModel } : {}),
}
: {
provider: 'guide',
label: 'Guided Review',
engine,
Expand Down Expand Up @@ -434,7 +448,7 @@ export const GuideEmptyState: React.FC<GuideEmptyStateProps> = ({ capabilities,

{!guideAvailable || availableEngines.length === 0 ? (
<p className="mt-8 text-xs text-muted-foreground/70">
Guided review needs an agent CLI (Claude, Codex, Cursor, OpenCode, or Pi) available on this machine.
Guided review needs an agent CLI (Claude, Codex, Cursor, OpenCode, Pi, or Copilot) available on this machine.
</p>
) : (
<>
Expand Down
5 changes: 5 additions & 0 deletions packages/review-editor/demoGuide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const DEMO_GUIDE: CodeGuideData = {
diffs: [
{
file: 'src/components/Button.tsx',
summary: 'Guards `onClick` behind the `disabled` prop and forwards native `disabled` to the DOM element.',
},
],
},
Expand All @@ -34,9 +35,11 @@ export const DEMO_GUIDE: CodeGuideData = {
diffs: [
{
file: 'src/hooks/useAuth.ts',
summary: 'Replaces the untyped fetch calls with `api.auth.*` and adds `error` state so failed logins surface in the UI.',
},
{
file: 'src/services/api.ts',
summary: 'New typed request client with an `ApiError` class carrying HTTP status and error code.',
},
],
},
Expand All @@ -47,9 +50,11 @@ export const DEMO_GUIDE: CodeGuideData = {
diffs: [
{
file: 'src/config/settings.ts',
summary: 'Indentation-only reformat of the config getters; no behavior change.',
},
{
file: 'src/components/Modal.tsx',
summary: 'New portal-based dialog component; added but not yet wired up anywhere.',
},
],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,10 +431,10 @@ function ProviderPill({ provider, engine, model }: { provider: string; engine?:
// Guide's engine union is wider than Tour's (marker engines included) —
// only show the model for Claude, mirroring Tour's own "skip it for
// engines with verbose/technical model ids" convention.
const engineLabel = engine === 'codex' ? 'Codex' : engine === 'cursor' ? 'Cursor' : engine === 'opencode' ? 'OpenCode' : engine === 'pi' ? 'Pi' : 'Claude';
const engineLabel = engine === 'codex' ? 'Codex' : engine === 'cursor' ? 'Cursor' : engine === 'opencode' ? 'OpenCode' : engine === 'pi' ? 'Pi' : engine === 'copilot' ? 'Copilot' : 'Claude';
label = model && engine === 'claude' ? `Guide · ${engineLabel} ${model.charAt(0).toUpperCase() + model.slice(1)}` : `Guide · ${engineLabel}`;
} else {
label = provider === 'claude' ? 'Claude' : provider === 'codex' ? 'Codex' : provider === 'cursor' ? 'Cursor' : provider === 'opencode' ? 'OpenCode' : provider === 'pi' ? 'Pi' : 'Shell';
label = provider === 'claude' ? 'Claude' : provider === 'codex' ? 'Codex' : provider === 'cursor' ? 'Cursor' : provider === 'opencode' ? 'OpenCode' : provider === 'pi' ? 'Pi' : provider === 'copilot' ? 'Copilot' : 'Shell';
}
return (
<span className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
Expand Down
10 changes: 6 additions & 4 deletions packages/server/agent-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
MARKER_ENGINES,
formatMarkerLogEvent,
type MarkerEngine,
type MarkerEngineId,
type MarkerModel,
} from "./marker-review";
import {
Expand Down Expand Up @@ -72,6 +73,7 @@ const SERVER_BUILT_PROVIDERS: ReadonlySet<string> = new Set([
"cursor",
"opencode",
"pi",
"copilot",
]);

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -204,7 +206,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob
const markerModelsCache = new Map<string, MarkerModel[]>();
async function buildCapabilitiesResponse(): Promise<AgentCapabilities> {
const providers = await Promise.all(capabilities.map(async (c) => {
const engine = MARKER_ENGINES[c.id as "cursor" | "opencode" | "pi"];
const engine = MARKER_ENGINES[c.id as MarkerEngineId];
if (!engine || !c.available) return c;
let models = markerModelsCache.get(engine.id);
if (!models) {
Expand Down Expand Up @@ -356,8 +358,8 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob
// Guide jobs keep provider: "guide" and carry the marker engine on
// spawnOptions.engine instead — fall back to that lookup so guide
// logs get the same readable formatting as review jobs.
const markerEngine = MARKER_ENGINES[provider as "cursor" | "opencode" | "pi"]
?? (spawnOptions?.engine ? MARKER_ENGINES[spawnOptions.engine as "cursor" | "opencode" | "pi"] : undefined);
const markerEngine = MARKER_ENGINES[provider as MarkerEngineId]
?? (spawnOptions?.engine ? MARKER_ENGINES[spawnOptions.engine as MarkerEngineId] : undefined);
if (markerEngine) {
const formatted = formatMarkerLogEvent(line, markerEngine);
if (formatted !== null) broadcast({ type: "job:log", jobId: id, delta: formatted + '\n' });
Expand Down Expand Up @@ -430,7 +432,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob
// stops/checklist, a guide's sections) with nothing meaningful
// partially ingested, so an unexpected throw here means the whole
// result is unusable — it must not sit at "done" with no content.
if (MARKER_ENGINES[provider as "cursor" | "opencode" | "pi"]) {
if (MARKER_ENGINES[provider as MarkerEngineId]) {
entry.info.status = "failed";
entry.info.error = err instanceof Error ? err.message : `${provider} result ingestion failed`;
} else if (provider === "tour" || provider === "guide") {
Expand Down
23 changes: 23 additions & 0 deletions packages/server/guide/guide-review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,29 @@ describe("validateGuideOutput", () => {
expect(result.guide.unplacedFiles?.sort()).toEqual(["src/b.ts", "src/c.ts"]);
});

it("carries per-file summaries through, omitting blank/non-string ones without dropping the ref", () => {
const raw = JSON.parse(
guideJson([
{
title: "S",
overview: "o",
diffs: [
{ file: "src/a.ts", summary: "Adds the thing." },
{ file: "src/b.ts", summary: " " },
{ file: "src/c.ts", summary: 42 },
],
},
]),
);
const result = validateGuideOutput(raw, FILES);
if ("error" in result) throw new Error(result.error);
expect(result.guide.sections[0].diffs).toEqual([
{ file: "src/a.ts", summary: "Adds the thing." },
{ file: "src/b.ts" },
{ file: "src/c.ts" },
]);
});

it("coerces non-string title/intent from prompt-only marker engines", () => {
const raw = JSON.parse(guideJson([{ title: "S", overview: "o", diffs: [{ file: "src/a.ts" }] }]));
raw.title = 42;
Expand Down
Loading