2026-02-21 11:43:46 -08:00
|
|
|
/**
|
|
|
|
|
* Node-compatible servers for Plannotator Pi extension.
|
|
|
|
|
*
|
|
|
|
|
* Pi loads extensions via jiti (Node.js), so we can't use Bun.serve().
|
|
|
|
|
* These are lightweight node:http servers implementing just the routes
|
|
|
|
|
* each UI needs — plan review, code review, and markdown annotation.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { createServer, type IncomingMessage, type Server } from "node:http";
|
2026-03-16 07:55:15 -07:00
|
|
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
2026-02-21 11:43:46 -08:00
|
|
|
import os from "node:os";
|
feat: plan archive browser (#369)
* feat: plan archive browser with in-session sidebar tab (#362)
Add `plannotator archive` subcommand and archive sidebar tab for browsing
saved plan decisions from ~/.plannotator/plans/. Plans show approved/denied
badges and timestamps. In-session mode uses the linked doc overlay pattern
so users can reference old plans while reviewing a current one.
- New archive server (packages/server/archive.ts) following annotate pattern
- New ArchiveBrowser sidebar component, reusable in both contexts
- Archive listing/parsing functions in storage.ts (reads decision snapshots)
- Archive endpoints on plan server for in-session use (/api/archive/plans)
- Remove dead "Other Plans" UI, projectPlans state, /api/plan/history endpoint
- Fix resize handle touch area covering scrollbars in sidebar/main content
- Fix sidebar tab bar overflow when narrow
For provenance purposes, this commit was AI assisted.
* fix: code quality sweep for plan archive
- Remove `as any` cast: add "archive" to SessionInfo.mode union
- Replace inline import() type with proper import for ArchivedPlan
- Replace any[] with ArchivedPlan[] in fetch response types
- Fix infinite re-fetch when archive is empty (use hasFetched ref)
- Cache archive plan list in plan server (avoid re-scanning filesystem)
- Document ResizeHandle side prop behavior
- Remove redundant comment on Viewer archiveInfo prop
For provenance purposes, this commit was AI assisted.
* chore: remove dead marketing components
Step.astro and Landing.astro are unused — landing page inlines
step markup and pages use Base.astro directly.
For provenance purposes, this commit was AI assisted.
* fix: address code review findings for plan archive
- Path traversal: use resolve() + trailing separator guard (matches reference-handlers.ts)
- Thread customPath into in-session archive endpoints via query param
- Sort same-day archive entries by mtime instead of title
- Clear selectedArchiveFile on linked doc back to prevent badge leak
- Hide archive tab in annotate mode (server doesn't serve those endpoints)
- Add targetTab param to useLinkedDoc.open() to preserve calling sidebar tab
- Replace mutable render variable with index-based date grouping
For provenance purposes, this commit was AI assisted.
* refactor: collapse standalone archive server into plan server
Delete packages/server/archive.ts (187 lines) — nearly all duplicated
from the plan server. Add mode:"archive" option to startPlannotatorServer
instead. Fixes two bugs from code review:
- handleArchiveCopy now splits on "# Plan Feedback" marker instead of
bare "---", preventing truncation at horizontal rules in plan content
- customPath support works in standalone archive mode (was only working
in-session because the standalone server never received it)
For provenance purposes, this commit was AI assisted.
* refactor: extract useArchive hook from App.tsx
Move archive state (archiveMode, plans, selectedFile, isLoading) and
handlers (select, fetchPlans, done, copy) into a dedicated useArchive
hook. Reduces App.tsx by ~75 lines and makes the archive feature
self-contained.
For provenance purposes, this commit was AI assisted.
* feat: Pi archive parity + eliminate server duplication
Move runtime-agnostic storage, draft, and project functions from
packages/server/ to packages/shared/ — eliminating ~250 lines of
duplicated code in Pi's server.ts. Server package becomes thin
re-exports, preserving all existing import paths.
Add archive mode to Pi's plan review server (mode, routes, waitForDone)
and register /plannotator-archive command in the Pi extension. Consolidate
ArchivedPlan type to single definition in shared/storage.ts.
Simplify archive copy to include full content with feedback.
For provenance purposes, this commit was AI assisted.
* fix: drop -core suffix from Pi shared copies
The -core suffix broke cross-file imports — storage.ts imports from
./project which didn't resolve to project-core.ts. Using the original
filenames (no collision) lets relative imports work naturally.
For provenance purposes, this commit was AI assisted.
* fix: archive custom path bugs, disable sharing, update docs
- Normalize planDir via resolve() in getPlanDir() to handle relative
paths and trailing slashes in the path traversal guard
- Re-fetch archive plans client-side with cookie-backed customPath
so standalone archive respects the user's configured save location
- Disable sharing in archive mode (read-only viewer, no need)
- Remove dead /api/plan/history endpoint and listProjectPlans import
from Pi extension
- Remove dead /api/plan/history mock from dev-mock-api
- Update CLAUDE.md and AGENTS.md: add archive flow, archive API
endpoints, shared package structure, correct storage location,
sidebar tab count, remove stale /api/plan/history references
- Update hook server docstring from four to five modes
For provenance purposes, this commit was AI assisted.
* fix: empty archive shows demo content, stale viewer after customPath fetch
- Clear demo markdown when archive opens with no plans (plan: "" was falsy,
so setMarkdown was never called)
- Remove redundant fetchPlans() from archive init — server already sends
archivePlans in initial response
- After fetchPlans() resolves with customPath results, auto-select and load
the first plan into the viewer
- Remove dead listProjectPlans re-export from server barrel
For provenance purposes, this commit was AI assisted.
* refactor: gitignore Pi shared copies, add @generated headers
Pi's copied .ts files (storage, draft, project, feedback-templates,
review-core) are build artifacts generated from packages/shared/. They
looked like editable source files, leading to confusion about which file
to edit. Now gitignored like the HTML copies, with @generated headers
prepended by the build script.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in CI before tests
The Pi .ts copies are now gitignored build artifacts. CI needs to
generate them before running tests since server.test.ts transitively
imports them via server.ts.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in release pipeline test job
Same fix as test.yml — the Pi .ts copies are gitignored, so the test
job in the release pipeline also needs to generate them before bun test.
For provenance purposes, this commit was AI assisted.
* fix: use block scalar in CI workflow to avoid YAML parse error
The inline `run:` had a colon in the printf string that YAML
interpreted as a mapping key. Switch to `run: |` block scalar.
For provenance purposes, this commit was AI assisted.
2026-03-23 11:31:38 -07:00
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
|
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
2026-03-16 07:55:15 -07:00
|
|
|
import { join, basename, resolve as resolvePath } from "node:path";
|
|
|
|
|
import { Readable } from "node:stream";
|
|
|
|
|
import {
|
|
|
|
|
type DiffOption,
|
|
|
|
|
type DiffType,
|
|
|
|
|
type GitCommandResult,
|
|
|
|
|
type GitContext,
|
|
|
|
|
type ReviewGitRuntime,
|
|
|
|
|
getFileContentsForDiff as getFileContentsForDiffCore,
|
|
|
|
|
getGitContext as getGitContextCore,
|
|
|
|
|
gitAddFile as gitAddFileCore,
|
|
|
|
|
gitResetFile as gitResetFileCore,
|
|
|
|
|
parseWorktreeDiffType,
|
|
|
|
|
runGitDiff as runGitDiffCore,
|
|
|
|
|
validateFilePath,
|
|
|
|
|
} from "./review-core.js";
|
feat: plan archive browser (#369)
* feat: plan archive browser with in-session sidebar tab (#362)
Add `plannotator archive` subcommand and archive sidebar tab for browsing
saved plan decisions from ~/.plannotator/plans/. Plans show approved/denied
badges and timestamps. In-session mode uses the linked doc overlay pattern
so users can reference old plans while reviewing a current one.
- New archive server (packages/server/archive.ts) following annotate pattern
- New ArchiveBrowser sidebar component, reusable in both contexts
- Archive listing/parsing functions in storage.ts (reads decision snapshots)
- Archive endpoints on plan server for in-session use (/api/archive/plans)
- Remove dead "Other Plans" UI, projectPlans state, /api/plan/history endpoint
- Fix resize handle touch area covering scrollbars in sidebar/main content
- Fix sidebar tab bar overflow when narrow
For provenance purposes, this commit was AI assisted.
* fix: code quality sweep for plan archive
- Remove `as any` cast: add "archive" to SessionInfo.mode union
- Replace inline import() type with proper import for ArchivedPlan
- Replace any[] with ArchivedPlan[] in fetch response types
- Fix infinite re-fetch when archive is empty (use hasFetched ref)
- Cache archive plan list in plan server (avoid re-scanning filesystem)
- Document ResizeHandle side prop behavior
- Remove redundant comment on Viewer archiveInfo prop
For provenance purposes, this commit was AI assisted.
* chore: remove dead marketing components
Step.astro and Landing.astro are unused — landing page inlines
step markup and pages use Base.astro directly.
For provenance purposes, this commit was AI assisted.
* fix: address code review findings for plan archive
- Path traversal: use resolve() + trailing separator guard (matches reference-handlers.ts)
- Thread customPath into in-session archive endpoints via query param
- Sort same-day archive entries by mtime instead of title
- Clear selectedArchiveFile on linked doc back to prevent badge leak
- Hide archive tab in annotate mode (server doesn't serve those endpoints)
- Add targetTab param to useLinkedDoc.open() to preserve calling sidebar tab
- Replace mutable render variable with index-based date grouping
For provenance purposes, this commit was AI assisted.
* refactor: collapse standalone archive server into plan server
Delete packages/server/archive.ts (187 lines) — nearly all duplicated
from the plan server. Add mode:"archive" option to startPlannotatorServer
instead. Fixes two bugs from code review:
- handleArchiveCopy now splits on "# Plan Feedback" marker instead of
bare "---", preventing truncation at horizontal rules in plan content
- customPath support works in standalone archive mode (was only working
in-session because the standalone server never received it)
For provenance purposes, this commit was AI assisted.
* refactor: extract useArchive hook from App.tsx
Move archive state (archiveMode, plans, selectedFile, isLoading) and
handlers (select, fetchPlans, done, copy) into a dedicated useArchive
hook. Reduces App.tsx by ~75 lines and makes the archive feature
self-contained.
For provenance purposes, this commit was AI assisted.
* feat: Pi archive parity + eliminate server duplication
Move runtime-agnostic storage, draft, and project functions from
packages/server/ to packages/shared/ — eliminating ~250 lines of
duplicated code in Pi's server.ts. Server package becomes thin
re-exports, preserving all existing import paths.
Add archive mode to Pi's plan review server (mode, routes, waitForDone)
and register /plannotator-archive command in the Pi extension. Consolidate
ArchivedPlan type to single definition in shared/storage.ts.
Simplify archive copy to include full content with feedback.
For provenance purposes, this commit was AI assisted.
* fix: drop -core suffix from Pi shared copies
The -core suffix broke cross-file imports — storage.ts imports from
./project which didn't resolve to project-core.ts. Using the original
filenames (no collision) lets relative imports work naturally.
For provenance purposes, this commit was AI assisted.
* fix: archive custom path bugs, disable sharing, update docs
- Normalize planDir via resolve() in getPlanDir() to handle relative
paths and trailing slashes in the path traversal guard
- Re-fetch archive plans client-side with cookie-backed customPath
so standalone archive respects the user's configured save location
- Disable sharing in archive mode (read-only viewer, no need)
- Remove dead /api/plan/history endpoint and listProjectPlans import
from Pi extension
- Remove dead /api/plan/history mock from dev-mock-api
- Update CLAUDE.md and AGENTS.md: add archive flow, archive API
endpoints, shared package structure, correct storage location,
sidebar tab count, remove stale /api/plan/history references
- Update hook server docstring from four to five modes
For provenance purposes, this commit was AI assisted.
* fix: empty archive shows demo content, stale viewer after customPath fetch
- Clear demo markdown when archive opens with no plans (plan: "" was falsy,
so setMarkdown was never called)
- Remove redundant fetchPlans() from archive init — server already sends
archivePlans in initial response
- After fetchPlans() resolves with customPath results, auto-select and load
the first plan into the viewer
- Remove dead listProjectPlans re-export from server barrel
For provenance purposes, this commit was AI assisted.
* refactor: gitignore Pi shared copies, add @generated headers
Pi's copied .ts files (storage, draft, project, feedback-templates,
review-core) are build artifacts generated from packages/shared/. They
looked like editable source files, leading to confusion about which file
to edit. Now gitignored like the HTML copies, with @generated headers
prepended by the build script.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in CI before tests
The Pi .ts copies are now gitignored build artifacts. CI needs to
generate them before running tests since server.test.ts transitively
imports them via server.ts.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in release pipeline test job
Same fix as test.yml — the Pi .ts copies are gitignored, so the test
job in the release pipeline also needs to generate them before bun test.
For provenance purposes, this commit was AI assisted.
* fix: use block scalar in CI workflow to avoid YAML parse error
The inline `run:` had a colon in the printf string that YAML
interpreted as a mapping key. Switch to `run: |` block scalar.
For provenance purposes, this commit was AI assisted.
2026-03-23 11:31:38 -07:00
|
|
|
import {
|
|
|
|
|
generateSlug,
|
|
|
|
|
saveToHistory,
|
|
|
|
|
getPlanVersion,
|
|
|
|
|
getVersionCount,
|
|
|
|
|
listVersions,
|
|
|
|
|
listArchivedPlans,
|
|
|
|
|
readArchivedPlan,
|
|
|
|
|
type ArchivedPlan,
|
|
|
|
|
} from "./storage.js";
|
|
|
|
|
import { contentHash, saveDraft, loadDraft, deleteDraft } from "./draft.js";
|
|
|
|
|
import { sanitizeTag } from "./project.js";
|
2026-02-21 11:43:46 -08:00
|
|
|
|
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function parseBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
let data = "";
|
|
|
|
|
req.on("data", (chunk: string) => (data += chunk));
|
|
|
|
|
req.on("end", () => {
|
|
|
|
|
try {
|
|
|
|
|
resolve(JSON.parse(data));
|
|
|
|
|
} catch {
|
|
|
|
|
resolve({});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function json(res: import("node:http").ServerResponse, data: unknown, status = 200): void {
|
|
|
|
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
|
|
|
res.end(JSON.stringify(data));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function html(res: import("node:http").ServerResponse, content: string): void {
|
|
|
|
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
|
|
|
res.end(content);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 07:55:15 -07:00
|
|
|
function send(
|
|
|
|
|
res: import("node:http").ServerResponse,
|
|
|
|
|
body: string | Buffer,
|
|
|
|
|
status = 200,
|
|
|
|
|
headers: Record<string, string> = {},
|
|
|
|
|
): void {
|
|
|
|
|
res.writeHead(status, headers);
|
|
|
|
|
res.end(body);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface EditorAnnotation {
|
|
|
|
|
id: string;
|
|
|
|
|
filePath: string;
|
|
|
|
|
selectedText: string;
|
|
|
|
|
lineStart: number;
|
|
|
|
|
lineEnd: number;
|
|
|
|
|
comment?: string;
|
|
|
|
|
createdAt: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createEditorAnnotationHandler() {
|
|
|
|
|
const annotations: EditorAnnotation[] = [];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
async handle(req: IncomingMessage, res: import("node:http").ServerResponse, url: URL): Promise<boolean> {
|
|
|
|
|
if (url.pathname === "/api/editor-annotations" && req.method === "GET") {
|
|
|
|
|
json(res, { annotations });
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (url.pathname === "/api/editor-annotation" && req.method === "POST") {
|
|
|
|
|
const body = await parseBody(req);
|
|
|
|
|
if (!body.filePath || !body.selectedText || !body.lineStart || !body.lineEnd) {
|
|
|
|
|
json(res, { error: "Missing required fields" }, 400);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const annotation: EditorAnnotation = {
|
|
|
|
|
id: randomUUID(),
|
|
|
|
|
filePath: String(body.filePath),
|
|
|
|
|
selectedText: String(body.selectedText),
|
|
|
|
|
lineStart: Number(body.lineStart),
|
|
|
|
|
lineEnd: Number(body.lineEnd),
|
|
|
|
|
comment: typeof body.comment === "string" ? body.comment : undefined,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
annotations.push(annotation);
|
|
|
|
|
json(res, { id: annotation.id });
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (url.pathname === "/api/editor-annotation" && req.method === "DELETE") {
|
|
|
|
|
const id = url.searchParams.get("id");
|
|
|
|
|
if (!id) {
|
|
|
|
|
json(res, { error: "Missing id parameter" }, 400);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
const idx = annotations.findIndex((annotation) => annotation.id === id);
|
|
|
|
|
if (idx !== -1) {
|
|
|
|
|
annotations.splice(idx, 1);
|
|
|
|
|
}
|
|
|
|
|
json(res, { ok: true });
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ALLOWED_IMAGE_EXTENSIONS = new Set([
|
|
|
|
|
"png",
|
|
|
|
|
"jpg",
|
|
|
|
|
"jpeg",
|
|
|
|
|
"gif",
|
|
|
|
|
"webp",
|
|
|
|
|
"svg",
|
|
|
|
|
"bmp",
|
|
|
|
|
"ico",
|
|
|
|
|
"tiff",
|
|
|
|
|
"tif",
|
|
|
|
|
"avif",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const IMAGE_CONTENT_TYPES: Record<string, string> = {
|
|
|
|
|
png: "image/png",
|
|
|
|
|
jpg: "image/jpeg",
|
|
|
|
|
jpeg: "image/jpeg",
|
|
|
|
|
gif: "image/gif",
|
|
|
|
|
webp: "image/webp",
|
|
|
|
|
svg: "image/svg+xml",
|
|
|
|
|
bmp: "image/bmp",
|
|
|
|
|
ico: "image/x-icon",
|
|
|
|
|
tiff: "image/tiff",
|
|
|
|
|
tif: "image/tiff",
|
|
|
|
|
avif: "image/avif",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const UPLOAD_DIR = join(os.tmpdir(), "plannotator");
|
|
|
|
|
|
|
|
|
|
function getExtension(filePath: string): string {
|
|
|
|
|
const lastDot = filePath.lastIndexOf(".");
|
|
|
|
|
if (lastDot === -1) return "";
|
|
|
|
|
return filePath.slice(lastDot + 1).toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateImagePath(rawPath: string): {
|
|
|
|
|
valid: boolean;
|
|
|
|
|
resolved: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
} {
|
|
|
|
|
const resolved = resolvePath(rawPath);
|
|
|
|
|
const ext = getExtension(resolved);
|
|
|
|
|
|
|
|
|
|
if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
|
|
|
|
|
return {
|
|
|
|
|
valid: false,
|
|
|
|
|
resolved,
|
|
|
|
|
error: "Path does not point to a supported image file",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { valid: true, resolved };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateUploadExtension(fileName: string): {
|
|
|
|
|
valid: boolean;
|
|
|
|
|
ext: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
} {
|
|
|
|
|
const ext = getExtension(fileName) || "png";
|
|
|
|
|
if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
|
|
|
|
|
return {
|
|
|
|
|
valid: false,
|
|
|
|
|
ext,
|
|
|
|
|
error: `File extension ".${ext}" is not a supported image type`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { valid: true, ext };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getImageContentType(filePath: string): string {
|
|
|
|
|
return IMAGE_CONTENT_TYPES[getExtension(filePath)] || "application/octet-stream";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toWebRequest(req: IncomingMessage): Request {
|
|
|
|
|
const headers = new Headers();
|
|
|
|
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
|
|
|
if (value === undefined) continue;
|
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
|
for (const item of value) headers.append(key, item);
|
|
|
|
|
} else {
|
|
|
|
|
headers.set(key, value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const init: RequestInit & { duplex?: "half" } = {
|
|
|
|
|
method: req.method,
|
|
|
|
|
headers,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
|
|
|
init.body = Readable.toWeb(req) as unknown as BodyInit;
|
|
|
|
|
init.duplex = "half";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Request(`http://localhost${req.url ?? "/"}`, init);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 04:12:39 +01:00
|
|
|
const DEFAULT_REMOTE_PORT = 19432;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if running in a remote session (SSH, devcontainer, etc.)
|
|
|
|
|
* Honors PLANNOTATOR_REMOTE env var, or detects SSH_TTY/SSH_CONNECTION.
|
|
|
|
|
*/
|
|
|
|
|
function isRemoteSession(): boolean {
|
|
|
|
|
const remote = process.env.PLANNOTATOR_REMOTE;
|
|
|
|
|
if (remote === "1" || remote?.toLowerCase() === "true") {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
// Legacy SSH detection
|
|
|
|
|
if (process.env.SSH_TTY || process.env.SSH_CONNECTION) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the server port to use.
|
|
|
|
|
* - PLANNOTATOR_PORT env var takes precedence
|
|
|
|
|
* - Remote sessions default to 19432 (for port forwarding)
|
|
|
|
|
* - Local sessions use random port
|
|
|
|
|
* Returns { port, portSource } so caller can notify user if needed.
|
|
|
|
|
*/
|
|
|
|
|
function getServerPort(): { port: number; portSource: "env" | "remote-default" | "random" } {
|
|
|
|
|
const envPort = process.env.PLANNOTATOR_PORT;
|
|
|
|
|
if (envPort) {
|
|
|
|
|
const parsed = parseInt(envPort, 10);
|
|
|
|
|
if (!isNaN(parsed) && parsed > 0 && parsed < 65536) {
|
|
|
|
|
return { port: parsed, portSource: "env" };
|
|
|
|
|
}
|
|
|
|
|
// Invalid port - fall back silently, caller can check env var themselves
|
|
|
|
|
}
|
|
|
|
|
if (isRemoteSession()) {
|
|
|
|
|
return { port: DEFAULT_REMOTE_PORT, portSource: "remote-default" };
|
|
|
|
|
}
|
|
|
|
|
return { port: 0, portSource: "random" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MAX_RETRIES = 5;
|
|
|
|
|
const RETRY_DELAY_MS = 500;
|
|
|
|
|
|
|
|
|
|
async function listenOnPort(server: Server): Promise<{ port: number; portSource: "env" | "remote-default" | "random" }> {
|
|
|
|
|
const result = getServerPort();
|
|
|
|
|
|
|
|
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
server.once("error", reject);
|
2026-03-16 07:55:15 -07:00
|
|
|
server.listen(result.port, isRemoteSession() ? "0.0.0.0" : "127.0.0.1", () => {
|
2026-03-16 04:12:39 +01:00
|
|
|
server.removeListener("error", reject);
|
|
|
|
|
resolve();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
const addr = server.address() as { port: number };
|
|
|
|
|
return { port: addr.port, portSource: result.portSource };
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
const isAddressInUse = err instanceof Error && err.message.includes("EADDRINUSE");
|
|
|
|
|
if (isAddressInUse && attempt < MAX_RETRIES) {
|
|
|
|
|
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (isAddressInUse) {
|
|
|
|
|
const hint = isRemoteSession() ? " (set PLANNOTATOR_PORT to use a different port)" : "";
|
|
|
|
|
throw new Error(`Port ${result.port} in use after ${MAX_RETRIES} retries${hint}`);
|
|
|
|
|
}
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unreachable, but satisfies TypeScript
|
|
|
|
|
throw new Error("Failed to bind port");
|
2026-02-21 11:43:46 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Open URL in system browser (Node-compatible, no Bun $ dependency).
|
|
|
|
|
* Honors PLANNOTATOR_BROWSER and BROWSER env vars, matching packages/server/browser.ts.
|
2026-03-16 04:12:39 +01:00
|
|
|
* Returns { opened: true } if browser was opened, { opened: false, isRemote: true, url } if remote session.
|
2026-02-21 11:43:46 -08:00
|
|
|
*/
|
2026-03-16 04:12:39 +01:00
|
|
|
export function openBrowser(url: string): { opened: boolean; isRemote?: boolean; url?: string } {
|
|
|
|
|
const browser = process.env.PLANNOTATOR_BROWSER || process.env.BROWSER;
|
|
|
|
|
if (isRemoteSession() && !browser) {
|
|
|
|
|
return { opened: false, isRemote: true, url };
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 11:43:46 -08:00
|
|
|
try {
|
|
|
|
|
const platform = process.platform;
|
|
|
|
|
const wsl = platform === "linux" && os.release().toLowerCase().includes("microsoft");
|
|
|
|
|
|
2026-03-14 17:40:44 -07:00
|
|
|
let cmd: string;
|
|
|
|
|
let args: string[];
|
|
|
|
|
|
2026-02-21 11:43:46 -08:00
|
|
|
if (browser) {
|
|
|
|
|
if (process.env.PLANNOTATOR_BROWSER && platform === "darwin") {
|
2026-03-14 17:40:44 -07:00
|
|
|
cmd = "open";
|
|
|
|
|
args = ["-a", browser, url];
|
2026-02-21 11:43:46 -08:00
|
|
|
} else if (platform === "win32" || wsl) {
|
2026-03-14 17:40:44 -07:00
|
|
|
cmd = "cmd.exe";
|
|
|
|
|
args = ["/c", "start", "", browser, url];
|
2026-02-21 11:43:46 -08:00
|
|
|
} else {
|
2026-03-14 17:40:44 -07:00
|
|
|
cmd = browser;
|
|
|
|
|
args = [url];
|
2026-02-21 11:43:46 -08:00
|
|
|
}
|
|
|
|
|
} else if (platform === "win32" || wsl) {
|
2026-03-14 17:40:44 -07:00
|
|
|
cmd = "cmd.exe";
|
|
|
|
|
args = ["/c", "start", "", url];
|
2026-02-21 11:43:46 -08:00
|
|
|
} else if (platform === "darwin") {
|
2026-03-14 17:40:44 -07:00
|
|
|
cmd = "open";
|
|
|
|
|
args = [url];
|
2026-02-21 11:43:46 -08:00
|
|
|
} else {
|
2026-03-14 17:40:44 -07:00
|
|
|
cmd = "xdg-open";
|
|
|
|
|
args = [url];
|
2026-02-21 11:43:46 -08:00
|
|
|
}
|
2026-03-14 17:40:44 -07:00
|
|
|
|
|
|
|
|
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
|
|
|
|
child.once("error", () => {});
|
|
|
|
|
child.unref();
|
2026-03-16 04:12:39 +01:00
|
|
|
return { opened: true };
|
2026-02-21 11:43:46 -08:00
|
|
|
} catch {
|
2026-03-16 04:12:39 +01:00
|
|
|
return { opened: false };
|
2026-02-21 11:43:46 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat: plan archive browser (#369)
* feat: plan archive browser with in-session sidebar tab (#362)
Add `plannotator archive` subcommand and archive sidebar tab for browsing
saved plan decisions from ~/.plannotator/plans/. Plans show approved/denied
badges and timestamps. In-session mode uses the linked doc overlay pattern
so users can reference old plans while reviewing a current one.
- New archive server (packages/server/archive.ts) following annotate pattern
- New ArchiveBrowser sidebar component, reusable in both contexts
- Archive listing/parsing functions in storage.ts (reads decision snapshots)
- Archive endpoints on plan server for in-session use (/api/archive/plans)
- Remove dead "Other Plans" UI, projectPlans state, /api/plan/history endpoint
- Fix resize handle touch area covering scrollbars in sidebar/main content
- Fix sidebar tab bar overflow when narrow
For provenance purposes, this commit was AI assisted.
* fix: code quality sweep for plan archive
- Remove `as any` cast: add "archive" to SessionInfo.mode union
- Replace inline import() type with proper import for ArchivedPlan
- Replace any[] with ArchivedPlan[] in fetch response types
- Fix infinite re-fetch when archive is empty (use hasFetched ref)
- Cache archive plan list in plan server (avoid re-scanning filesystem)
- Document ResizeHandle side prop behavior
- Remove redundant comment on Viewer archiveInfo prop
For provenance purposes, this commit was AI assisted.
* chore: remove dead marketing components
Step.astro and Landing.astro are unused — landing page inlines
step markup and pages use Base.astro directly.
For provenance purposes, this commit was AI assisted.
* fix: address code review findings for plan archive
- Path traversal: use resolve() + trailing separator guard (matches reference-handlers.ts)
- Thread customPath into in-session archive endpoints via query param
- Sort same-day archive entries by mtime instead of title
- Clear selectedArchiveFile on linked doc back to prevent badge leak
- Hide archive tab in annotate mode (server doesn't serve those endpoints)
- Add targetTab param to useLinkedDoc.open() to preserve calling sidebar tab
- Replace mutable render variable with index-based date grouping
For provenance purposes, this commit was AI assisted.
* refactor: collapse standalone archive server into plan server
Delete packages/server/archive.ts (187 lines) — nearly all duplicated
from the plan server. Add mode:"archive" option to startPlannotatorServer
instead. Fixes two bugs from code review:
- handleArchiveCopy now splits on "# Plan Feedback" marker instead of
bare "---", preventing truncation at horizontal rules in plan content
- customPath support works in standalone archive mode (was only working
in-session because the standalone server never received it)
For provenance purposes, this commit was AI assisted.
* refactor: extract useArchive hook from App.tsx
Move archive state (archiveMode, plans, selectedFile, isLoading) and
handlers (select, fetchPlans, done, copy) into a dedicated useArchive
hook. Reduces App.tsx by ~75 lines and makes the archive feature
self-contained.
For provenance purposes, this commit was AI assisted.
* feat: Pi archive parity + eliminate server duplication
Move runtime-agnostic storage, draft, and project functions from
packages/server/ to packages/shared/ — eliminating ~250 lines of
duplicated code in Pi's server.ts. Server package becomes thin
re-exports, preserving all existing import paths.
Add archive mode to Pi's plan review server (mode, routes, waitForDone)
and register /plannotator-archive command in the Pi extension. Consolidate
ArchivedPlan type to single definition in shared/storage.ts.
Simplify archive copy to include full content with feedback.
For provenance purposes, this commit was AI assisted.
* fix: drop -core suffix from Pi shared copies
The -core suffix broke cross-file imports — storage.ts imports from
./project which didn't resolve to project-core.ts. Using the original
filenames (no collision) lets relative imports work naturally.
For provenance purposes, this commit was AI assisted.
* fix: archive custom path bugs, disable sharing, update docs
- Normalize planDir via resolve() in getPlanDir() to handle relative
paths and trailing slashes in the path traversal guard
- Re-fetch archive plans client-side with cookie-backed customPath
so standalone archive respects the user's configured save location
- Disable sharing in archive mode (read-only viewer, no need)
- Remove dead /api/plan/history endpoint and listProjectPlans import
from Pi extension
- Remove dead /api/plan/history mock from dev-mock-api
- Update CLAUDE.md and AGENTS.md: add archive flow, archive API
endpoints, shared package structure, correct storage location,
sidebar tab count, remove stale /api/plan/history references
- Update hook server docstring from four to five modes
For provenance purposes, this commit was AI assisted.
* fix: empty archive shows demo content, stale viewer after customPath fetch
- Clear demo markdown when archive opens with no plans (plan: "" was falsy,
so setMarkdown was never called)
- Remove redundant fetchPlans() from archive init — server already sends
archivePlans in initial response
- After fetchPlans() resolves with customPath results, auto-select and load
the first plan into the viewer
- Remove dead listProjectPlans re-export from server barrel
For provenance purposes, this commit was AI assisted.
* refactor: gitignore Pi shared copies, add @generated headers
Pi's copied .ts files (storage, draft, project, feedback-templates,
review-core) are build artifacts generated from packages/shared/. They
looked like editable source files, leading to confusion about which file
to edit. Now gitignored like the HTML copies, with @generated headers
prepended by the build script.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in CI before tests
The Pi .ts copies are now gitignored build artifacts. CI needs to
generate them before running tests since server.test.ts transitively
imports them via server.ts.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in release pipeline test job
Same fix as test.yml — the Pi .ts copies are gitignored, so the test
job in the release pipeline also needs to generate them before bun test.
For provenance purposes, this commit was AI assisted.
* fix: use block scalar in CI workflow to avoid YAML parse error
The inline `run:` had a colon in the printf string that YAML
interpreted as a mapping key. Switch to `run: |` block scalar.
For provenance purposes, this commit was AI assisted.
2026-03-23 11:31:38 -07:00
|
|
|
// ── Pi-specific helpers ──────────────────────────────────────────────────
|
2026-02-22 11:36:18 -08:00
|
|
|
|
|
|
|
|
function detectProjectName(): string {
|
|
|
|
|
try {
|
|
|
|
|
const toplevel = execSync("git rev-parse --show-toplevel", {
|
|
|
|
|
encoding: "utf-8",
|
|
|
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
|
|
|
}).trim();
|
|
|
|
|
const name = basename(toplevel);
|
|
|
|
|
return sanitizeTag(name) ?? "_unknown";
|
|
|
|
|
} catch {
|
|
|
|
|
// Not a git repo — fall back to cwd
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const name = basename(process.cwd());
|
|
|
|
|
return sanitizeTag(name) ?? "_unknown";
|
|
|
|
|
} catch {
|
|
|
|
|
return "_unknown";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 07:55:15 -07:00
|
|
|
function parseRemoteUrl(url: string): string | null {
|
|
|
|
|
if (!url) return null;
|
|
|
|
|
|
|
|
|
|
const sshMatch = url.match(/:([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
|
|
|
if (sshMatch) return sshMatch[1];
|
|
|
|
|
|
|
|
|
|
const httpsMatch = url.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
|
|
|
if (httpsMatch) return httpsMatch[1];
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDirName(path: string): string | null {
|
|
|
|
|
if (!path) return null;
|
|
|
|
|
const trimmed = path.trim().replace(/\/+$/, "");
|
|
|
|
|
const parts = trimmed.split("/");
|
|
|
|
|
return parts[parts.length - 1] || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getRepoInfo(): { display: string; branch?: string } | null {
|
|
|
|
|
const branch = git("rev-parse --abbrev-ref HEAD");
|
|
|
|
|
const safeBranch = branch && branch !== "HEAD" ? branch : undefined;
|
|
|
|
|
|
|
|
|
|
const originUrl = git("remote get-url origin");
|
|
|
|
|
const orgRepo = parseRemoteUrl(originUrl);
|
|
|
|
|
if (orgRepo) {
|
|
|
|
|
return { display: orgRepo, branch: safeBranch };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const topLevel = git("rev-parse --show-toplevel");
|
|
|
|
|
const repoName = getDirName(topLevel);
|
|
|
|
|
if (repoName) {
|
|
|
|
|
return { display: repoName, branch: safeBranch };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cwdName = getDirName(process.cwd());
|
|
|
|
|
if (cwdName) {
|
|
|
|
|
return { display: cwdName };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 11:43:46 -08:00
|
|
|
// ── Plan Review Server ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface PlanServerResult {
|
|
|
|
|
port: number;
|
2026-03-16 04:12:39 +01:00
|
|
|
portSource: "env" | "remote-default" | "random";
|
2026-02-21 11:43:46 -08:00
|
|
|
url: string;
|
|
|
|
|
waitForDecision: () => Promise<{ approved: boolean; feedback?: string }>;
|
feat: plan archive browser (#369)
* feat: plan archive browser with in-session sidebar tab (#362)
Add `plannotator archive` subcommand and archive sidebar tab for browsing
saved plan decisions from ~/.plannotator/plans/. Plans show approved/denied
badges and timestamps. In-session mode uses the linked doc overlay pattern
so users can reference old plans while reviewing a current one.
- New archive server (packages/server/archive.ts) following annotate pattern
- New ArchiveBrowser sidebar component, reusable in both contexts
- Archive listing/parsing functions in storage.ts (reads decision snapshots)
- Archive endpoints on plan server for in-session use (/api/archive/plans)
- Remove dead "Other Plans" UI, projectPlans state, /api/plan/history endpoint
- Fix resize handle touch area covering scrollbars in sidebar/main content
- Fix sidebar tab bar overflow when narrow
For provenance purposes, this commit was AI assisted.
* fix: code quality sweep for plan archive
- Remove `as any` cast: add "archive" to SessionInfo.mode union
- Replace inline import() type with proper import for ArchivedPlan
- Replace any[] with ArchivedPlan[] in fetch response types
- Fix infinite re-fetch when archive is empty (use hasFetched ref)
- Cache archive plan list in plan server (avoid re-scanning filesystem)
- Document ResizeHandle side prop behavior
- Remove redundant comment on Viewer archiveInfo prop
For provenance purposes, this commit was AI assisted.
* chore: remove dead marketing components
Step.astro and Landing.astro are unused — landing page inlines
step markup and pages use Base.astro directly.
For provenance purposes, this commit was AI assisted.
* fix: address code review findings for plan archive
- Path traversal: use resolve() + trailing separator guard (matches reference-handlers.ts)
- Thread customPath into in-session archive endpoints via query param
- Sort same-day archive entries by mtime instead of title
- Clear selectedArchiveFile on linked doc back to prevent badge leak
- Hide archive tab in annotate mode (server doesn't serve those endpoints)
- Add targetTab param to useLinkedDoc.open() to preserve calling sidebar tab
- Replace mutable render variable with index-based date grouping
For provenance purposes, this commit was AI assisted.
* refactor: collapse standalone archive server into plan server
Delete packages/server/archive.ts (187 lines) — nearly all duplicated
from the plan server. Add mode:"archive" option to startPlannotatorServer
instead. Fixes two bugs from code review:
- handleArchiveCopy now splits on "# Plan Feedback" marker instead of
bare "---", preventing truncation at horizontal rules in plan content
- customPath support works in standalone archive mode (was only working
in-session because the standalone server never received it)
For provenance purposes, this commit was AI assisted.
* refactor: extract useArchive hook from App.tsx
Move archive state (archiveMode, plans, selectedFile, isLoading) and
handlers (select, fetchPlans, done, copy) into a dedicated useArchive
hook. Reduces App.tsx by ~75 lines and makes the archive feature
self-contained.
For provenance purposes, this commit was AI assisted.
* feat: Pi archive parity + eliminate server duplication
Move runtime-agnostic storage, draft, and project functions from
packages/server/ to packages/shared/ — eliminating ~250 lines of
duplicated code in Pi's server.ts. Server package becomes thin
re-exports, preserving all existing import paths.
Add archive mode to Pi's plan review server (mode, routes, waitForDone)
and register /plannotator-archive command in the Pi extension. Consolidate
ArchivedPlan type to single definition in shared/storage.ts.
Simplify archive copy to include full content with feedback.
For provenance purposes, this commit was AI assisted.
* fix: drop -core suffix from Pi shared copies
The -core suffix broke cross-file imports — storage.ts imports from
./project which didn't resolve to project-core.ts. Using the original
filenames (no collision) lets relative imports work naturally.
For provenance purposes, this commit was AI assisted.
* fix: archive custom path bugs, disable sharing, update docs
- Normalize planDir via resolve() in getPlanDir() to handle relative
paths and trailing slashes in the path traversal guard
- Re-fetch archive plans client-side with cookie-backed customPath
so standalone archive respects the user's configured save location
- Disable sharing in archive mode (read-only viewer, no need)
- Remove dead /api/plan/history endpoint and listProjectPlans import
from Pi extension
- Remove dead /api/plan/history mock from dev-mock-api
- Update CLAUDE.md and AGENTS.md: add archive flow, archive API
endpoints, shared package structure, correct storage location,
sidebar tab count, remove stale /api/plan/history references
- Update hook server docstring from four to five modes
For provenance purposes, this commit was AI assisted.
* fix: empty archive shows demo content, stale viewer after customPath fetch
- Clear demo markdown when archive opens with no plans (plan: "" was falsy,
so setMarkdown was never called)
- Remove redundant fetchPlans() from archive init — server already sends
archivePlans in initial response
- After fetchPlans() resolves with customPath results, auto-select and load
the first plan into the viewer
- Remove dead listProjectPlans re-export from server barrel
For provenance purposes, this commit was AI assisted.
* refactor: gitignore Pi shared copies, add @generated headers
Pi's copied .ts files (storage, draft, project, feedback-templates,
review-core) are build artifacts generated from packages/shared/. They
looked like editable source files, leading to confusion about which file
to edit. Now gitignored like the HTML copies, with @generated headers
prepended by the build script.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in CI before tests
The Pi .ts copies are now gitignored build artifacts. CI needs to
generate them before running tests since server.test.ts transitively
imports them via server.ts.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in release pipeline test job
Same fix as test.yml — the Pi .ts copies are gitignored, so the test
job in the release pipeline also needs to generate them before bun test.
For provenance purposes, this commit was AI assisted.
* fix: use block scalar in CI workflow to avoid YAML parse error
The inline `run:` had a colon in the printf string that YAML
interpreted as a mapping key. Switch to `run: |` block scalar.
For provenance purposes, this commit was AI assisted.
2026-03-23 11:31:38 -07:00
|
|
|
waitForDone?: () => Promise<void>;
|
2026-02-21 11:43:46 -08:00
|
|
|
stop: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 04:12:39 +01:00
|
|
|
export async function startPlanReviewServer(options: {
|
2026-02-21 11:43:46 -08:00
|
|
|
plan: string;
|
|
|
|
|
htmlContent: string;
|
|
|
|
|
origin?: string;
|
2026-03-20 14:13:00 -04:00
|
|
|
sharingEnabled?: boolean;
|
|
|
|
|
shareBaseUrl?: string;
|
|
|
|
|
pasteApiUrl?: string;
|
feat: plan archive browser (#369)
* feat: plan archive browser with in-session sidebar tab (#362)
Add `plannotator archive` subcommand and archive sidebar tab for browsing
saved plan decisions from ~/.plannotator/plans/. Plans show approved/denied
badges and timestamps. In-session mode uses the linked doc overlay pattern
so users can reference old plans while reviewing a current one.
- New archive server (packages/server/archive.ts) following annotate pattern
- New ArchiveBrowser sidebar component, reusable in both contexts
- Archive listing/parsing functions in storage.ts (reads decision snapshots)
- Archive endpoints on plan server for in-session use (/api/archive/plans)
- Remove dead "Other Plans" UI, projectPlans state, /api/plan/history endpoint
- Fix resize handle touch area covering scrollbars in sidebar/main content
- Fix sidebar tab bar overflow when narrow
For provenance purposes, this commit was AI assisted.
* fix: code quality sweep for plan archive
- Remove `as any` cast: add "archive" to SessionInfo.mode union
- Replace inline import() type with proper import for ArchivedPlan
- Replace any[] with ArchivedPlan[] in fetch response types
- Fix infinite re-fetch when archive is empty (use hasFetched ref)
- Cache archive plan list in plan server (avoid re-scanning filesystem)
- Document ResizeHandle side prop behavior
- Remove redundant comment on Viewer archiveInfo prop
For provenance purposes, this commit was AI assisted.
* chore: remove dead marketing components
Step.astro and Landing.astro are unused — landing page inlines
step markup and pages use Base.astro directly.
For provenance purposes, this commit was AI assisted.
* fix: address code review findings for plan archive
- Path traversal: use resolve() + trailing separator guard (matches reference-handlers.ts)
- Thread customPath into in-session archive endpoints via query param
- Sort same-day archive entries by mtime instead of title
- Clear selectedArchiveFile on linked doc back to prevent badge leak
- Hide archive tab in annotate mode (server doesn't serve those endpoints)
- Add targetTab param to useLinkedDoc.open() to preserve calling sidebar tab
- Replace mutable render variable with index-based date grouping
For provenance purposes, this commit was AI assisted.
* refactor: collapse standalone archive server into plan server
Delete packages/server/archive.ts (187 lines) — nearly all duplicated
from the plan server. Add mode:"archive" option to startPlannotatorServer
instead. Fixes two bugs from code review:
- handleArchiveCopy now splits on "# Plan Feedback" marker instead of
bare "---", preventing truncation at horizontal rules in plan content
- customPath support works in standalone archive mode (was only working
in-session because the standalone server never received it)
For provenance purposes, this commit was AI assisted.
* refactor: extract useArchive hook from App.tsx
Move archive state (archiveMode, plans, selectedFile, isLoading) and
handlers (select, fetchPlans, done, copy) into a dedicated useArchive
hook. Reduces App.tsx by ~75 lines and makes the archive feature
self-contained.
For provenance purposes, this commit was AI assisted.
* feat: Pi archive parity + eliminate server duplication
Move runtime-agnostic storage, draft, and project functions from
packages/server/ to packages/shared/ — eliminating ~250 lines of
duplicated code in Pi's server.ts. Server package becomes thin
re-exports, preserving all existing import paths.
Add archive mode to Pi's plan review server (mode, routes, waitForDone)
and register /plannotator-archive command in the Pi extension. Consolidate
ArchivedPlan type to single definition in shared/storage.ts.
Simplify archive copy to include full content with feedback.
For provenance purposes, this commit was AI assisted.
* fix: drop -core suffix from Pi shared copies
The -core suffix broke cross-file imports — storage.ts imports from
./project which didn't resolve to project-core.ts. Using the original
filenames (no collision) lets relative imports work naturally.
For provenance purposes, this commit was AI assisted.
* fix: archive custom path bugs, disable sharing, update docs
- Normalize planDir via resolve() in getPlanDir() to handle relative
paths and trailing slashes in the path traversal guard
- Re-fetch archive plans client-side with cookie-backed customPath
so standalone archive respects the user's configured save location
- Disable sharing in archive mode (read-only viewer, no need)
- Remove dead /api/plan/history endpoint and listProjectPlans import
from Pi extension
- Remove dead /api/plan/history mock from dev-mock-api
- Update CLAUDE.md and AGENTS.md: add archive flow, archive API
endpoints, shared package structure, correct storage location,
sidebar tab count, remove stale /api/plan/history references
- Update hook server docstring from four to five modes
For provenance purposes, this commit was AI assisted.
* fix: empty archive shows demo content, stale viewer after customPath fetch
- Clear demo markdown when archive opens with no plans (plan: "" was falsy,
so setMarkdown was never called)
- Remove redundant fetchPlans() from archive init — server already sends
archivePlans in initial response
- After fetchPlans() resolves with customPath results, auto-select and load
the first plan into the viewer
- Remove dead listProjectPlans re-export from server barrel
For provenance purposes, this commit was AI assisted.
* refactor: gitignore Pi shared copies, add @generated headers
Pi's copied .ts files (storage, draft, project, feedback-templates,
review-core) are build artifacts generated from packages/shared/. They
looked like editable source files, leading to confusion about which file
to edit. Now gitignored like the HTML copies, with @generated headers
prepended by the build script.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in CI before tests
The Pi .ts copies are now gitignored build artifacts. CI needs to
generate them before running tests since server.test.ts transitively
imports them via server.ts.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in release pipeline test job
Same fix as test.yml — the Pi .ts copies are gitignored, so the test
job in the release pipeline also needs to generate them before bun test.
For provenance purposes, this commit was AI assisted.
* fix: use block scalar in CI workflow to avoid YAML parse error
The inline `run:` had a colon in the printf string that YAML
interpreted as a mapping key. Switch to `run: |` block scalar.
For provenance purposes, this commit was AI assisted.
2026-03-23 11:31:38 -07:00
|
|
|
mode?: "archive";
|
|
|
|
|
customPlanPath?: string | null;
|
2026-03-16 04:12:39 +01:00
|
|
|
}): Promise<PlanServerResult> {
|
2026-03-20 14:13:00 -04:00
|
|
|
const sharingEnabled =
|
|
|
|
|
options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled";
|
|
|
|
|
const shareBaseUrl =
|
|
|
|
|
(options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined;
|
|
|
|
|
const pasteApiUrl =
|
|
|
|
|
(options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined;
|
|
|
|
|
|
feat: plan archive browser (#369)
* feat: plan archive browser with in-session sidebar tab (#362)
Add `plannotator archive` subcommand and archive sidebar tab for browsing
saved plan decisions from ~/.plannotator/plans/. Plans show approved/denied
badges and timestamps. In-session mode uses the linked doc overlay pattern
so users can reference old plans while reviewing a current one.
- New archive server (packages/server/archive.ts) following annotate pattern
- New ArchiveBrowser sidebar component, reusable in both contexts
- Archive listing/parsing functions in storage.ts (reads decision snapshots)
- Archive endpoints on plan server for in-session use (/api/archive/plans)
- Remove dead "Other Plans" UI, projectPlans state, /api/plan/history endpoint
- Fix resize handle touch area covering scrollbars in sidebar/main content
- Fix sidebar tab bar overflow when narrow
For provenance purposes, this commit was AI assisted.
* fix: code quality sweep for plan archive
- Remove `as any` cast: add "archive" to SessionInfo.mode union
- Replace inline import() type with proper import for ArchivedPlan
- Replace any[] with ArchivedPlan[] in fetch response types
- Fix infinite re-fetch when archive is empty (use hasFetched ref)
- Cache archive plan list in plan server (avoid re-scanning filesystem)
- Document ResizeHandle side prop behavior
- Remove redundant comment on Viewer archiveInfo prop
For provenance purposes, this commit was AI assisted.
* chore: remove dead marketing components
Step.astro and Landing.astro are unused — landing page inlines
step markup and pages use Base.astro directly.
For provenance purposes, this commit was AI assisted.
* fix: address code review findings for plan archive
- Path traversal: use resolve() + trailing separator guard (matches reference-handlers.ts)
- Thread customPath into in-session archive endpoints via query param
- Sort same-day archive entries by mtime instead of title
- Clear selectedArchiveFile on linked doc back to prevent badge leak
- Hide archive tab in annotate mode (server doesn't serve those endpoints)
- Add targetTab param to useLinkedDoc.open() to preserve calling sidebar tab
- Replace mutable render variable with index-based date grouping
For provenance purposes, this commit was AI assisted.
* refactor: collapse standalone archive server into plan server
Delete packages/server/archive.ts (187 lines) — nearly all duplicated
from the plan server. Add mode:"archive" option to startPlannotatorServer
instead. Fixes two bugs from code review:
- handleArchiveCopy now splits on "# Plan Feedback" marker instead of
bare "---", preventing truncation at horizontal rules in plan content
- customPath support works in standalone archive mode (was only working
in-session because the standalone server never received it)
For provenance purposes, this commit was AI assisted.
* refactor: extract useArchive hook from App.tsx
Move archive state (archiveMode, plans, selectedFile, isLoading) and
handlers (select, fetchPlans, done, copy) into a dedicated useArchive
hook. Reduces App.tsx by ~75 lines and makes the archive feature
self-contained.
For provenance purposes, this commit was AI assisted.
* feat: Pi archive parity + eliminate server duplication
Move runtime-agnostic storage, draft, and project functions from
packages/server/ to packages/shared/ — eliminating ~250 lines of
duplicated code in Pi's server.ts. Server package becomes thin
re-exports, preserving all existing import paths.
Add archive mode to Pi's plan review server (mode, routes, waitForDone)
and register /plannotator-archive command in the Pi extension. Consolidate
ArchivedPlan type to single definition in shared/storage.ts.
Simplify archive copy to include full content with feedback.
For provenance purposes, this commit was AI assisted.
* fix: drop -core suffix from Pi shared copies
The -core suffix broke cross-file imports — storage.ts imports from
./project which didn't resolve to project-core.ts. Using the original
filenames (no collision) lets relative imports work naturally.
For provenance purposes, this commit was AI assisted.
* fix: archive custom path bugs, disable sharing, update docs
- Normalize planDir via resolve() in getPlanDir() to handle relative
paths and trailing slashes in the path traversal guard
- Re-fetch archive plans client-side with cookie-backed customPath
so standalone archive respects the user's configured save location
- Disable sharing in archive mode (read-only viewer, no need)
- Remove dead /api/plan/history endpoint and listProjectPlans import
from Pi extension
- Remove dead /api/plan/history mock from dev-mock-api
- Update CLAUDE.md and AGENTS.md: add archive flow, archive API
endpoints, shared package structure, correct storage location,
sidebar tab count, remove stale /api/plan/history references
- Update hook server docstring from four to five modes
For provenance purposes, this commit was AI assisted.
* fix: empty archive shows demo content, stale viewer after customPath fetch
- Clear demo markdown when archive opens with no plans (plan: "" was falsy,
so setMarkdown was never called)
- Remove redundant fetchPlans() from archive init — server already sends
archivePlans in initial response
- After fetchPlans() resolves with customPath results, auto-select and load
the first plan into the viewer
- Remove dead listProjectPlans re-export from server barrel
For provenance purposes, this commit was AI assisted.
* refactor: gitignore Pi shared copies, add @generated headers
Pi's copied .ts files (storage, draft, project, feedback-templates,
review-core) are build artifacts generated from packages/shared/. They
looked like editable source files, leading to confusion about which file
to edit. Now gitignored like the HTML copies, with @generated headers
prepended by the build script.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in CI before tests
The Pi .ts copies are now gitignored build artifacts. CI needs to
generate them before running tests since server.test.ts transitively
imports them via server.ts.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in release pipeline test job
Same fix as test.yml — the Pi .ts copies are gitignored, so the test
job in the release pipeline also needs to generate them before bun test.
For provenance purposes, this commit was AI assisted.
* fix: use block scalar in CI workflow to avoid YAML parse error
The inline `run:` had a colon in the printf string that YAML
interpreted as a mapping key. Switch to `run: |` block scalar.
For provenance purposes, this commit was AI assisted.
2026-03-23 11:31:38 -07:00
|
|
|
// --- Archive mode setup ---
|
|
|
|
|
let archivePlans: ArchivedPlan[] = [];
|
|
|
|
|
let initialArchivePlan = "";
|
|
|
|
|
let resolveDone: (() => void) | undefined;
|
|
|
|
|
let donePromise: Promise<void> | undefined;
|
|
|
|
|
|
|
|
|
|
if (options.mode === "archive") {
|
|
|
|
|
archivePlans = listArchivedPlans(options.customPlanPath ?? undefined);
|
|
|
|
|
initialArchivePlan = archivePlans.length > 0
|
|
|
|
|
? readArchivedPlan(archivePlans[0].filename, options.customPlanPath ?? undefined) ?? ""
|
|
|
|
|
: "";
|
|
|
|
|
donePromise = new Promise<void>((resolve) => { resolveDone = resolve; });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Plan review mode setup (skip in archive mode) ---
|
|
|
|
|
const slug = options.mode !== "archive" ? generateSlug(options.plan) : "";
|
|
|
|
|
const project = options.mode !== "archive" ? detectProjectName() : "";
|
|
|
|
|
const historyResult = options.mode !== "archive"
|
|
|
|
|
? saveToHistory(project, slug, options.plan)
|
|
|
|
|
: { version: 0, path: "", isNew: false };
|
|
|
|
|
const previousPlan = options.mode !== "archive" && historyResult.version > 1
|
|
|
|
|
? getPlanVersion(project, slug, historyResult.version - 1)
|
|
|
|
|
: null;
|
|
|
|
|
const versionInfo = options.mode !== "archive"
|
|
|
|
|
? { version: historyResult.version, totalVersions: getVersionCount(project, slug), project }
|
|
|
|
|
: null;
|
2026-02-22 11:36:18 -08:00
|
|
|
|
2026-02-21 11:43:46 -08:00
|
|
|
let resolveDecision!: (result: { approved: boolean; feedback?: string }) => void;
|
|
|
|
|
const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>((r) => {
|
|
|
|
|
resolveDecision = r;
|
|
|
|
|
});
|
|
|
|
|
|
feat: plan archive browser (#369)
* feat: plan archive browser with in-session sidebar tab (#362)
Add `plannotator archive` subcommand and archive sidebar tab for browsing
saved plan decisions from ~/.plannotator/plans/. Plans show approved/denied
badges and timestamps. In-session mode uses the linked doc overlay pattern
so users can reference old plans while reviewing a current one.
- New archive server (packages/server/archive.ts) following annotate pattern
- New ArchiveBrowser sidebar component, reusable in both contexts
- Archive listing/parsing functions in storage.ts (reads decision snapshots)
- Archive endpoints on plan server for in-session use (/api/archive/plans)
- Remove dead "Other Plans" UI, projectPlans state, /api/plan/history endpoint
- Fix resize handle touch area covering scrollbars in sidebar/main content
- Fix sidebar tab bar overflow when narrow
For provenance purposes, this commit was AI assisted.
* fix: code quality sweep for plan archive
- Remove `as any` cast: add "archive" to SessionInfo.mode union
- Replace inline import() type with proper import for ArchivedPlan
- Replace any[] with ArchivedPlan[] in fetch response types
- Fix infinite re-fetch when archive is empty (use hasFetched ref)
- Cache archive plan list in plan server (avoid re-scanning filesystem)
- Document ResizeHandle side prop behavior
- Remove redundant comment on Viewer archiveInfo prop
For provenance purposes, this commit was AI assisted.
* chore: remove dead marketing components
Step.astro and Landing.astro are unused — landing page inlines
step markup and pages use Base.astro directly.
For provenance purposes, this commit was AI assisted.
* fix: address code review findings for plan archive
- Path traversal: use resolve() + trailing separator guard (matches reference-handlers.ts)
- Thread customPath into in-session archive endpoints via query param
- Sort same-day archive entries by mtime instead of title
- Clear selectedArchiveFile on linked doc back to prevent badge leak
- Hide archive tab in annotate mode (server doesn't serve those endpoints)
- Add targetTab param to useLinkedDoc.open() to preserve calling sidebar tab
- Replace mutable render variable with index-based date grouping
For provenance purposes, this commit was AI assisted.
* refactor: collapse standalone archive server into plan server
Delete packages/server/archive.ts (187 lines) — nearly all duplicated
from the plan server. Add mode:"archive" option to startPlannotatorServer
instead. Fixes two bugs from code review:
- handleArchiveCopy now splits on "# Plan Feedback" marker instead of
bare "---", preventing truncation at horizontal rules in plan content
- customPath support works in standalone archive mode (was only working
in-session because the standalone server never received it)
For provenance purposes, this commit was AI assisted.
* refactor: extract useArchive hook from App.tsx
Move archive state (archiveMode, plans, selectedFile, isLoading) and
handlers (select, fetchPlans, done, copy) into a dedicated useArchive
hook. Reduces App.tsx by ~75 lines and makes the archive feature
self-contained.
For provenance purposes, this commit was AI assisted.
* feat: Pi archive parity + eliminate server duplication
Move runtime-agnostic storage, draft, and project functions from
packages/server/ to packages/shared/ — eliminating ~250 lines of
duplicated code in Pi's server.ts. Server package becomes thin
re-exports, preserving all existing import paths.
Add archive mode to Pi's plan review server (mode, routes, waitForDone)
and register /plannotator-archive command in the Pi extension. Consolidate
ArchivedPlan type to single definition in shared/storage.ts.
Simplify archive copy to include full content with feedback.
For provenance purposes, this commit was AI assisted.
* fix: drop -core suffix from Pi shared copies
The -core suffix broke cross-file imports — storage.ts imports from
./project which didn't resolve to project-core.ts. Using the original
filenames (no collision) lets relative imports work naturally.
For provenance purposes, this commit was AI assisted.
* fix: archive custom path bugs, disable sharing, update docs
- Normalize planDir via resolve() in getPlanDir() to handle relative
paths and trailing slashes in the path traversal guard
- Re-fetch archive plans client-side with cookie-backed customPath
so standalone archive respects the user's configured save location
- Disable sharing in archive mode (read-only viewer, no need)
- Remove dead /api/plan/history endpoint and listProjectPlans import
from Pi extension
- Remove dead /api/plan/history mock from dev-mock-api
- Update CLAUDE.md and AGENTS.md: add archive flow, archive API
endpoints, shared package structure, correct storage location,
sidebar tab count, remove stale /api/plan/history references
- Update hook server docstring from four to five modes
For provenance purposes, this commit was AI assisted.
* fix: empty archive shows demo content, stale viewer after customPath fetch
- Clear demo markdown when archive opens with no plans (plan: "" was falsy,
so setMarkdown was never called)
- Remove redundant fetchPlans() from archive init — server already sends
archivePlans in initial response
- After fetchPlans() resolves with customPath results, auto-select and load
the first plan into the viewer
- Remove dead listProjectPlans re-export from server barrel
For provenance purposes, this commit was AI assisted.
* refactor: gitignore Pi shared copies, add @generated headers
Pi's copied .ts files (storage, draft, project, feedback-templates,
review-core) are build artifacts generated from packages/shared/. They
looked like editable source files, leading to confusion about which file
to edit. Now gitignored like the HTML copies, with @generated headers
prepended by the build script.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in CI before tests
The Pi .ts copies are now gitignored build artifacts. CI needs to
generate them before running tests since server.test.ts transitively
imports them via server.ts.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in release pipeline test job
Same fix as test.yml — the Pi .ts copies are gitignored, so the test
job in the release pipeline also needs to generate them before bun test.
For provenance purposes, this commit was AI assisted.
* fix: use block scalar in CI workflow to avoid YAML parse error
The inline `run:` had a colon in the printf string that YAML
interpreted as a mapping key. Switch to `run: |` block scalar.
For provenance purposes, this commit was AI assisted.
2026-03-23 11:31:38 -07:00
|
|
|
// Lazy cache for in-session archive tab
|
|
|
|
|
let cachedArchivePlans: ArchivedPlan[] | null = null;
|
|
|
|
|
|
2026-02-21 11:43:46 -08:00
|
|
|
const server = createServer(async (req, res) => {
|
|
|
|
|
const url = new URL(req.url!, `http://localhost`);
|
|
|
|
|
|
feat: plan archive browser (#369)
* feat: plan archive browser with in-session sidebar tab (#362)
Add `plannotator archive` subcommand and archive sidebar tab for browsing
saved plan decisions from ~/.plannotator/plans/. Plans show approved/denied
badges and timestamps. In-session mode uses the linked doc overlay pattern
so users can reference old plans while reviewing a current one.
- New archive server (packages/server/archive.ts) following annotate pattern
- New ArchiveBrowser sidebar component, reusable in both contexts
- Archive listing/parsing functions in storage.ts (reads decision snapshots)
- Archive endpoints on plan server for in-session use (/api/archive/plans)
- Remove dead "Other Plans" UI, projectPlans state, /api/plan/history endpoint
- Fix resize handle touch area covering scrollbars in sidebar/main content
- Fix sidebar tab bar overflow when narrow
For provenance purposes, this commit was AI assisted.
* fix: code quality sweep for plan archive
- Remove `as any` cast: add "archive" to SessionInfo.mode union
- Replace inline import() type with proper import for ArchivedPlan
- Replace any[] with ArchivedPlan[] in fetch response types
- Fix infinite re-fetch when archive is empty (use hasFetched ref)
- Cache archive plan list in plan server (avoid re-scanning filesystem)
- Document ResizeHandle side prop behavior
- Remove redundant comment on Viewer archiveInfo prop
For provenance purposes, this commit was AI assisted.
* chore: remove dead marketing components
Step.astro and Landing.astro are unused — landing page inlines
step markup and pages use Base.astro directly.
For provenance purposes, this commit was AI assisted.
* fix: address code review findings for plan archive
- Path traversal: use resolve() + trailing separator guard (matches reference-handlers.ts)
- Thread customPath into in-session archive endpoints via query param
- Sort same-day archive entries by mtime instead of title
- Clear selectedArchiveFile on linked doc back to prevent badge leak
- Hide archive tab in annotate mode (server doesn't serve those endpoints)
- Add targetTab param to useLinkedDoc.open() to preserve calling sidebar tab
- Replace mutable render variable with index-based date grouping
For provenance purposes, this commit was AI assisted.
* refactor: collapse standalone archive server into plan server
Delete packages/server/archive.ts (187 lines) — nearly all duplicated
from the plan server. Add mode:"archive" option to startPlannotatorServer
instead. Fixes two bugs from code review:
- handleArchiveCopy now splits on "# Plan Feedback" marker instead of
bare "---", preventing truncation at horizontal rules in plan content
- customPath support works in standalone archive mode (was only working
in-session because the standalone server never received it)
For provenance purposes, this commit was AI assisted.
* refactor: extract useArchive hook from App.tsx
Move archive state (archiveMode, plans, selectedFile, isLoading) and
handlers (select, fetchPlans, done, copy) into a dedicated useArchive
hook. Reduces App.tsx by ~75 lines and makes the archive feature
self-contained.
For provenance purposes, this commit was AI assisted.
* feat: Pi archive parity + eliminate server duplication
Move runtime-agnostic storage, draft, and project functions from
packages/server/ to packages/shared/ — eliminating ~250 lines of
duplicated code in Pi's server.ts. Server package becomes thin
re-exports, preserving all existing import paths.
Add archive mode to Pi's plan review server (mode, routes, waitForDone)
and register /plannotator-archive command in the Pi extension. Consolidate
ArchivedPlan type to single definition in shared/storage.ts.
Simplify archive copy to include full content with feedback.
For provenance purposes, this commit was AI assisted.
* fix: drop -core suffix from Pi shared copies
The -core suffix broke cross-file imports — storage.ts imports from
./project which didn't resolve to project-core.ts. Using the original
filenames (no collision) lets relative imports work naturally.
For provenance purposes, this commit was AI assisted.
* fix: archive custom path bugs, disable sharing, update docs
- Normalize planDir via resolve() in getPlanDir() to handle relative
paths and trailing slashes in the path traversal guard
- Re-fetch archive plans client-side with cookie-backed customPath
so standalone archive respects the user's configured save location
- Disable sharing in archive mode (read-only viewer, no need)
- Remove dead /api/plan/history endpoint and listProjectPlans import
from Pi extension
- Remove dead /api/plan/history mock from dev-mock-api
- Update CLAUDE.md and AGENTS.md: add archive flow, archive API
endpoints, shared package structure, correct storage location,
sidebar tab count, remove stale /api/plan/history references
- Update hook server docstring from four to five modes
For provenance purposes, this commit was AI assisted.
* fix: empty archive shows demo content, stale viewer after customPath fetch
- Clear demo markdown when archive opens with no plans (plan: "" was falsy,
so setMarkdown was never called)
- Remove redundant fetchPlans() from archive init — server already sends
archivePlans in initial response
- After fetchPlans() resolves with customPath results, auto-select and load
the first plan into the viewer
- Remove dead listProjectPlans re-export from server barrel
For provenance purposes, this commit was AI assisted.
* refactor: gitignore Pi shared copies, add @generated headers
Pi's copied .ts files (storage, draft, project, feedback-templates,
review-core) are build artifacts generated from packages/shared/. They
looked like editable source files, leading to confusion about which file
to edit. Now gitignored like the HTML copies, with @generated headers
prepended by the build script.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in CI before tests
The Pi .ts copies are now gitignored build artifacts. CI needs to
generate them before running tests since server.test.ts transitively
imports them via server.ts.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in release pipeline test job
Same fix as test.yml — the Pi .ts copies are gitignored, so the test
job in the release pipeline also needs to generate them before bun test.
For provenance purposes, this commit was AI assisted.
* fix: use block scalar in CI workflow to avoid YAML parse error
The inline `run:` had a colon in the printf string that YAML
interpreted as a mapping key. Switch to `run: |` block scalar.
For provenance purposes, this commit was AI assisted.
2026-03-23 11:31:38 -07:00
|
|
|
if (url.pathname === "/api/done" && req.method === "POST") {
|
|
|
|
|
resolveDone?.();
|
|
|
|
|
json(res, { ok: true });
|
|
|
|
|
} else if (url.pathname === "/api/archive/plans") {
|
|
|
|
|
const customPath = url.searchParams.get("customPath") || undefined;
|
|
|
|
|
if (!cachedArchivePlans) cachedArchivePlans = listArchivedPlans(customPath);
|
|
|
|
|
json(res, { plans: cachedArchivePlans });
|
|
|
|
|
} else if (url.pathname === "/api/archive/plan") {
|
|
|
|
|
const filename = url.searchParams.get("filename");
|
|
|
|
|
const customPath = url.searchParams.get("customPath") || undefined;
|
|
|
|
|
if (!filename) { json(res, { error: "Missing filename" }, 400); return; }
|
|
|
|
|
const markdown = readArchivedPlan(filename, customPath);
|
|
|
|
|
if (!markdown) { json(res, { error: "Not found" }, 404); return; }
|
|
|
|
|
json(res, { markdown, filepath: filename });
|
|
|
|
|
} else if (url.pathname === "/api/plan/version") {
|
2026-02-22 11:36:18 -08:00
|
|
|
const vParam = url.searchParams.get("v");
|
|
|
|
|
if (!vParam) {
|
|
|
|
|
json(res, { error: "Missing v parameter" }, 400);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const v = parseInt(vParam, 10);
|
|
|
|
|
if (isNaN(v) || v < 1) {
|
|
|
|
|
json(res, { error: "Invalid version number" }, 400);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const content = getPlanVersion(project, slug, v);
|
|
|
|
|
if (content === null) {
|
|
|
|
|
json(res, { error: "Version not found" }, 404);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
json(res, { plan: content, version: v });
|
|
|
|
|
} else if (url.pathname === "/api/plan/versions") {
|
|
|
|
|
json(res, { project, slug, versions: listVersions(project, slug) });
|
|
|
|
|
} else if (url.pathname === "/api/plan") {
|
feat: plan archive browser (#369)
* feat: plan archive browser with in-session sidebar tab (#362)
Add `plannotator archive` subcommand and archive sidebar tab for browsing
saved plan decisions from ~/.plannotator/plans/. Plans show approved/denied
badges and timestamps. In-session mode uses the linked doc overlay pattern
so users can reference old plans while reviewing a current one.
- New archive server (packages/server/archive.ts) following annotate pattern
- New ArchiveBrowser sidebar component, reusable in both contexts
- Archive listing/parsing functions in storage.ts (reads decision snapshots)
- Archive endpoints on plan server for in-session use (/api/archive/plans)
- Remove dead "Other Plans" UI, projectPlans state, /api/plan/history endpoint
- Fix resize handle touch area covering scrollbars in sidebar/main content
- Fix sidebar tab bar overflow when narrow
For provenance purposes, this commit was AI assisted.
* fix: code quality sweep for plan archive
- Remove `as any` cast: add "archive" to SessionInfo.mode union
- Replace inline import() type with proper import for ArchivedPlan
- Replace any[] with ArchivedPlan[] in fetch response types
- Fix infinite re-fetch when archive is empty (use hasFetched ref)
- Cache archive plan list in plan server (avoid re-scanning filesystem)
- Document ResizeHandle side prop behavior
- Remove redundant comment on Viewer archiveInfo prop
For provenance purposes, this commit was AI assisted.
* chore: remove dead marketing components
Step.astro and Landing.astro are unused — landing page inlines
step markup and pages use Base.astro directly.
For provenance purposes, this commit was AI assisted.
* fix: address code review findings for plan archive
- Path traversal: use resolve() + trailing separator guard (matches reference-handlers.ts)
- Thread customPath into in-session archive endpoints via query param
- Sort same-day archive entries by mtime instead of title
- Clear selectedArchiveFile on linked doc back to prevent badge leak
- Hide archive tab in annotate mode (server doesn't serve those endpoints)
- Add targetTab param to useLinkedDoc.open() to preserve calling sidebar tab
- Replace mutable render variable with index-based date grouping
For provenance purposes, this commit was AI assisted.
* refactor: collapse standalone archive server into plan server
Delete packages/server/archive.ts (187 lines) — nearly all duplicated
from the plan server. Add mode:"archive" option to startPlannotatorServer
instead. Fixes two bugs from code review:
- handleArchiveCopy now splits on "# Plan Feedback" marker instead of
bare "---", preventing truncation at horizontal rules in plan content
- customPath support works in standalone archive mode (was only working
in-session because the standalone server never received it)
For provenance purposes, this commit was AI assisted.
* refactor: extract useArchive hook from App.tsx
Move archive state (archiveMode, plans, selectedFile, isLoading) and
handlers (select, fetchPlans, done, copy) into a dedicated useArchive
hook. Reduces App.tsx by ~75 lines and makes the archive feature
self-contained.
For provenance purposes, this commit was AI assisted.
* feat: Pi archive parity + eliminate server duplication
Move runtime-agnostic storage, draft, and project functions from
packages/server/ to packages/shared/ — eliminating ~250 lines of
duplicated code in Pi's server.ts. Server package becomes thin
re-exports, preserving all existing import paths.
Add archive mode to Pi's plan review server (mode, routes, waitForDone)
and register /plannotator-archive command in the Pi extension. Consolidate
ArchivedPlan type to single definition in shared/storage.ts.
Simplify archive copy to include full content with feedback.
For provenance purposes, this commit was AI assisted.
* fix: drop -core suffix from Pi shared copies
The -core suffix broke cross-file imports — storage.ts imports from
./project which didn't resolve to project-core.ts. Using the original
filenames (no collision) lets relative imports work naturally.
For provenance purposes, this commit was AI assisted.
* fix: archive custom path bugs, disable sharing, update docs
- Normalize planDir via resolve() in getPlanDir() to handle relative
paths and trailing slashes in the path traversal guard
- Re-fetch archive plans client-side with cookie-backed customPath
so standalone archive respects the user's configured save location
- Disable sharing in archive mode (read-only viewer, no need)
- Remove dead /api/plan/history endpoint and listProjectPlans import
from Pi extension
- Remove dead /api/plan/history mock from dev-mock-api
- Update CLAUDE.md and AGENTS.md: add archive flow, archive API
endpoints, shared package structure, correct storage location,
sidebar tab count, remove stale /api/plan/history references
- Update hook server docstring from four to five modes
For provenance purposes, this commit was AI assisted.
* fix: empty archive shows demo content, stale viewer after customPath fetch
- Clear demo markdown when archive opens with no plans (plan: "" was falsy,
so setMarkdown was never called)
- Remove redundant fetchPlans() from archive init — server already sends
archivePlans in initial response
- After fetchPlans() resolves with customPath results, auto-select and load
the first plan into the viewer
- Remove dead listProjectPlans re-export from server barrel
For provenance purposes, this commit was AI assisted.
* refactor: gitignore Pi shared copies, add @generated headers
Pi's copied .ts files (storage, draft, project, feedback-templates,
review-core) are build artifacts generated from packages/shared/. They
looked like editable source files, leading to confusion about which file
to edit. Now gitignored like the HTML copies, with @generated headers
prepended by the build script.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in CI before tests
The Pi .ts copies are now gitignored build artifacts. CI needs to
generate them before running tests since server.test.ts transitively
imports them via server.ts.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in release pipeline test job
Same fix as test.yml — the Pi .ts copies are gitignored, so the test
job in the release pipeline also needs to generate them before bun test.
For provenance purposes, this commit was AI assisted.
* fix: use block scalar in CI workflow to avoid YAML parse error
The inline `run:` had a colon in the printf string that YAML
interpreted as a mapping key. Switch to `run: |` block scalar.
For provenance purposes, this commit was AI assisted.
2026-03-23 11:31:38 -07:00
|
|
|
if (options.mode === "archive") {
|
|
|
|
|
json(res, {
|
|
|
|
|
plan: initialArchivePlan, origin: options.origin ?? "pi",
|
|
|
|
|
mode: "archive", archivePlans, sharingEnabled, shareBaseUrl, pasteApiUrl,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
json(res, { plan: options.plan, origin: options.origin ?? "pi", previousPlan, versionInfo, sharingEnabled, shareBaseUrl, pasteApiUrl });
|
|
|
|
|
}
|
2026-02-21 11:43:46 -08:00
|
|
|
} else if (url.pathname === "/api/approve" && req.method === "POST") {
|
|
|
|
|
const body = await parseBody(req);
|
|
|
|
|
resolveDecision({ approved: true, feedback: body.feedback as string | undefined });
|
|
|
|
|
json(res, { ok: true });
|
|
|
|
|
} else if (url.pathname === "/api/deny" && req.method === "POST") {
|
|
|
|
|
const body = await parseBody(req);
|
|
|
|
|
resolveDecision({ approved: false, feedback: (body.feedback as string) || "Plan rejected" });
|
|
|
|
|
json(res, { ok: true });
|
|
|
|
|
} else {
|
|
|
|
|
html(res, options.htmlContent);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-16 04:12:39 +01:00
|
|
|
const { port, portSource } = await listenOnPort(server);
|
2026-02-21 11:43:46 -08:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
port,
|
2026-03-16 04:12:39 +01:00
|
|
|
portSource,
|
2026-02-21 11:43:46 -08:00
|
|
|
url: `http://localhost:${port}`,
|
|
|
|
|
waitForDecision: () => decisionPromise,
|
feat: plan archive browser (#369)
* feat: plan archive browser with in-session sidebar tab (#362)
Add `plannotator archive` subcommand and archive sidebar tab for browsing
saved plan decisions from ~/.plannotator/plans/. Plans show approved/denied
badges and timestamps. In-session mode uses the linked doc overlay pattern
so users can reference old plans while reviewing a current one.
- New archive server (packages/server/archive.ts) following annotate pattern
- New ArchiveBrowser sidebar component, reusable in both contexts
- Archive listing/parsing functions in storage.ts (reads decision snapshots)
- Archive endpoints on plan server for in-session use (/api/archive/plans)
- Remove dead "Other Plans" UI, projectPlans state, /api/plan/history endpoint
- Fix resize handle touch area covering scrollbars in sidebar/main content
- Fix sidebar tab bar overflow when narrow
For provenance purposes, this commit was AI assisted.
* fix: code quality sweep for plan archive
- Remove `as any` cast: add "archive" to SessionInfo.mode union
- Replace inline import() type with proper import for ArchivedPlan
- Replace any[] with ArchivedPlan[] in fetch response types
- Fix infinite re-fetch when archive is empty (use hasFetched ref)
- Cache archive plan list in plan server (avoid re-scanning filesystem)
- Document ResizeHandle side prop behavior
- Remove redundant comment on Viewer archiveInfo prop
For provenance purposes, this commit was AI assisted.
* chore: remove dead marketing components
Step.astro and Landing.astro are unused — landing page inlines
step markup and pages use Base.astro directly.
For provenance purposes, this commit was AI assisted.
* fix: address code review findings for plan archive
- Path traversal: use resolve() + trailing separator guard (matches reference-handlers.ts)
- Thread customPath into in-session archive endpoints via query param
- Sort same-day archive entries by mtime instead of title
- Clear selectedArchiveFile on linked doc back to prevent badge leak
- Hide archive tab in annotate mode (server doesn't serve those endpoints)
- Add targetTab param to useLinkedDoc.open() to preserve calling sidebar tab
- Replace mutable render variable with index-based date grouping
For provenance purposes, this commit was AI assisted.
* refactor: collapse standalone archive server into plan server
Delete packages/server/archive.ts (187 lines) — nearly all duplicated
from the plan server. Add mode:"archive" option to startPlannotatorServer
instead. Fixes two bugs from code review:
- handleArchiveCopy now splits on "# Plan Feedback" marker instead of
bare "---", preventing truncation at horizontal rules in plan content
- customPath support works in standalone archive mode (was only working
in-session because the standalone server never received it)
For provenance purposes, this commit was AI assisted.
* refactor: extract useArchive hook from App.tsx
Move archive state (archiveMode, plans, selectedFile, isLoading) and
handlers (select, fetchPlans, done, copy) into a dedicated useArchive
hook. Reduces App.tsx by ~75 lines and makes the archive feature
self-contained.
For provenance purposes, this commit was AI assisted.
* feat: Pi archive parity + eliminate server duplication
Move runtime-agnostic storage, draft, and project functions from
packages/server/ to packages/shared/ — eliminating ~250 lines of
duplicated code in Pi's server.ts. Server package becomes thin
re-exports, preserving all existing import paths.
Add archive mode to Pi's plan review server (mode, routes, waitForDone)
and register /plannotator-archive command in the Pi extension. Consolidate
ArchivedPlan type to single definition in shared/storage.ts.
Simplify archive copy to include full content with feedback.
For provenance purposes, this commit was AI assisted.
* fix: drop -core suffix from Pi shared copies
The -core suffix broke cross-file imports — storage.ts imports from
./project which didn't resolve to project-core.ts. Using the original
filenames (no collision) lets relative imports work naturally.
For provenance purposes, this commit was AI assisted.
* fix: archive custom path bugs, disable sharing, update docs
- Normalize planDir via resolve() in getPlanDir() to handle relative
paths and trailing slashes in the path traversal guard
- Re-fetch archive plans client-side with cookie-backed customPath
so standalone archive respects the user's configured save location
- Disable sharing in archive mode (read-only viewer, no need)
- Remove dead /api/plan/history endpoint and listProjectPlans import
from Pi extension
- Remove dead /api/plan/history mock from dev-mock-api
- Update CLAUDE.md and AGENTS.md: add archive flow, archive API
endpoints, shared package structure, correct storage location,
sidebar tab count, remove stale /api/plan/history references
- Update hook server docstring from four to five modes
For provenance purposes, this commit was AI assisted.
* fix: empty archive shows demo content, stale viewer after customPath fetch
- Clear demo markdown when archive opens with no plans (plan: "" was falsy,
so setMarkdown was never called)
- Remove redundant fetchPlans() from archive init — server already sends
archivePlans in initial response
- After fetchPlans() resolves with customPath results, auto-select and load
the first plan into the viewer
- Remove dead listProjectPlans re-export from server barrel
For provenance purposes, this commit was AI assisted.
* refactor: gitignore Pi shared copies, add @generated headers
Pi's copied .ts files (storage, draft, project, feedback-templates,
review-core) are build artifacts generated from packages/shared/. They
looked like editable source files, leading to confusion about which file
to edit. Now gitignored like the HTML copies, with @generated headers
prepended by the build script.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in CI before tests
The Pi .ts copies are now gitignored build artifacts. CI needs to
generate them before running tests since server.test.ts transitively
imports them via server.ts.
For provenance purposes, this commit was AI assisted.
* fix: generate Pi shared copies in release pipeline test job
Same fix as test.yml — the Pi .ts copies are gitignored, so the test
job in the release pipeline also needs to generate them before bun test.
For provenance purposes, this commit was AI assisted.
* fix: use block scalar in CI workflow to avoid YAML parse error
The inline `run:` had a colon in the printf string that YAML
interpreted as a mapping key. Switch to `run: |` block scalar.
For provenance purposes, this commit was AI assisted.
2026-03-23 11:31:38 -07:00
|
|
|
...(donePromise && { waitForDone: () => donePromise }),
|
2026-02-21 11:43:46 -08:00
|
|
|
stop: () => server.close(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 07:55:15 -07:00
|
|
|
export type { DiffType, DiffOption, GitContext } from "./review-core.js";
|
2026-02-21 11:43:46 -08:00
|
|
|
|
|
|
|
|
export interface ReviewServerResult {
|
|
|
|
|
port: number;
|
2026-03-16 04:12:39 +01:00
|
|
|
portSource: "env" | "remote-default" | "random";
|
2026-02-21 11:43:46 -08:00
|
|
|
url: string;
|
2026-03-16 07:55:15 -07:00
|
|
|
waitForDecision: () => Promise<{
|
|
|
|
|
approved: boolean;
|
|
|
|
|
feedback: string;
|
|
|
|
|
annotations: unknown[];
|
|
|
|
|
agentSwitch?: string;
|
|
|
|
|
}>;
|
2026-02-21 11:43:46 -08:00
|
|
|
stop: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Run a git command and return stdout (empty string on error). */
|
|
|
|
|
function git(cmd: string): string {
|
|
|
|
|
try {
|
|
|
|
|
return execSync(`git ${cmd}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
|
|
|
} catch {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 07:55:15 -07:00
|
|
|
const reviewRuntime: ReviewGitRuntime = {
|
|
|
|
|
async runGit(args: string[], options?: { cwd?: string }): Promise<GitCommandResult> {
|
|
|
|
|
const result = spawnSync("git", args, {
|
|
|
|
|
cwd: options?.cwd,
|
|
|
|
|
encoding: "utf-8",
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
stdout: result.stdout ?? "",
|
|
|
|
|
stderr: result.stderr ?? "",
|
|
|
|
|
exitCode: result.status ?? (result.error ? 1 : 0),
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async readTextFile(path: string): Promise<string | null> {
|
|
|
|
|
try {
|
|
|
|
|
return readFileSync(path, "utf-8");
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-02-21 11:43:46 -08:00
|
|
|
|
2026-03-16 07:55:15 -07:00
|
|
|
export function getGitContext(): Promise<GitContext> {
|
|
|
|
|
return getGitContextCore(reviewRuntime);
|
|
|
|
|
}
|
2026-02-21 11:43:46 -08:00
|
|
|
|
2026-03-16 07:55:15 -07:00
|
|
|
export function runGitDiff(
|
|
|
|
|
diffType: DiffType,
|
|
|
|
|
defaultBranch = "main",
|
|
|
|
|
): Promise<{ patch: string; label: string; error?: string }> {
|
|
|
|
|
return runGitDiffCore(reviewRuntime, diffType, defaultBranch);
|
2026-02-21 11:43:46 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-16 04:12:39 +01:00
|
|
|
export async function startReviewServer(options: {
|
2026-02-21 11:43:46 -08:00
|
|
|
rawPatch: string;
|
|
|
|
|
gitRef: string;
|
|
|
|
|
htmlContent: string;
|
|
|
|
|
origin?: string;
|
|
|
|
|
diffType?: DiffType;
|
|
|
|
|
gitContext?: GitContext;
|
2026-03-16 07:55:15 -07:00
|
|
|
error?: string;
|
|
|
|
|
sharingEnabled?: boolean;
|
|
|
|
|
shareBaseUrl?: string;
|
2026-03-20 14:13:00 -04:00
|
|
|
pasteApiUrl?: string;
|
2026-03-16 04:12:39 +01:00
|
|
|
}): Promise<ReviewServerResult> {
|
2026-03-16 07:55:15 -07:00
|
|
|
const draftKey = contentHash(options.rawPatch);
|
|
|
|
|
const repoInfo = getRepoInfo();
|
|
|
|
|
const editorAnnotations = createEditorAnnotationHandler();
|
2026-02-21 11:43:46 -08:00
|
|
|
let currentPatch = options.rawPatch;
|
|
|
|
|
let currentGitRef = options.gitRef;
|
|
|
|
|
let currentDiffType: DiffType = options.diffType || "uncommitted";
|
2026-03-16 07:55:15 -07:00
|
|
|
let currentError = options.error;
|
|
|
|
|
const sharingEnabled =
|
|
|
|
|
options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled";
|
|
|
|
|
const shareBaseUrl =
|
|
|
|
|
(options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined;
|
2026-03-20 14:13:00 -04:00
|
|
|
const pasteApiUrl =
|
|
|
|
|
(options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined;
|
2026-03-16 07:55:15 -07:00
|
|
|
|
|
|
|
|
let resolveDecision!: (result: {
|
|
|
|
|
approved: boolean;
|
|
|
|
|
feedback: string;
|
|
|
|
|
annotations: unknown[];
|
|
|
|
|
agentSwitch?: string;
|
|
|
|
|
}) => void;
|
|
|
|
|
const decisionPromise = new Promise<{
|
|
|
|
|
approved: boolean;
|
|
|
|
|
feedback: string;
|
|
|
|
|
annotations: unknown[];
|
|
|
|
|
agentSwitch?: string;
|
|
|
|
|
}>((r) => {
|
2026-02-21 11:43:46 -08:00
|
|
|
resolveDecision = r;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const server = createServer(async (req, res) => {
|
|
|
|
|
const url = new URL(req.url!, `http://localhost`);
|
|
|
|
|
|
|
|
|
|
if (url.pathname === "/api/diff" && req.method === "GET") {
|
|
|
|
|
json(res, {
|
|
|
|
|
rawPatch: currentPatch,
|
|
|
|
|
gitRef: currentGitRef,
|
|
|
|
|
origin: options.origin ?? "pi",
|
|
|
|
|
diffType: currentDiffType,
|
|
|
|
|
gitContext: options.gitContext,
|
2026-03-16 07:55:15 -07:00
|
|
|
sharingEnabled,
|
|
|
|
|
shareBaseUrl,
|
2026-03-20 14:13:00 -04:00
|
|
|
pasteApiUrl,
|
2026-03-16 07:55:15 -07:00
|
|
|
repoInfo,
|
|
|
|
|
...(currentError ? { error: currentError } : {}),
|
2026-02-21 11:43:46 -08:00
|
|
|
});
|
|
|
|
|
} else if (url.pathname === "/api/diff/switch" && req.method === "POST") {
|
|
|
|
|
const body = await parseBody(req);
|
|
|
|
|
const newType = body.diffType as DiffType;
|
|
|
|
|
if (!newType) {
|
|
|
|
|
json(res, { error: "Missing diffType" }, 400);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const defaultBranch = options.gitContext?.defaultBranch || "main";
|
2026-03-16 07:55:15 -07:00
|
|
|
const result = await runGitDiff(newType, defaultBranch);
|
2026-02-21 11:43:46 -08:00
|
|
|
currentPatch = result.patch;
|
|
|
|
|
currentGitRef = result.label;
|
|
|
|
|
currentDiffType = newType;
|
2026-03-16 07:55:15 -07:00
|
|
|
currentError = result.error;
|
|
|
|
|
json(res, {
|
|
|
|
|
rawPatch: currentPatch,
|
|
|
|
|
gitRef: currentGitRef,
|
|
|
|
|
diffType: currentDiffType,
|
|
|
|
|
...(currentError ? { error: currentError } : {}),
|
|
|
|
|
});
|
|
|
|
|
} else if (url.pathname === "/api/file-content" && req.method === "GET") {
|
|
|
|
|
const filePath = url.searchParams.get("path");
|
|
|
|
|
if (!filePath) {
|
|
|
|
|
json(res, { error: "Missing path" }, 400);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
validateFilePath(filePath);
|
|
|
|
|
} catch {
|
|
|
|
|
json(res, { error: "Invalid path" }, 400);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const oldPath = url.searchParams.get("oldPath") || undefined;
|
|
|
|
|
if (oldPath) {
|
|
|
|
|
try {
|
|
|
|
|
validateFilePath(oldPath);
|
|
|
|
|
} catch {
|
|
|
|
|
json(res, { error: "Invalid path" }, 400);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const defaultBranch = options.gitContext?.defaultBranch || "main";
|
|
|
|
|
const result = await getFileContentsForDiffCore(
|
|
|
|
|
reviewRuntime,
|
|
|
|
|
currentDiffType,
|
|
|
|
|
defaultBranch,
|
|
|
|
|
filePath,
|
|
|
|
|
oldPath,
|
|
|
|
|
);
|
|
|
|
|
json(res, result);
|
|
|
|
|
} else if (url.pathname === "/api/image") {
|
|
|
|
|
const imagePath = url.searchParams.get("path");
|
|
|
|
|
if (!imagePath) {
|
|
|
|
|
send(res, "Missing path parameter", 400, { "Content-Type": "text/plain" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tryServePath = (candidate: string): boolean => {
|
|
|
|
|
const validation = validateImagePath(candidate);
|
|
|
|
|
if (!validation.valid) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
if (!existsSync(validation.resolved)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const data = readFileSync(validation.resolved);
|
|
|
|
|
send(res, data, 200, { "Content-Type": getImageContentType(validation.resolved) });
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (tryServePath(imagePath)) return;
|
|
|
|
|
|
|
|
|
|
const base = url.searchParams.get("base");
|
|
|
|
|
if (base && !imagePath.startsWith("/") && tryServePath(resolvePath(base, imagePath))) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const validation = validateImagePath(imagePath);
|
|
|
|
|
if (!validation.valid) {
|
|
|
|
|
send(res, validation.error || "Invalid image path", 403, { "Content-Type": "text/plain" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
send(res, "File not found", 404, { "Content-Type": "text/plain" });
|
|
|
|
|
} else if (url.pathname === "/api/upload" && req.method === "POST") {
|
|
|
|
|
try {
|
|
|
|
|
const request = toWebRequest(req);
|
|
|
|
|
const formData = await request.formData();
|
|
|
|
|
const file = formData.get("file");
|
|
|
|
|
if (!file || typeof file !== "object" || !("arrayBuffer" in file) || !("name" in file)) {
|
|
|
|
|
json(res, { error: "No file provided" }, 400);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const upload = file as File;
|
|
|
|
|
const extResult = validateUploadExtension(upload.name);
|
|
|
|
|
if (!extResult.valid) {
|
|
|
|
|
json(res, { error: extResult.error }, 400);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
|
|
|
const tempPath = join(UPLOAD_DIR, `${randomUUID()}.${extResult.ext}`);
|
|
|
|
|
const bytes = Buffer.from(await upload.arrayBuffer());
|
|
|
|
|
writeFileSync(tempPath, bytes);
|
|
|
|
|
json(res, { path: tempPath, originalName: upload.name });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : "Upload failed";
|
|
|
|
|
json(res, { error: message }, 500);
|
|
|
|
|
}
|
|
|
|
|
} else if (url.pathname === "/api/agents" && req.method === "GET") {
|
|
|
|
|
json(res, { agents: [] });
|
|
|
|
|
} else if (url.pathname === "/api/git-add" && req.method === "POST") {
|
|
|
|
|
const body = await parseBody(req);
|
|
|
|
|
const filePath = body.filePath as string | undefined;
|
|
|
|
|
if (!filePath) {
|
|
|
|
|
json(res, { error: "Missing filePath" }, 400);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
let cwd: string | undefined;
|
|
|
|
|
if (currentDiffType.startsWith("worktree:")) {
|
|
|
|
|
const parsed = parseWorktreeDiffType(currentDiffType);
|
|
|
|
|
if (parsed) cwd = parsed.path;
|
|
|
|
|
}
|
|
|
|
|
if (body.undo) {
|
|
|
|
|
await gitResetFileCore(reviewRuntime, filePath, cwd);
|
|
|
|
|
} else {
|
|
|
|
|
await gitAddFileCore(reviewRuntime, filePath, cwd);
|
|
|
|
|
}
|
|
|
|
|
json(res, { ok: true });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : "Failed to git add";
|
|
|
|
|
json(res, { error: message }, 500);
|
|
|
|
|
}
|
|
|
|
|
} else if (url.pathname === "/api/draft") {
|
|
|
|
|
if (req.method === "POST") {
|
|
|
|
|
const body = await parseBody(req);
|
|
|
|
|
saveDraft(draftKey, body);
|
|
|
|
|
json(res, { ok: true });
|
|
|
|
|
} else if (req.method === "DELETE") {
|
|
|
|
|
deleteDraft(draftKey);
|
|
|
|
|
json(res, { ok: true });
|
|
|
|
|
} else {
|
|
|
|
|
const draft = loadDraft(draftKey);
|
|
|
|
|
if (!draft) {
|
|
|
|
|
json(res, { found: false }, 404);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
json(res, draft);
|
|
|
|
|
}
|
|
|
|
|
} else if (await editorAnnotations.handle(req, res, url)) {
|
|
|
|
|
return;
|
2026-02-21 11:43:46 -08:00
|
|
|
} else if (url.pathname === "/api/feedback" && req.method === "POST") {
|
|
|
|
|
const body = await parseBody(req);
|
2026-03-16 07:55:15 -07:00
|
|
|
deleteDraft(draftKey);
|
2026-03-14 17:58:57 -07:00
|
|
|
resolveDecision({
|
|
|
|
|
approved: (body.approved as boolean) ?? false,
|
|
|
|
|
feedback: (body.feedback as string) || "",
|
2026-03-16 07:55:15 -07:00
|
|
|
annotations: (body.annotations as unknown[]) || [],
|
|
|
|
|
agentSwitch: body.agentSwitch as string | undefined,
|
2026-03-14 17:58:57 -07:00
|
|
|
});
|
2026-02-21 11:43:46 -08:00
|
|
|
json(res, { ok: true });
|
|
|
|
|
} else {
|
|
|
|
|
html(res, options.htmlContent);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-16 04:12:39 +01:00
|
|
|
const { port, portSource } = await listenOnPort(server);
|
2026-02-21 11:43:46 -08:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
port,
|
2026-03-16 04:12:39 +01:00
|
|
|
portSource,
|
2026-02-21 11:43:46 -08:00
|
|
|
url: `http://localhost:${port}`,
|
|
|
|
|
waitForDecision: () => decisionPromise,
|
|
|
|
|
stop: () => server.close(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Annotate Server ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface AnnotateServerResult {
|
|
|
|
|
port: number;
|
2026-03-16 04:12:39 +01:00
|
|
|
portSource: "env" | "remote-default" | "random";
|
2026-02-21 11:43:46 -08:00
|
|
|
url: string;
|
|
|
|
|
waitForDecision: () => Promise<{ feedback: string }>;
|
|
|
|
|
stop: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 04:12:39 +01:00
|
|
|
export async function startAnnotateServer(options: {
|
2026-02-21 11:43:46 -08:00
|
|
|
markdown: string;
|
|
|
|
|
filePath: string;
|
|
|
|
|
htmlContent: string;
|
|
|
|
|
origin?: string;
|
2026-03-20 14:13:00 -04:00
|
|
|
mode?: string;
|
|
|
|
|
sharingEnabled?: boolean;
|
|
|
|
|
shareBaseUrl?: string;
|
|
|
|
|
pasteApiUrl?: string;
|
2026-03-16 04:12:39 +01:00
|
|
|
}): Promise<AnnotateServerResult> {
|
2026-03-20 14:13:00 -04:00
|
|
|
const sharingEnabled =
|
|
|
|
|
options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled";
|
|
|
|
|
const shareBaseUrl =
|
|
|
|
|
(options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined;
|
|
|
|
|
const pasteApiUrl =
|
|
|
|
|
(options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined;
|
|
|
|
|
|
2026-02-21 11:43:46 -08:00
|
|
|
let resolveDecision!: (result: { feedback: string }) => void;
|
|
|
|
|
const decisionPromise = new Promise<{ feedback: string }>((r) => {
|
|
|
|
|
resolveDecision = r;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const server = createServer(async (req, res) => {
|
|
|
|
|
const url = new URL(req.url!, `http://localhost`);
|
|
|
|
|
|
|
|
|
|
if (url.pathname === "/api/plan" && req.method === "GET") {
|
|
|
|
|
json(res, {
|
|
|
|
|
plan: options.markdown,
|
|
|
|
|
origin: options.origin ?? "pi",
|
2026-03-20 14:13:00 -04:00
|
|
|
mode: options.mode || "annotate",
|
2026-02-21 11:43:46 -08:00
|
|
|
filePath: options.filePath,
|
2026-03-20 14:13:00 -04:00
|
|
|
sharingEnabled,
|
|
|
|
|
shareBaseUrl,
|
|
|
|
|
pasteApiUrl,
|
2026-02-21 11:43:46 -08:00
|
|
|
});
|
|
|
|
|
} else if (url.pathname === "/api/feedback" && req.method === "POST") {
|
|
|
|
|
const body = await parseBody(req);
|
|
|
|
|
resolveDecision({ feedback: (body.feedback as string) || "" });
|
|
|
|
|
json(res, { ok: true });
|
|
|
|
|
} else {
|
|
|
|
|
html(res, options.htmlContent);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-16 04:12:39 +01:00
|
|
|
const { port, portSource } = await listenOnPort(server);
|
2026-02-21 11:43:46 -08:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
port,
|
2026-03-16 04:12:39 +01:00
|
|
|
portSource,
|
2026-02-21 11:43:46 -08:00
|
|
|
url: `http://localhost:${port}`,
|
|
|
|
|
waitForDecision: () => decisionPromise,
|
|
|
|
|
stop: () => server.close(),
|
|
|
|
|
};
|
|
|
|
|
}
|