mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
2d90d381c9
* fix: detect calling agent via env vars for correct badge display Add prioritized agent detection chain: CODEX_THREAD_ID (codex) > COPILOT_CLI (copilot-cli) > default (claude-code). All subcommands use the shared detectedOrigin constant. Display name updated from 'Copilot CLI' to 'GitHub Copilot'. TypeScript origin union type updated to include 'copilot-cli'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: centralize agent config into @plannotator/shared/agents - Create packages/shared/agents.ts as single source of truth for Origin type, agent display names (AGENT_CONFIG), getAgentName(), and getAgentBadge() - Replace all inline origin type unions across 8 files with Origin import - Type detectedOrigin and ServerOptions.origin as Origin for compile-time safety - Add @plannotator/shared dependency to @plannotator/editor package.json - Adding a new agent is now a one-file change to AGENT_CONFIG Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: restore gitContext in startReviewServer call Accidentally removed alongside the origin refactor. Without it, local code reviews lose branch detection, diff-type switching, and CWD resolution. For provenance purposes, this commit was AI assisted. --------- Co-authored-by: Yecats <Yecats@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Michael Ramos <mdramos8@gmail.com>
569 lines
21 KiB
TypeScript
569 lines
21 KiB
TypeScript
/**
|
|
* Plannotator Shared Server
|
|
*
|
|
* Provides a consistent server implementation for both Claude Code and OpenCode plugins.
|
|
*
|
|
* Environment variables:
|
|
* PLANNOTATOR_REMOTE - Set to "1" or "true" for remote/devcontainer mode
|
|
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
|
|
* PLANNOTATOR_ORIGIN - Origin identifier ("claude-code" or "opencode")
|
|
*/
|
|
|
|
import type { Origin } from "@plannotator/shared/agents";
|
|
import { resolve } from "path";
|
|
import { isRemoteSession, getServerPort } from "./remote";
|
|
import { openEditorDiff } from "./ide";
|
|
import {
|
|
saveToObsidian,
|
|
saveToBear,
|
|
saveToOctarine,
|
|
type ObsidianConfig,
|
|
type BearConfig,
|
|
type OctarineConfig,
|
|
type IntegrationResult,
|
|
} from "./integrations";
|
|
import {
|
|
generateSlug,
|
|
savePlan,
|
|
saveAnnotations,
|
|
saveFinalSnapshot,
|
|
saveToHistory,
|
|
getPlanVersion,
|
|
getPlanVersionPath,
|
|
getVersionCount,
|
|
listVersions,
|
|
listArchivedPlans,
|
|
readArchivedPlan,
|
|
type ArchivedPlan,
|
|
} from "./storage";
|
|
import { getRepoInfo } from "./repo";
|
|
import { detectProjectName } from "./project";
|
|
import { saveConfig, detectGitUser, getServerConfig } from "./config";
|
|
import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, type OpencodeClient } from "./shared-handlers";
|
|
import { contentHash, deleteDraft } from "./draft";
|
|
import { handleDoc, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc, handleFileBrowserFiles } from "./reference-handlers";
|
|
import { createEditorAnnotationHandler } from "./editor-annotations";
|
|
import { isWSL } from "./browser";
|
|
|
|
// Re-export utilities
|
|
export { isRemoteSession, getServerPort } from "./remote";
|
|
export { openBrowser } from "./browser";
|
|
export * from "./integrations";
|
|
export * from "./storage";
|
|
export { handleServerReady } from "./shared-handlers";
|
|
export { type VaultNode, buildFileTree } from "@plannotator/shared/reference-common";
|
|
|
|
// --- Types ---
|
|
|
|
export interface ServerOptions {
|
|
/** The plan markdown content */
|
|
plan: string;
|
|
/** Origin identifier (e.g., "claude-code", "opencode") */
|
|
origin: Origin;
|
|
/** HTML content to serve for the UI */
|
|
htmlContent: string;
|
|
/** Current permission mode to preserve (Claude Code only) */
|
|
permissionMode?: string;
|
|
/** Whether URL sharing is enabled (default: true) */
|
|
sharingEnabled?: boolean;
|
|
/** Custom base URL for share links (default: https://share.plannotator.ai) */
|
|
shareBaseUrl?: string;
|
|
/** Base URL of the paste service API for short URL sharing */
|
|
pasteApiUrl?: string;
|
|
/** Called when server starts with the URL, remote status, and port */
|
|
onReady?: (url: string, isRemote: boolean, port: number) => void;
|
|
/** OpenCode client for querying available agents (OpenCode only) */
|
|
opencodeClient?: OpencodeClient;
|
|
/** When set to "archive", server runs in read-only archive browser mode */
|
|
mode?: "archive";
|
|
/** Custom plan save path — used by archive mode to find saved plans */
|
|
customPlanPath?: string | null;
|
|
}
|
|
|
|
export interface ServerResult {
|
|
/** The port the server is running on */
|
|
port: number;
|
|
/** The full URL to access the server */
|
|
url: string;
|
|
/** Whether running in remote mode */
|
|
isRemote: boolean;
|
|
/** Wait for user decision (approve/deny) */
|
|
waitForDecision: () => Promise<{
|
|
approved: boolean;
|
|
feedback?: string;
|
|
savedPath?: string;
|
|
agentSwitch?: string;
|
|
permissionMode?: string;
|
|
}>;
|
|
/** Wait for user to close (archive mode only) */
|
|
waitForDone?: () => Promise<void>;
|
|
/** Stop the server */
|
|
stop: () => void;
|
|
}
|
|
|
|
// --- Server Implementation ---
|
|
|
|
const MAX_RETRIES = 5;
|
|
const RETRY_DELAY_MS = 500;
|
|
|
|
/**
|
|
* Start the Plannotator server
|
|
*
|
|
* Handles:
|
|
* - Remote detection and port configuration
|
|
* - All API routes (/api/plan, /api/approve, /api/deny, etc.)
|
|
* - Obsidian/Bear integrations
|
|
* - Port conflict retries
|
|
*/
|
|
export async function startPlannotatorServer(
|
|
options: ServerOptions
|
|
): Promise<ServerResult> {
|
|
const { plan, origin, htmlContent, permissionMode, sharingEnabled = true, shareBaseUrl, pasteApiUrl, onReady, mode, customPlanPath } = options;
|
|
|
|
const isRemote = isRemoteSession();
|
|
const configuredPort = getServerPort();
|
|
const wslFlag = await isWSL();
|
|
const gitUser = detectGitUser();
|
|
|
|
// --- Archive mode setup ---
|
|
let archivePlans: ArchivedPlan[] = [];
|
|
let initialArchivePlan = "";
|
|
let resolveDone: (() => void) | undefined;
|
|
let donePromise: Promise<void> | undefined;
|
|
|
|
if (mode === "archive") {
|
|
archivePlans = listArchivedPlans(customPlanPath ?? undefined);
|
|
initialArchivePlan = archivePlans.length > 0
|
|
? readArchivedPlan(archivePlans[0].filename, customPlanPath ?? undefined) ?? ""
|
|
: "";
|
|
donePromise = new Promise<void>((resolve) => { resolveDone = resolve; });
|
|
}
|
|
|
|
// --- Plan review mode setup (skip in archive mode) ---
|
|
const draftKey = mode !== "archive" ? contentHash(plan) : "";
|
|
const editorAnnotations = mode !== "archive" ? createEditorAnnotationHandler() : null;
|
|
const slug = mode !== "archive" ? generateSlug(plan) : "";
|
|
|
|
// Lazy cache for in-session archive browsing (plan review sidebar tab)
|
|
let cachedArchivePlans: ReturnType<typeof listArchivedPlans> | null = null;
|
|
|
|
// Plan-specific: repo info, version history, decision promise
|
|
let repoInfo: Awaited<ReturnType<typeof getRepoInfo>> | null = null;
|
|
let project = "";
|
|
let currentPlanPath = "";
|
|
let previousPlan: string | null = null;
|
|
let versionInfo = { version: 0, totalVersions: 0, project: "" };
|
|
|
|
let resolveDecision: (result: {
|
|
approved: boolean;
|
|
feedback?: string;
|
|
savedPath?: string;
|
|
agentSwitch?: string;
|
|
permissionMode?: string;
|
|
}) => void;
|
|
let decisionPromise: Promise<{
|
|
approved: boolean;
|
|
feedback?: string;
|
|
savedPath?: string;
|
|
agentSwitch?: string;
|
|
permissionMode?: string;
|
|
}>;
|
|
|
|
if (mode !== "archive") {
|
|
repoInfo = await getRepoInfo();
|
|
project = (await detectProjectName()) ?? "_unknown";
|
|
const historyResult = saveToHistory(project, slug, plan);
|
|
currentPlanPath = historyResult.path;
|
|
previousPlan =
|
|
historyResult.version > 1
|
|
? getPlanVersion(project, slug, historyResult.version - 1)
|
|
: null;
|
|
versionInfo = {
|
|
version: historyResult.version,
|
|
totalVersions: getVersionCount(project, slug),
|
|
project,
|
|
};
|
|
|
|
decisionPromise = new Promise((resolve) => {
|
|
resolveDecision = resolve;
|
|
});
|
|
} else {
|
|
// Never-resolving promise — archive mode uses waitForDone instead
|
|
decisionPromise = new Promise(() => {});
|
|
}
|
|
|
|
// Start server with retry logic
|
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
try {
|
|
server = Bun.serve({
|
|
port: configuredPort,
|
|
|
|
async fetch(req) {
|
|
const url = new URL(req.url);
|
|
|
|
// API: Get a specific plan version from history
|
|
if (url.pathname === "/api/plan/version") {
|
|
const vParam = url.searchParams.get("v");
|
|
if (!vParam) {
|
|
return new Response("Missing v parameter", { status: 400 });
|
|
}
|
|
const v = parseInt(vParam, 10);
|
|
if (isNaN(v) || v < 1) {
|
|
return new Response("Invalid version number", { status: 400 });
|
|
}
|
|
const content = getPlanVersion(project, slug, v);
|
|
if (content === null) {
|
|
return Response.json({ error: "Version not found" }, { status: 404 });
|
|
}
|
|
return Response.json({ plan: content, version: v });
|
|
}
|
|
|
|
// API: List all versions for the current plan
|
|
if (url.pathname === "/api/plan/versions") {
|
|
return Response.json({
|
|
project,
|
|
slug,
|
|
versions: listVersions(project, slug),
|
|
});
|
|
}
|
|
|
|
// API: List archived plans (from ~/.plannotator/plans/)
|
|
// Cached for session lifetime — new plans won't appear during a single review
|
|
if (url.pathname === "/api/archive/plans" && req.method === "GET") {
|
|
const customPath = url.searchParams.get("customPath") || undefined;
|
|
if (!cachedArchivePlans) cachedArchivePlans = listArchivedPlans(customPath);
|
|
return Response.json({ plans: cachedArchivePlans });
|
|
}
|
|
|
|
// API: Get a specific archived plan
|
|
if (url.pathname === "/api/archive/plan" && req.method === "GET") {
|
|
const filename = url.searchParams.get("filename");
|
|
if (!filename) {
|
|
return Response.json({ error: "Missing filename parameter" }, { status: 400 });
|
|
}
|
|
const customPath = url.searchParams.get("customPath") || undefined;
|
|
const content = readArchivedPlan(filename, customPath);
|
|
if (content === null) {
|
|
return Response.json({ error: "Plan not found" }, { status: 404 });
|
|
}
|
|
return Response.json({ markdown: content, filepath: filename });
|
|
}
|
|
|
|
// API: Close archive browser (archive mode only)
|
|
if (url.pathname === "/api/done" && req.method === "POST") {
|
|
resolveDone?.();
|
|
return Response.json({ ok: true });
|
|
}
|
|
|
|
// API: Get plan content
|
|
if (url.pathname === "/api/plan") {
|
|
if (mode === "archive") {
|
|
return Response.json({
|
|
plan: initialArchivePlan,
|
|
origin,
|
|
mode: "archive",
|
|
archivePlans,
|
|
sharingEnabled,
|
|
shareBaseUrl,
|
|
isWSL: wslFlag,
|
|
serverConfig: getServerConfig(gitUser),
|
|
});
|
|
}
|
|
return Response.json({ plan, origin, permissionMode, sharingEnabled, shareBaseUrl, pasteApiUrl, repoInfo, previousPlan, versionInfo, projectRoot: process.cwd(), isWSL: wslFlag, serverConfig: getServerConfig(gitUser) });
|
|
}
|
|
|
|
// API: Serve a linked markdown document
|
|
if (url.pathname === "/api/doc" && req.method === "GET") {
|
|
return handleDoc(req);
|
|
}
|
|
|
|
// API: Update user config (write-back to ~/.plannotator/config.json)
|
|
if (url.pathname === "/api/config" && req.method === "POST") {
|
|
try {
|
|
const body = (await req.json()) as { displayName?: string };
|
|
if (body.displayName !== undefined) {
|
|
saveConfig({ displayName: body.displayName });
|
|
}
|
|
return Response.json({ ok: true });
|
|
} catch {
|
|
return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
// API: Serve images (local paths or temp uploads)
|
|
if (url.pathname === "/api/image") {
|
|
return handleImage(req);
|
|
}
|
|
|
|
// API: Upload image -> save to temp -> return path
|
|
if (url.pathname === "/api/upload" && req.method === "POST") {
|
|
return handleUpload(req);
|
|
}
|
|
|
|
// API: Open plan diff in VS Code
|
|
if (url.pathname === "/api/plan/vscode-diff" && req.method === "POST") {
|
|
try {
|
|
const body = (await req.json()) as { baseVersion: number };
|
|
|
|
if (!body.baseVersion) {
|
|
return Response.json({ error: "Missing baseVersion" }, { status: 400 });
|
|
}
|
|
|
|
const basePath = getPlanVersionPath(project, slug, body.baseVersion);
|
|
if (!basePath) {
|
|
return Response.json({ error: `Version ${body.baseVersion} not found` }, { status: 404 });
|
|
}
|
|
|
|
const result = await openEditorDiff(basePath, currentPlanPath);
|
|
if ("error" in result) {
|
|
return Response.json({ error: result.error }, { status: 500 });
|
|
}
|
|
return Response.json({ ok: true });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Failed to open VS Code diff";
|
|
return Response.json({ error: message }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// API: Detect Obsidian vaults
|
|
if (url.pathname === "/api/obsidian/vaults") {
|
|
return handleObsidianVaults();
|
|
}
|
|
|
|
// API: List Obsidian vault files as a tree
|
|
if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {
|
|
return handleObsidianFiles(req);
|
|
}
|
|
|
|
// API: Read an Obsidian vault document
|
|
if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") {
|
|
return handleObsidianDoc(req);
|
|
}
|
|
|
|
// API: List markdown files in a directory as a tree
|
|
if (url.pathname === "/api/reference/files" && req.method === "GET") {
|
|
return handleFileBrowserFiles(req);
|
|
}
|
|
|
|
// API: Get available agents (OpenCode only)
|
|
if (url.pathname === "/api/agents") {
|
|
return handleAgents(options.opencodeClient);
|
|
}
|
|
|
|
// API: Annotation draft persistence
|
|
if (url.pathname === "/api/draft") {
|
|
if (req.method === "POST") return handleDraftSave(req, draftKey);
|
|
if (req.method === "DELETE") return handleDraftDelete(draftKey);
|
|
return handleDraftLoad(draftKey);
|
|
}
|
|
|
|
// API: Editor annotations (VS Code extension)
|
|
const editorResponse = await editorAnnotations?.handle(req, url);
|
|
if (editorResponse) return editorResponse;
|
|
|
|
// API: Save to notes (decoupled from approve/deny)
|
|
if (url.pathname === "/api/save-notes" && req.method === "POST") {
|
|
const results: { obsidian?: IntegrationResult; bear?: IntegrationResult; octarine?: IntegrationResult } = {};
|
|
|
|
try {
|
|
const body = (await req.json()) as {
|
|
obsidian?: ObsidianConfig;
|
|
bear?: BearConfig;
|
|
octarine?: OctarineConfig;
|
|
};
|
|
|
|
// Run integrations in parallel — they're independent
|
|
const promises: Promise<void>[] = [];
|
|
if (body.obsidian?.vaultPath && body.obsidian?.plan) {
|
|
promises.push(saveToObsidian(body.obsidian).then(r => { results.obsidian = r; }));
|
|
}
|
|
if (body.bear?.plan) {
|
|
promises.push(saveToBear(body.bear).then(r => { results.bear = r; }));
|
|
}
|
|
if (body.octarine?.plan && body.octarine?.workspace) {
|
|
promises.push(saveToOctarine(body.octarine).then(r => { results.octarine = r; }));
|
|
}
|
|
await Promise.allSettled(promises);
|
|
|
|
for (const [name, result] of Object.entries(results)) {
|
|
if (!result?.success && result) {
|
|
console.error(`[${name}] Save failed: ${result.error}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(`[Save Notes] Error:`, err);
|
|
return Response.json({ error: "Save failed" }, { status: 500 });
|
|
}
|
|
|
|
return Response.json({ ok: true, results });
|
|
}
|
|
|
|
// API: Approve plan
|
|
if (url.pathname === "/api/approve" && req.method === "POST") {
|
|
// Check for note integrations and optional feedback
|
|
let feedback: string | undefined;
|
|
let agentSwitch: string | undefined;
|
|
let requestedPermissionMode: string | undefined;
|
|
let planSaveEnabled = true; // default to enabled for backwards compat
|
|
let planSaveCustomPath: string | undefined;
|
|
try {
|
|
const body = (await req.json().catch(() => ({}))) as {
|
|
obsidian?: ObsidianConfig;
|
|
bear?: BearConfig;
|
|
octarine?: OctarineConfig;
|
|
feedback?: string;
|
|
agentSwitch?: string;
|
|
planSave?: { enabled: boolean; customPath?: string };
|
|
permissionMode?: string;
|
|
};
|
|
|
|
// Capture feedback if provided (for "approve with notes")
|
|
if (body.feedback) {
|
|
feedback = body.feedback;
|
|
}
|
|
|
|
// Capture agent switch setting for OpenCode
|
|
if (body.agentSwitch) {
|
|
agentSwitch = body.agentSwitch;
|
|
}
|
|
|
|
// Capture permission mode from client request (Claude Code)
|
|
if (body.permissionMode) {
|
|
requestedPermissionMode = body.permissionMode;
|
|
}
|
|
|
|
// Capture plan save settings
|
|
if (body.planSave !== undefined) {
|
|
planSaveEnabled = body.planSave.enabled;
|
|
planSaveCustomPath = body.planSave.customPath;
|
|
}
|
|
|
|
// Run integrations in parallel — they're independent
|
|
const integrationResults: Record<string, IntegrationResult> = {};
|
|
const integrationPromises: Promise<void>[] = [];
|
|
if (body.obsidian?.vaultPath && body.obsidian?.plan) {
|
|
integrationPromises.push(saveToObsidian(body.obsidian).then(r => { integrationResults.obsidian = r; }));
|
|
}
|
|
if (body.bear?.plan) {
|
|
integrationPromises.push(saveToBear(body.bear).then(r => { integrationResults.bear = r; }));
|
|
}
|
|
if (body.octarine?.plan && body.octarine?.workspace) {
|
|
integrationPromises.push(saveToOctarine(body.octarine).then(r => { integrationResults.octarine = r; }));
|
|
}
|
|
await Promise.allSettled(integrationPromises);
|
|
|
|
for (const [name, result] of Object.entries(integrationResults)) {
|
|
if (!result?.success && result) {
|
|
console.error(`[${name}] Save failed: ${result.error}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Don't block approval on integration errors
|
|
console.error(`[Integration] Error:`, err);
|
|
}
|
|
|
|
// Save annotations and final snapshot (if enabled)
|
|
let savedPath: string | undefined;
|
|
if (planSaveEnabled) {
|
|
const annotations = feedback || "";
|
|
if (annotations) {
|
|
saveAnnotations(slug, annotations, planSaveCustomPath);
|
|
}
|
|
savedPath = saveFinalSnapshot(slug, "approved", plan, annotations, planSaveCustomPath);
|
|
}
|
|
|
|
// Clean up draft on successful submit
|
|
deleteDraft(draftKey);
|
|
|
|
// Use permission mode from client request if provided, otherwise fall back to hook input
|
|
const effectivePermissionMode = requestedPermissionMode || permissionMode;
|
|
resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode });
|
|
return Response.json({ ok: true, savedPath });
|
|
}
|
|
|
|
// API: Deny with feedback
|
|
if (url.pathname === "/api/deny" && req.method === "POST") {
|
|
let feedback = "Plan rejected by user";
|
|
let planSaveEnabled = true; // default to enabled for backwards compat
|
|
let planSaveCustomPath: string | undefined;
|
|
try {
|
|
const body = (await req.json()) as {
|
|
feedback?: string;
|
|
planSave?: { enabled: boolean; customPath?: string };
|
|
};
|
|
feedback = body.feedback || feedback;
|
|
|
|
// Capture plan save settings
|
|
if (body.planSave !== undefined) {
|
|
planSaveEnabled = body.planSave.enabled;
|
|
planSaveCustomPath = body.planSave.customPath;
|
|
}
|
|
} catch {
|
|
// Use default feedback
|
|
}
|
|
|
|
// Save annotations and final snapshot (if enabled)
|
|
let savedPath: string | undefined;
|
|
if (planSaveEnabled) {
|
|
saveAnnotations(slug, feedback, planSaveCustomPath);
|
|
savedPath = saveFinalSnapshot(slug, "denied", plan, feedback, planSaveCustomPath);
|
|
}
|
|
|
|
deleteDraft(draftKey);
|
|
resolveDecision({ approved: false, feedback, savedPath });
|
|
return Response.json({ ok: true, savedPath });
|
|
}
|
|
|
|
// Favicon
|
|
if (url.pathname === "/favicon.svg") return handleFavicon();
|
|
|
|
// Serve embedded HTML for all other routes (SPA)
|
|
return new Response(htmlContent, {
|
|
headers: { "Content-Type": "text/html" },
|
|
});
|
|
},
|
|
});
|
|
|
|
break; // Success, exit retry loop
|
|
} catch (err: unknown) {
|
|
const isAddressInUse =
|
|
err instanceof Error && err.message.includes("EADDRINUSE");
|
|
|
|
if (isAddressInUse && attempt < MAX_RETRIES) {
|
|
await Bun.sleep(RETRY_DELAY_MS);
|
|
continue;
|
|
}
|
|
|
|
if (isAddressInUse) {
|
|
const hint = isRemote ? " (set PLANNOTATOR_PORT to use different port)" : "";
|
|
throw new Error(`Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}`);
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
if (!server) {
|
|
throw new Error("Failed to start server");
|
|
}
|
|
|
|
const port = server.port!;
|
|
const serverUrl = `http://localhost:${port}`;
|
|
|
|
// Notify caller that server is ready
|
|
if (onReady) {
|
|
onReady(serverUrl, isRemote, port);
|
|
}
|
|
|
|
return {
|
|
port,
|
|
url: serverUrl,
|
|
isRemote,
|
|
waitForDecision: () => decisionPromise,
|
|
...(donePromise && { waitForDone: () => donePromise }),
|
|
stop: () => server.stop(),
|
|
};
|
|
}
|