mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
c2950e709f
Fixes from the 0.27.9 pre-release review. Servers: an unreadable rendered-HTML root falls back to the startup snapshot on both runtimes with a once-per-process warning instead of hanging (Pi) or answering 500 (Bun); the version diff is recomputed against current bytes on reload and carried through the in-app Refresh instead of being dropped, with no history write on a GET. Client: a Refresh action on the compact touch shell; HtmlSurfaceControls renders Refresh independently of the eye; the dead HtmlSurfaceActions removed. Threading: one linear, cycle-safe reply resolution shared by the annotations panel, its sort, and the export (5,000-chain tests), PATCH ingest on both runtimes rejects self-references and cycles, nothing is ever dropped from feedback. WebMCP and viewer hygiene: bounded tombstone and request memories, per-instance minted ids, nudge id caps, waiter cleanup on unmount, a shared retry epoch for diagram blocks. Docs: HTML Refresh documented, the WebMCP design pointer fixed, marketing pages updated. AI-assisted (Claude) under maintainer direction.
1198 lines
49 KiB
TypeScript
1198 lines
49 KiB
TypeScript
import { createServer } from "node:http";
|
|
import type { IncomingMessage } from "node:http";
|
|
import { dirname, resolve as resolvePath } from "node:path";
|
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
|
|
import {
|
|
buildLiveAppUrl,
|
|
buildLiveEditorOrigins,
|
|
composeLiveBridgeJs,
|
|
liveAppDraftIdentity,
|
|
} from "../generated/live-proxy-core.ts";
|
|
import { startLiveAppProxyNode } from "../generated/live-proxy-node.ts";
|
|
import type { LiveAppProxy } from "../generated/live-proxy-core.ts";
|
|
|
|
import { contentHash, deleteDraft } from "../generated/draft.ts";
|
|
import { getPlanVersion, getVersionCount, listVersions } from "../generated/storage.ts";
|
|
import { computeAnnotateHistory, deriveAnnotateHistorySlug, persistAnnotateSubmission, type AnnotateHistoryResult } from "../generated/annotate-history.ts";
|
|
import { htmlDiff } from "../generated/html-diff.ts";
|
|
import { saveConfig, detectGitUser, getServerConfig, isAgentTerminalSide, loadConfig, resolveAIEnabled, resolveSharingEnabled, resolveAnnotateHistory, type PromptRuntime } from "../generated/config.ts";
|
|
import { isFaviconStyle, type FaviconStyle } from "../generated/favicon.ts";
|
|
import { getAnnotateFileFeedbackTemplate, getAnnotateMessageFeedbackTemplate } from "../generated/prompts.ts";
|
|
import { disabledSourceSave, type SourceSaveRequest } from "../generated/source-save.ts";
|
|
import { getAnnotateReferenceRootPaths } from "../generated/annotate-reference-roots-node.ts";
|
|
import {
|
|
createSourceSaveCapability,
|
|
createSourceSaveCapabilityFromText,
|
|
readSourceFileSnapshot,
|
|
resolveFolderSourceFile,
|
|
resolveFolderSourceFileForSave,
|
|
saveSourceFileAtomic,
|
|
} from "../generated/source-save-node.ts";
|
|
|
|
import {
|
|
handleDraftRequest,
|
|
handleFavicon,
|
|
handleImageRequest,
|
|
handleReferenceSkillsRequest,
|
|
handleReferenceSkillContentRequest,
|
|
readDraftGenerationFromBody,
|
|
readDraftGenerationFromUrl,
|
|
handleSaveNotesRequest,
|
|
handleUploadRequest,
|
|
} from "./handlers.ts";
|
|
import { handleApiNotFound, html, json, parseBody, requestUrl } from "./helpers.ts";
|
|
import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.ts";
|
|
|
|
import { buildAdvertisedUrl, isRemoteSession, listenOnPort } from "./network.ts";
|
|
import { getAvailableOpenInApps, openFileInApp } from "./open-in-apps.ts";
|
|
|
|
import { getRepoInfo } from "./project.ts";
|
|
import {
|
|
handleDocRequest,
|
|
handleDocExistsRequest,
|
|
handleFileBrowserRequest,
|
|
handleObsidianVaultsRequest,
|
|
handleObsidianFilesRequest,
|
|
handleObsidianDocRequest,
|
|
resolveAllowedDocPath,
|
|
type FolderAnnotateHistory,
|
|
} from "./reference.ts";
|
|
import { closeAllFileBrowserWatchers, handleFileBrowserStreamRequest } from "./file-browser-watch.ts";
|
|
import { getExtraMarkdownExtensions, MAX_ANNOTATABLE_FILE_BYTES, resolveUserPath, warmFileListCache } from "../generated/resolve-file.ts";
|
|
import { createExternalAnnotationHandler } from "./external-annotations.ts";
|
|
import { createNodeAgentTerminalBridge } from "./agent-terminal.ts";
|
|
import {
|
|
HTML_ASSET_ROUTE_PREFIX,
|
|
encodeHtmlAssetPath,
|
|
htmlAssetContentType,
|
|
normalizeHtmlAssetRoutePath,
|
|
rewriteHtmlAssetReferences,
|
|
} from "../generated/html-assets.ts";
|
|
import { inlineHtmlLocalAssets, isWithinDirectory, MAX_HTML_ASSET_BYTES, resolveOpenInTarget } from "../generated/html-assets-node.ts";
|
|
import {
|
|
supportsAnnotateAgentTerminalMode,
|
|
type AgentTerminalCapability,
|
|
} from "../generated/agent-terminal.ts";
|
|
import {
|
|
ANNOTATE_CLIENT_LEASE_GRACE_MS,
|
|
ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS,
|
|
ANNOTATE_CLIENT_LEASE_STREAM_PATH,
|
|
createAnnotateClientLeaseStreamSession,
|
|
createAnnotateClientLeaseTracker,
|
|
} from "../generated/annotate-client-lease.ts";
|
|
import { createAnnotateDecisionSettler } from "../generated/annotate-decision.ts";
|
|
|
|
export interface AnnotateServerResult {
|
|
port: number;
|
|
portSource: "env" | "remote-default" | "random";
|
|
url: string;
|
|
waitForDecision: () => Promise<{ feedback: string; annotations: unknown[]; exit?: boolean; approved?: boolean; selectedMessageId?: string; feedbackScope?: "message" | "messages" }>;
|
|
stop: () => void;
|
|
}
|
|
|
|
function parseOptionalApprovalBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
return new Promise((resolve, reject) => {
|
|
let data = "";
|
|
req.on("data", (chunk) => {
|
|
data += chunk;
|
|
});
|
|
req.on("error", reject);
|
|
req.on("end", () => {
|
|
if (!data.trim()) {
|
|
resolve({});
|
|
return;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error("Expected a JSON object.");
|
|
}
|
|
resolve(parsed as Record<string, unknown>);
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function createHtmlAssetRegistry() {
|
|
const rootsByToken = new Map<string, string>();
|
|
const tokensByRoot = new Map<string, string>();
|
|
|
|
function register(baseDir: string): string {
|
|
const root = resolvePath(baseDir);
|
|
const existing = tokensByRoot.get(root);
|
|
if (existing) return existing;
|
|
const token = randomUUID().replace(/-/g, "").slice(0, 16);
|
|
tokensByRoot.set(root, token);
|
|
rootsByToken.set(token, root);
|
|
return token;
|
|
}
|
|
|
|
function rewriteHtml(htmlContent: string, htmlFilePath: string): string {
|
|
if (/^https?:\/\//i.test(htmlFilePath)) return htmlContent;
|
|
try {
|
|
const token = register(dirname(resolvePath(htmlFilePath)));
|
|
return rewriteHtmlAssetReferences(
|
|
htmlContent,
|
|
(assetPath) => `${HTML_ASSET_ROUTE_PREFIX}/${token}/${encodeHtmlAssetPath(assetPath)}`,
|
|
);
|
|
} catch {
|
|
return htmlContent;
|
|
}
|
|
}
|
|
|
|
function inlineHtml(htmlContent: string, htmlFilePath: string): string {
|
|
return inlineHtmlLocalAssets(htmlContent, htmlFilePath);
|
|
}
|
|
|
|
function handle(res: import("node:http").ServerResponse, url: URL): boolean {
|
|
const prefix = `${HTML_ASSET_ROUTE_PREFIX}/`;
|
|
if (!url.pathname.startsWith(prefix)) return false;
|
|
|
|
const rest = url.pathname.slice(prefix.length);
|
|
const slash = rest.indexOf("/");
|
|
if (slash <= 0) {
|
|
json(res, { error: "Missing asset token or path" }, 404);
|
|
return true;
|
|
}
|
|
|
|
const token = rest.slice(0, slash);
|
|
const root = rootsByToken.get(token);
|
|
if (!root) {
|
|
json(res, { error: "Unknown asset root" }, 404);
|
|
return true;
|
|
}
|
|
|
|
const assetPath = normalizeHtmlAssetRoutePath(rest.slice(slash + 1));
|
|
if (!assetPath) {
|
|
json(res, { error: "Invalid asset path" }, 400);
|
|
return true;
|
|
}
|
|
|
|
const contentType = htmlAssetContentType(assetPath);
|
|
if (!contentType) {
|
|
json(res, { error: "Unsupported asset type" }, 415);
|
|
return true;
|
|
}
|
|
|
|
const resolved = resolvePath(root, assetPath);
|
|
if (!isWithinDirectory(resolved, root)) {
|
|
json(res, { error: "Access denied" }, 403);
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
if (!existsSync(resolved)) {
|
|
json(res, { error: "Asset not found" }, 404);
|
|
return true;
|
|
}
|
|
const stat = statSync(resolved);
|
|
if (stat.size > MAX_HTML_ASSET_BYTES) {
|
|
json(res, { error: "Asset too large" }, 413);
|
|
return true;
|
|
}
|
|
res.writeHead(200, {
|
|
"Content-Type": contentType,
|
|
"Cache-Control": "no-store",
|
|
"Access-Control-Allow-Origin": "*",
|
|
});
|
|
res.end(readFileSync(resolved));
|
|
} catch {
|
|
json(res, { error: "Failed to read asset" }, 500);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return { rewriteHtml, inlineHtml, handle };
|
|
}
|
|
|
|
export async function startAnnotateServer(options: {
|
|
markdown: string;
|
|
filePath: string;
|
|
htmlContent: string;
|
|
origin?: string;
|
|
mode?: string;
|
|
folderPath?: string;
|
|
recentMessages?: { messageId: string; text: string; timestamp?: string }[];
|
|
sharingEnabled?: boolean;
|
|
shareBaseUrl?: string;
|
|
pasteApiUrl?: string;
|
|
sourceInfo?: string;
|
|
sourceConverted?: boolean;
|
|
gate?: boolean;
|
|
approvalNotesSupported?: boolean;
|
|
clientLeaseSupported?: boolean;
|
|
/** @internal Test-only timing overrides for the client-lease grace/heartbeat intervals — never sleeps real 30s in tests. */
|
|
clientLeaseTestOverrides?: { graceMs?: number; heartbeatMs?: number };
|
|
rawHtml?: string;
|
|
renderHtml?: boolean;
|
|
convertHtml?: boolean;
|
|
agentCwd?: string;
|
|
/** Project name for keying per-file version history (powers the annotate version diff). */
|
|
project?: string;
|
|
/**
|
|
* Live local app annotation (mode "annotate-app"): the server starts a
|
|
* loopback reverse proxy (node:http transport over the shared core)
|
|
* mirroring targetUrl and serves the composed bridge body from it. The
|
|
* caller supplies the bridge sources; the server owns the per-session
|
|
* token. Refused outright in remote mode.
|
|
*/
|
|
liveApp?: {
|
|
targetUrl: string;
|
|
bridgeScript: string;
|
|
bridgeBootstrap: string;
|
|
annotationCss: string;
|
|
};
|
|
}): Promise<AnnotateServerResult> {
|
|
// Remote hard-off, defense in depth behind the command-side check: a live
|
|
// proxy relays the user's authenticated dev app, so it must never coexist
|
|
// with a beyond-loopback annotate bind. No override env var exists on
|
|
// purpose (mirrors packages/server/annotate.ts).
|
|
if (options.liveApp && isRemoteSession()) {
|
|
throw new Error("Live app annotation is unavailable in remote mode");
|
|
}
|
|
|
|
const gitUser = detectGitUser();
|
|
const sharingEnabled =
|
|
options.sharingEnabled ?? resolveSharingEnabled(loadConfig());
|
|
const shareBaseUrl =
|
|
(options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined;
|
|
const pasteApiUrl =
|
|
(options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined;
|
|
|
|
let resolveDecision!: (result: {
|
|
feedback: string;
|
|
annotations: unknown[];
|
|
exit?: boolean;
|
|
approved?: boolean;
|
|
selectedMessageId?: string;
|
|
feedbackScope?: "message" | "messages";
|
|
}) => void;
|
|
const decisionPromise = new Promise<{
|
|
feedback: string;
|
|
annotations: unknown[];
|
|
exit?: boolean;
|
|
approved?: boolean;
|
|
selectedMessageId?: string;
|
|
feedbackScope?: "message" | "messages";
|
|
}>((r) => {
|
|
resolveDecision = r;
|
|
});
|
|
|
|
// Every decision producer goes through this: connected tabs and the client
|
|
// lease below race, and a producer that loses must not delete the reviewer's
|
|
// draft or report success for an outcome the caller never received.
|
|
const decision = createAnnotateDecisionSettler(resolveDecision);
|
|
const sendAlreadyDecided = (res: import("node:http").ServerResponse): void => {
|
|
json(res, { error: "This review session has already been decided." }, 409);
|
|
};
|
|
|
|
// Last-client abandonment lease: once the tab's client-lease stream
|
|
// disconnects (as reported by the transport) and stays disconnected for the
|
|
// grace period with no reconnect, the decision resolves as dismissed
|
|
// instead of hanging the Pi orchestration forever. Grace timing is bounded
|
|
// only for clean disconnects; abrupt/half-open connection loss is detected
|
|
// on a best-effort basis by the transport and can take longer than
|
|
// graceMs to be noticed at all. See packages/shared/annotate-client-lease.ts
|
|
// (vendored to generated/annotate-client-lease.ts by vendor.sh).
|
|
const clientLeaseGraceMs = options.clientLeaseTestOverrides?.graceMs ?? ANNOTATE_CLIENT_LEASE_GRACE_MS;
|
|
const clientLeaseHeartbeatMs = options.clientLeaseTestOverrides?.heartbeatMs ?? ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS;
|
|
const clientLease = createAnnotateClientLeaseTracker(
|
|
() => decision.settle({ feedback: "", annotations: [], exit: true }),
|
|
{ graceMs: clientLeaseGraceMs },
|
|
);
|
|
|
|
// Draft identity. Content-derived for the modes that HAVE content, and
|
|
// path-derived for the modes that do not: a live app session resolves
|
|
// `markdown` to "" by construction (the page lives behind the proxy), so
|
|
// its target is its identity — hashing the empty body would give every
|
|
// live session on the machine one shared draft slot. Folder sessions key
|
|
// by folder path for the same reason. liveAppDraftIdentity is the shared
|
|
// normalization, so Bun and Pi sessions key the same target identically.
|
|
const draftSource =
|
|
options.mode === "annotate-app" && options.liveApp
|
|
? `annotate-app\0${liveAppDraftIdentity(options.liveApp.targetUrl)}`
|
|
: options.mode === "annotate-folder" && options.folderPath
|
|
? `folder:${resolvePath(options.folderPath)}`
|
|
: options.renderHtml && options.rawHtml ? options.rawHtml : options.markdown;
|
|
const draftKey = contentHash(draftSource);
|
|
|
|
// Per-file version history → powers the native version diff in annotate mode.
|
|
// Unlike the plan flow (slug = first-heading + date), annotate keys history by
|
|
// file path so re-opening the same file groups its versions across edits even
|
|
// when headings change. Diff content is the markdown, or the raw HTML source
|
|
// when rendering HTML. Only single local files (not URLs/folders/messages).
|
|
const annotateProjectName = options.project ?? "_unknown";
|
|
const annotateHistoryEnabled = resolveAnnotateHistory(loadConfig());
|
|
// Single local file sessions are the only ones this eager gate covers.
|
|
// URL, agent-message, and live-app sessions never write session content to
|
|
// the data dir. Folder sessions do participate in per-file version history,
|
|
// but lazily through /api/doc (see computeFolderAnnotateHistory below), not
|
|
// here. The durable submit records stay single-local-file only. The
|
|
// mode === "annotate" check is deliberate and explicit: "annotate-app"
|
|
// (whose filePath is URL-shaped anyway) must never become history-eligible
|
|
// by accident.
|
|
const singleFileLocalAnnotate =
|
|
(options.mode || "annotate") === "annotate" && !/^https?:\/\//i.test(options.filePath);
|
|
let annotateHistory: AnnotateHistoryResult | null = null;
|
|
{
|
|
const historyContent = options.renderHtml && options.rawHtml ? options.rawHtml : options.markdown;
|
|
const eligible =
|
|
singleFileLocalAnnotate &&
|
|
historyContent.length > 0 &&
|
|
annotateHistoryEnabled;
|
|
// History is an enhancement, never a gate: a read-only/full data dir
|
|
// must degrade to stateless annotate (no version diff), not fail the
|
|
// whole session before the UI ever opens. (computeAnnotateHistory never
|
|
// throws — it logs and returns null on any storage error.)
|
|
if (eligible) {
|
|
annotateHistory = computeAnnotateHistory(annotateProjectName, resolvePath(options.filePath), historyContent);
|
|
}
|
|
}
|
|
|
|
// Folder annotate: the same per-file version history, but run lazily the
|
|
// first time a folder file is opened via /api/doc (not eagerly for every
|
|
// file in the folder) and memoized per resolved absolute path for the life
|
|
// of this server — reopening the same file in this session never re-snapshots.
|
|
// The memo drops `diffCurrent` (it always equals the request's own content
|
|
// and the client never reads it off /api/doc) — only slug/previousPlan/
|
|
// versionInfo are retained.
|
|
const folderAnnotateHistoryCache = new Map<string, FolderAnnotateHistory | null>();
|
|
function computeFolderAnnotateHistory(resolvedFilePath: string, content: string): FolderAnnotateHistory | null {
|
|
const cached = folderAnnotateHistoryCache.get(resolvedFilePath);
|
|
if (cached !== undefined) return cached;
|
|
const full = computeAnnotateHistory(annotateProjectName, resolvedFilePath, content);
|
|
const result: FolderAnnotateHistory | null = full
|
|
? { slug: full.slug, previousPlan: full.previousPlan, versionInfo: full.versionInfo }
|
|
: null;
|
|
folderAnnotateHistoryCache.set(resolvedFilePath, result);
|
|
return result;
|
|
}
|
|
|
|
// Durable submit records (#678): the caller consuming waitForDecision() may
|
|
// be gone (agent-side timeout) by the time the reviewer clicks submit —
|
|
// settling the promise then deleting the draft would leave the submitted
|
|
// feedback existing nowhere. persistAnnotateSubmission writes the record to
|
|
// {DATA_DIR}/history/{project}/{slug}/submissions/{timestamp}.md (next to
|
|
// the file's annotate version history) BEFORE the draft delete.
|
|
//
|
|
// annotateHistory opt-out policy: PLANNOTATOR_ANNOTATE_HISTORY=0 means "do
|
|
// not write annotated content to the data dir", and submitted feedback
|
|
// quotes that content, so the record is skipped and the legacy submit
|
|
// behavior (draft deleted) is preserved unchanged. A missing/timed-out
|
|
// consumer is not detectable in-process (the server cannot know its caller
|
|
// stopped reading), so there is no narrower condition to key off.
|
|
//
|
|
// Scope: identical to the version-history gate above — single local files
|
|
// only. annotate-last / URL / folder sessions never wrote submit
|
|
// records and still do not: their submissions quote agent messages or
|
|
// fetched pages, which this record was never meant to persist. (Folder
|
|
// sessions do write lazy per-file version history via /api/doc; that is
|
|
// a separate, documented pipeline with its own gate.)
|
|
//
|
|
// Returns whether the draft delete may proceed: true when the record was
|
|
// written, when there was no user content to lose, or when the session
|
|
// does not persist; false only when a durable write was expected and
|
|
// failed — the draft then stays behind as the recovery copy.
|
|
const persistSubmittedDecision = (
|
|
feedback: unknown,
|
|
annotations: unknown,
|
|
approved: boolean,
|
|
): boolean => {
|
|
// Defensive: /api/feedback does not type-validate its body (unlike
|
|
// /api/approve), and a malformed value must degrade to the legacy
|
|
// behavior (settle + delete draft + 200), never throw into a 500.
|
|
const feedbackText = typeof feedback === "string" ? feedback : "";
|
|
const annotationList = Array.isArray(annotations) ? annotations : [];
|
|
if (!feedbackText.trim() && annotationList.length === 0) return true; // contentless (e.g. bare approve)
|
|
if (!annotateHistoryEnabled) return true; // opt-out: stateless annotate sessions
|
|
if (!singleFileLocalAnnotate) return true; // stateless modes stay stateless
|
|
return (
|
|
persistAnnotateSubmission({
|
|
project: annotateProjectName,
|
|
sessionPath: resolvePath(options.filePath),
|
|
feedback: feedbackText,
|
|
annotations: annotationList,
|
|
approved,
|
|
}) !== null
|
|
);
|
|
};
|
|
|
|
// Detect repo info (cached for this session)
|
|
const repoInfo = getRepoInfo();
|
|
|
|
const externalAnnotations = createExternalAnnotationHandler("plan");
|
|
const aiRuntime = resolveAIEnabled() ? await createPiAIRuntime() : null;
|
|
const htmlAssets = createHtmlAssetRegistry();
|
|
let agentTerminalCapability: AgentTerminalCapability = {
|
|
enabled: false,
|
|
reason: "unsupported-runtime",
|
|
};
|
|
|
|
function isAllowedHtmlSharePath(targetPath: string): boolean {
|
|
const roots = new Set<string>([process.cwd()]);
|
|
if (options.folderPath) roots.add(options.folderPath);
|
|
if (!/^https?:\/\//i.test(options.filePath)) roots.add(dirname(options.filePath));
|
|
for (const root of roots) {
|
|
if (isWithinDirectory(targetPath, root)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// The fallback is silent to the reviewer, so the reason is logged once per
|
|
// process: a genuine bug in the read must not hide behind the snapshot.
|
|
let rootHtmlUnreadableWarned = false;
|
|
const warnRootHtmlUnreadable = (path: string, err: unknown) => {
|
|
if (rootHtmlUnreadableWarned) return;
|
|
rootHtmlUnreadableWarned = true;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.warn(`[plannotator] could not read the HTML root ${path}; serving the startup snapshot instead: ${message}`);
|
|
};
|
|
|
|
// A local rendered-HTML root is served from its CURRENT bytes, not the
|
|
// startup snapshot: the reviewer can Refresh in-app or reload the tab after
|
|
// an agent edits the file, and both /api/plan and /api/share-html must then
|
|
// describe the page the annotations were placed on. The snapshot is only
|
|
// the fallback when the file is gone or has grown past the annotate cap.
|
|
const rootHtmlSourcePath =
|
|
options.renderHtml && options.rawHtml && !/^https?:\/\//i.test(options.filePath)
|
|
? resolvePath(options.filePath)
|
|
: null;
|
|
type RootHtmlRead =
|
|
| { kind: "current"; html: string }
|
|
| { kind: "snapshot"; reason: "missing" | "too-large" | "unreadable" };
|
|
function readRootHtml(): RootHtmlRead | null {
|
|
if (!rootHtmlSourcePath) return null;
|
|
// A present-but-unreadable root (permissions revoked, the path replaced
|
|
// by a directory) is the same fallback as a missing one: the startup
|
|
// snapshot, with its version diff. The read must never throw out of the
|
|
// request handler: an unhandled rejection there leaves /api/plan
|
|
// unanswered and the tab hangs on reload.
|
|
try {
|
|
if (!existsSync(rootHtmlSourcePath)) return { kind: "snapshot", reason: "missing" };
|
|
if (statSync(rootHtmlSourcePath).size > MAX_ANNOTATABLE_FILE_BYTES) {
|
|
return { kind: "snapshot", reason: "too-large" };
|
|
}
|
|
return { kind: "current", html: readFileSync(rootHtmlSourcePath, "utf-8") };
|
|
} catch (err) {
|
|
warnRootHtmlUnreadable(rootHtmlSourcePath, err);
|
|
return { kind: "snapshot", reason: "unreadable" };
|
|
}
|
|
}
|
|
|
|
// The in-app Refresh re-reads the root through /api/doc. For the ROOT
|
|
// document only (linked docs are unchanged), the response also carries the
|
|
// version-diff fields /api/plan serves, recomputed against the bytes just
|
|
// read, so a refresh keeps the "Show changes" toggle exactly like a reload.
|
|
const rootHistory = annotateHistory;
|
|
const rootHtmlVersionDiff =
|
|
rootHtmlSourcePath && rootHistory
|
|
? {
|
|
path: rootHtmlSourcePath,
|
|
compute: (currentHtml: string) => ({
|
|
previousPlan: rootHistory.previousPlan,
|
|
versionInfo: rootHistory.versionInfo,
|
|
...(rootHistory.previousPlan
|
|
? { diffHtml: htmlAssets.rewriteHtml(htmlDiff(rootHistory.previousPlan, currentHtml), options.filePath) }
|
|
: {}),
|
|
}),
|
|
}
|
|
: undefined;
|
|
|
|
function handleShareHtml(res: import("node:http").ServerResponse, url: URL): void {
|
|
if (/^https?:\/\//i.test(options.filePath)) {
|
|
json(res, { error: "Raw HTML sharing is unavailable for URL annotations" }, 400);
|
|
return;
|
|
}
|
|
|
|
const sourcePath = resolvePath(options.filePath);
|
|
const requestedPath = url.searchParams.get("path")
|
|
? resolvePath(url.searchParams.get("path")!)
|
|
: sourcePath;
|
|
if (!/\.html?$/i.test(requestedPath)) {
|
|
json(res, { error: "Share HTML is only available for HTML documents" }, 400);
|
|
return;
|
|
}
|
|
if (!isAllowedHtmlSharePath(requestedPath)) {
|
|
json(res, { error: "Access denied" }, 403);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let htmlContent: string;
|
|
if (rootHtmlSourcePath && requestedPath === rootHtmlSourcePath) {
|
|
const read = readRootHtml();
|
|
if (read?.kind === "snapshot" && read.reason === "too-large") {
|
|
json(res, { error: "File too large to share (max 2MB)" }, 413);
|
|
return;
|
|
}
|
|
htmlContent = read?.kind === "current" ? read.html : options.rawHtml!;
|
|
} else {
|
|
htmlContent = readFileSync(requestedPath, "utf-8");
|
|
}
|
|
json(res, { shareHtml: htmlAssets.inlineHtml(htmlContent, requestedPath) });
|
|
} catch {
|
|
json(res, { error: "Failed to prepare share HTML" }, 500);
|
|
}
|
|
}
|
|
|
|
const sourceMode = options.mode || "annotate";
|
|
const singleFileSourceSaveEligible =
|
|
sourceMode === "annotate" &&
|
|
!options.sourceConverted &&
|
|
!(options.renderHtml && options.rawHtml) &&
|
|
!/^https?:\/\//i.test(options.filePath);
|
|
const initialSingleFileSourceSave = singleFileSourceSaveEligible
|
|
? createSourceSaveCapability("single-file", options.filePath)
|
|
: null;
|
|
const initialSingleFileSourcePath = singleFileSourceSaveEligible
|
|
? initialSingleFileSourceSave?.enabled
|
|
? initialSingleFileSourceSave.path
|
|
: resolveUserPath(options.filePath)
|
|
: null;
|
|
const openedSourceFilePaths = new Set<string>();
|
|
if (initialSingleFileSourcePath) openedSourceFilePaths.add(initialSingleFileSourcePath);
|
|
const getPrimarySource = () => {
|
|
const mode = options.mode || "annotate";
|
|
if (mode === "annotate-last") {
|
|
return { plan: options.markdown, sourceSave: disabledSourceSave("message-mode") };
|
|
}
|
|
if (mode === "annotate-folder") {
|
|
return { plan: options.markdown, sourceSave: disabledSourceSave("folder-mode") };
|
|
}
|
|
if (options.renderHtml && options.rawHtml) {
|
|
return { plan: options.markdown, sourceSave: disabledSourceSave("html-render") };
|
|
}
|
|
if (options.sourceConverted) {
|
|
return { plan: options.markdown, sourceSave: disabledSourceSave("converted-source") };
|
|
}
|
|
if (/^https?:\/\//i.test(options.filePath)) {
|
|
return { plan: options.markdown, sourceSave: disabledSourceSave("not-local-file") };
|
|
}
|
|
|
|
const sourceSave = createSourceSaveCapability("single-file", initialSingleFileSourcePath ?? options.filePath);
|
|
if (!sourceSave.enabled) {
|
|
if (sourceSave.reason === "missing-file" && initialSingleFileSourcePath) {
|
|
const missingSourceSave = createSourceSaveCapabilityFromText("single-file", initialSingleFileSourcePath, options.markdown);
|
|
if (missingSourceSave.enabled) {
|
|
return { plan: options.markdown, sourceSave: missingSourceSave };
|
|
}
|
|
}
|
|
return { plan: options.markdown, sourceSave };
|
|
}
|
|
|
|
try {
|
|
const snapshot = readSourceFileSnapshot(sourceSave.path);
|
|
return {
|
|
plan: snapshot.text,
|
|
sourceSave: {
|
|
...sourceSave,
|
|
hash: snapshot.hash,
|
|
mtimeMs: snapshot.mtimeMs,
|
|
size: snapshot.size,
|
|
eol: snapshot.eol,
|
|
},
|
|
};
|
|
} catch {
|
|
return { plan: options.markdown, sourceSave: disabledSourceSave("unreadable-file") };
|
|
}
|
|
};
|
|
|
|
const getReferenceRootPaths = () => getAnnotateReferenceRootPaths({
|
|
mode: options.mode || "annotate",
|
|
filePath: options.filePath,
|
|
folderPath: options.folderPath,
|
|
initialSingleFileSourcePath,
|
|
});
|
|
|
|
// Live app session state, populated after the annotate port is known (the
|
|
// editor origins carry the port) and before the URL is returned to the
|
|
// caller for advertisement.
|
|
let liveProxy: LiveAppProxy | null = null;
|
|
let liveSessionToken = "";
|
|
let liveAppUrl = "";
|
|
|
|
const server = createServer(async (req, res) => {
|
|
const url = requestUrl(req);
|
|
|
|
if (await externalAnnotations.handle(req, res, url)) return;
|
|
if (url.pathname.startsWith("/api/ai/") && await handlePiAIRequest(req, res, url, aiRuntime)) return;
|
|
|
|
// API: Client-lease SSE — see generated/annotate-client-lease.ts.
|
|
// Only local direct structured annotate gates advertise and serve
|
|
// this; other sessions get a 404.
|
|
if (url.pathname === ANNOTATE_CLIENT_LEASE_STREAM_PATH && req.method === "GET") {
|
|
if (!options.clientLeaseSupported) {
|
|
res.writeHead(404);
|
|
res.end("Client lease unavailable");
|
|
return;
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
});
|
|
res.setTimeout(0);
|
|
|
|
const session = createAnnotateClientLeaseStreamSession({
|
|
tracker: clientLease,
|
|
heartbeatMs: clientLeaseHeartbeatMs,
|
|
write: (chunk) => {
|
|
res.write(chunk);
|
|
},
|
|
endStream: () => res.end(),
|
|
});
|
|
|
|
// `req.on("close")` reliably fires on client disconnect; `res.on("close")`
|
|
// is registered too as defense-in-depth for Node runtimes/versions where
|
|
// only the response object emits it. `session.close()` is idempotent so a
|
|
// double-fire is harmless.
|
|
req.on("close", () => session.close());
|
|
res.on("close", () => session.close());
|
|
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/plan" && req.method === "GET" && options.mode === "annotate-app" && options.liveApp) {
|
|
// Live app session: no rawHtml, no renderAs, no version fields,
|
|
// sharing off. The client frames appUrl (the loopback proxy) and
|
|
// authenticates the bridge with liveToken. Mirrors the Bun
|
|
// server's annotate-app payload (packages/server/annotate.ts).
|
|
json(res, {
|
|
plan: "",
|
|
origin: options.origin ?? "pi",
|
|
mode: options.mode,
|
|
filePath: options.filePath,
|
|
sourceInfo: options.sourceInfo ?? options.liveApp.targetUrl,
|
|
appUrl: liveAppUrl,
|
|
targetUrl: options.liveApp.targetUrl,
|
|
liveToken: liveSessionToken,
|
|
gate: options.gate ?? false,
|
|
approvalNotesSupported: options.approvalNotesSupported ?? false,
|
|
clientLease: options.clientLeaseSupported
|
|
? { enabled: true as const, reconnectGraceMs: clientLeaseGraceMs }
|
|
: { enabled: false as const },
|
|
sharingEnabled: false,
|
|
convertHtml: false,
|
|
repoInfo,
|
|
projectRoot: process.cwd(),
|
|
serverConfig: getServerConfig(gitUser),
|
|
agentTerminal: agentTerminalCapability,
|
|
feedbackTemplates: {
|
|
fileFeedback: getAnnotateFileFeedbackTemplate(
|
|
(options.origin ?? "pi") as PromptRuntime,
|
|
),
|
|
messageFeedback: getAnnotateMessageFeedbackTemplate(
|
|
(options.origin ?? "pi") as PromptRuntime,
|
|
),
|
|
},
|
|
});
|
|
} else if (url.pathname === "/api/plan" && req.method === "GET") {
|
|
// Local rendered-HTML roots serve their current bytes (see
|
|
// readRootHtml); every other session serves what it started with.
|
|
const rootRead = readRootHtml();
|
|
const servedHtml = rootRead?.kind === "current" ? rootRead.html : options.rawHtml;
|
|
// The version-diff fields describe the SAVED baseline: previousPlan
|
|
// and versionInfo name the version history saved at startup, which
|
|
// stays the correct "previous version" however often the file is
|
|
// edited afterwards. When the served bytes differ from the startup
|
|
// snapshot the diff is RECOMPUTED against them (htmlDiff is pure,
|
|
// and a GET never writes history), so a tab reload after an agent
|
|
// edit keeps the "Show changes" toggle instead of losing it for
|
|
// the rest of the session. The in-app Refresh reads the same
|
|
// fields off /api/doc (rootHtmlVersionDiff), so refresh and
|
|
// reload converge on the same state. Mirrors packages/server/annotate.ts.
|
|
const servedIsSnapshot = servedHtml === options.rawHtml;
|
|
const displayRawHtml = options.renderHtml && servedHtml
|
|
? htmlAssets.rewriteHtml(servedHtml, options.filePath)
|
|
: undefined;
|
|
// For HTML, render the version diff as the real page with inline
|
|
// <ins>/<del> highlights (tag-aware htmlDiff), asset-rewritten the
|
|
// same way as the live page so it renders identically.
|
|
const diffHtml =
|
|
options.renderHtml && servedHtml && annotateHistory?.previousPlan
|
|
? htmlAssets.rewriteHtml(htmlDiff(annotateHistory.previousPlan, servedHtml), options.filePath)
|
|
: undefined;
|
|
const primarySource = getPrimarySource();
|
|
json(res, {
|
|
plan: primarySource.plan,
|
|
origin: options.origin ?? "pi",
|
|
mode: options.mode || "annotate",
|
|
filePath: options.filePath,
|
|
sourceInfo: options.sourceInfo,
|
|
sourceConverted: options.sourceConverted ?? false,
|
|
sourceSave: primarySource.sourceSave,
|
|
gate: options.gate ?? false,
|
|
approvalNotesSupported: options.approvalNotesSupported ?? false,
|
|
clientLease: options.clientLeaseSupported
|
|
? { enabled: true as const, reconnectGraceMs: clientLeaseGraceMs }
|
|
: { enabled: false as const },
|
|
renderAs: displayRawHtml ? 'html' : 'markdown',
|
|
...(displayRawHtml ? { rawHtml: displayRawHtml } : {}),
|
|
...(diffHtml ? { diffHtml } : {}),
|
|
convertHtml: options.convertHtml ?? false,
|
|
...(annotateHistory
|
|
? {
|
|
previousPlan: annotateHistory.previousPlan,
|
|
versionInfo: annotateHistory.versionInfo,
|
|
diffCurrent: servedIsSnapshot || !servedHtml ? annotateHistory.diffCurrent : servedHtml,
|
|
}
|
|
: {}),
|
|
sharingEnabled,
|
|
shareBaseUrl,
|
|
pasteApiUrl,
|
|
repoInfo,
|
|
projectRoot: options.folderPath || process.cwd(),
|
|
// Extra extensions the user registered as markdown (#1307).
|
|
// The renderer needs them to linkify relative/wiki links to
|
|
// sibling docs the same way it linkifies .md ones.
|
|
markdownExtensions: getExtraMarkdownExtensions(),
|
|
serverConfig: getServerConfig(gitUser),
|
|
agentTerminal: agentTerminalCapability,
|
|
...(options.recentMessages ? { recentMessages: options.recentMessages } : {}),
|
|
// Resolved copy-wrapper templates (config-aware, placeholders
|
|
// intact) so clipboard Copy matches what Send Feedback produces
|
|
// instead of the plan-deny wrap (#1107). Resolved per request so
|
|
// config edits mid-session behave like Send Feedback (which
|
|
// resolves at submit time).
|
|
feedbackTemplates: {
|
|
fileFeedback: getAnnotateFileFeedbackTemplate(
|
|
(options.origin ?? "pi") as PromptRuntime,
|
|
),
|
|
messageFeedback: getAnnotateMessageFeedbackTemplate(
|
|
(options.origin ?? "pi") as PromptRuntime,
|
|
),
|
|
},
|
|
});
|
|
} else if (url.pathname === "/api/plan/version" && req.method === "GET") {
|
|
// fetch a specific version of the annotated file (version diff base picker)
|
|
//
|
|
// Folder sessions pass `?path=` (optionally `&base=`) to identify which
|
|
// file's history to read, resolved and containment-checked exactly like
|
|
// /api/doc; the slug is always derived server-side from that resolved
|
|
// path — a client-supplied slug is never accepted, since the history
|
|
// lookup joins it into a filesystem path unsanitized. Without `path`,
|
|
// behavior is unchanged: the single session's own history is used.
|
|
const pathParam = url.searchParams.get("path");
|
|
let slug: string;
|
|
if (pathParam !== null) {
|
|
const resolved = resolveAllowedDocPath(pathParam, url.searchParams.get("base"), {
|
|
rootPaths: getReferenceRootPaths(),
|
|
});
|
|
if (resolved.kind === "denied") {
|
|
json(res, { error: "Access denied: path is outside project root" }, 403);
|
|
return;
|
|
}
|
|
slug = deriveAnnotateHistorySlug(resolved.path);
|
|
if (getVersionCount(annotateProjectName, slug) === 0) {
|
|
json(res, { error: "No version history" }, 404);
|
|
return;
|
|
}
|
|
} else {
|
|
if (!annotateHistory) {
|
|
json(res, { error: "No version history" }, 404);
|
|
return;
|
|
}
|
|
slug = annotateHistory.slug;
|
|
}
|
|
const vParam = url.searchParams.get("v");
|
|
const v = vParam ? parseInt(vParam, 10) : NaN;
|
|
if (isNaN(v) || v < 1) {
|
|
json(res, { error: "Invalid version number" }, 400);
|
|
return;
|
|
}
|
|
const content = getPlanVersion(annotateProjectName, 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" && req.method === "GET") {
|
|
// list all stored versions of the annotated file (Version Browser)
|
|
// Same `?path=`/`&base=` parameterization as /api/plan/version above.
|
|
const pathParam = url.searchParams.get("path");
|
|
if (pathParam !== null) {
|
|
const resolved = resolveAllowedDocPath(pathParam, url.searchParams.get("base"), {
|
|
rootPaths: getReferenceRootPaths(),
|
|
});
|
|
if (resolved.kind === "denied") {
|
|
json(res, { error: "Access denied: path is outside project root" }, 403);
|
|
return;
|
|
}
|
|
const slug = deriveAnnotateHistorySlug(resolved.path);
|
|
const versions = listVersions(annotateProjectName, slug);
|
|
json(res, {
|
|
project: annotateProjectName,
|
|
slug: versions.length > 0 ? slug : null,
|
|
versions,
|
|
});
|
|
return;
|
|
}
|
|
if (!annotateHistory) {
|
|
json(res, { project: annotateProjectName, slug: null, versions: [] });
|
|
return;
|
|
}
|
|
json(res, {
|
|
project: annotateProjectName,
|
|
slug: annotateHistory.slug,
|
|
versions: listVersions(annotateProjectName, annotateHistory.slug),
|
|
});
|
|
} else if (url.pathname === "/api/share-html" && req.method === "GET") {
|
|
handleShareHtml(res, url);
|
|
} else if (url.pathname === "/api/config" && req.method === "POST") {
|
|
try {
|
|
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; favicon?: FaviconStyle; conventionalComments?: boolean; agentTerminalSide?: unknown; agentTerminalDefaultAgent?: unknown };
|
|
const toSave: Record<string, unknown> = {};
|
|
if (body.displayName !== undefined) toSave.displayName = body.displayName;
|
|
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
|
|
if (body.theme !== undefined) toSave.theme = body.theme;
|
|
if (isFaviconStyle(body.favicon)) toSave.favicon = body.favicon;
|
|
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
|
|
if (isAgentTerminalSide(body.agentTerminalSide)) toSave.agentTerminalSide = body.agentTerminalSide;
|
|
if (typeof body.agentTerminalDefaultAgent === "string") toSave.agentTerminalDefaultAgent = body.agentTerminalDefaultAgent;
|
|
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
|
|
json(res, { ok: true });
|
|
} catch {
|
|
json(res, { error: "Invalid request" }, 400);
|
|
}
|
|
} else if (url.pathname === "/api/image") {
|
|
handleImageRequest(res, url);
|
|
} else if (htmlAssets.handle(res, url)) {
|
|
return;
|
|
} else if (url.pathname === "/api/upload" && req.method === "POST") {
|
|
await handleUploadRequest(req, res);
|
|
} else if (url.pathname === "/api/open-in/apps" && req.method === "GET") {
|
|
// Remote/headless sessions can't open apps on the user's machine, and
|
|
// URL annotations have no local file to reveal — report unavailable so
|
|
// the UI hides the control entirely.
|
|
const urlSource = /^https?:\/\//i.test(options.filePath);
|
|
if (isRemoteSession() || urlSource) {
|
|
json(res, { available: false, apps: [] });
|
|
return;
|
|
}
|
|
json(res, { available: true, apps: getAvailableOpenInApps() });
|
|
} else if (url.pathname === "/api/open-in" && req.method === "POST") {
|
|
if (isRemoteSession() || /^https?:\/\//i.test(options.filePath)) {
|
|
json(res, { ok: false, error: "Open in app is unavailable for this source" }, 400);
|
|
return;
|
|
}
|
|
try {
|
|
const body = await parseBody(req);
|
|
const filePath = body.filePath;
|
|
if (typeof filePath !== "string" || !filePath) {
|
|
json(res, { ok: false, error: "Missing filePath" }, 400);
|
|
return;
|
|
}
|
|
const appId = typeof body.appId === "string" ? body.appId : undefined;
|
|
// Confine opens to the same reference roots /api/doc serves from,
|
|
// so any linked doc the user can view can also be opened.
|
|
const abs = resolveOpenInTarget(filePath, null, getReferenceRootPaths);
|
|
if (abs == null) {
|
|
json(res, { ok: false, error: "Path is outside the allowed directory" }, 403);
|
|
return;
|
|
}
|
|
const result = await openFileInApp(abs, appId);
|
|
json(res, result, 200);
|
|
} catch (err) {
|
|
json(
|
|
res,
|
|
{ ok: false, error: err instanceof Error ? err.message : "Failed to open file" },
|
|
500,
|
|
);
|
|
}
|
|
} else if (url.pathname === "/api/draft") {
|
|
await handleDraftRequest(req, res, draftKey);
|
|
} else if (url.pathname === "/api/doc" && req.method === "GET") {
|
|
// Inject source file's directory as base for relative path resolution.
|
|
// Skip for URL annotations — there's no local directory to resolve against.
|
|
if (!url.searchParams.has("base") && options.filePath && !/^https?:\/\//i.test(options.filePath)) {
|
|
url.searchParams.set("base", options.mode === "annotate-folder" && options.folderPath ? options.folderPath : dirname(resolvePath(options.filePath)));
|
|
}
|
|
if (options.convertHtml && !url.searchParams.has("convert")) {
|
|
url.searchParams.set("convert", "1");
|
|
}
|
|
await handleDocRequest(res, url, {
|
|
rewriteHtml: htmlAssets.rewriteHtml,
|
|
sourceSaveFilePath: singleFileSourceSaveEligible
|
|
? initialSingleFileSourcePath ?? options.filePath
|
|
: undefined,
|
|
sourceSaveFolderPath: options.mode === "annotate-folder" ? options.folderPath : undefined,
|
|
onSourceDocumentServed: (path) => openedSourceFilePaths.add(path),
|
|
rootPaths: getReferenceRootPaths(),
|
|
annotateHistory:
|
|
options.mode === "annotate-folder" && annotateHistoryEnabled
|
|
? { compute: computeFolderAnnotateHistory }
|
|
: undefined,
|
|
rootHtmlVersionDiff,
|
|
});
|
|
} else if (url.pathname === "/api/source/save" && req.method === "POST") {
|
|
let body: SourceSaveRequest;
|
|
try {
|
|
body = (await parseBody(req)) as unknown as SourceSaveRequest;
|
|
} catch {
|
|
json(res, { ok: false, code: "invalid-request", message: "Invalid JSON body." }, 400);
|
|
return;
|
|
}
|
|
|
|
if (typeof body.text !== "string" || typeof body.baseHash !== "string") {
|
|
json(res, { ok: false, code: "invalid-request", message: "Expected text and baseHash." }, 400);
|
|
return;
|
|
}
|
|
|
|
let targetPath: string | null = null;
|
|
if (singleFileSourceSaveEligible) {
|
|
const capability = createSourceSaveCapability("single-file", initialSingleFileSourcePath ?? options.filePath);
|
|
targetPath = capability.enabled ? capability.path : initialSingleFileSourcePath;
|
|
} else if (options.mode === "annotate-folder" && options.folderPath && typeof body.path === "string") {
|
|
targetPath = body.allowMissingBase
|
|
? resolveFolderSourceFileForSave(body.path, options.folderPath)
|
|
: resolveFolderSourceFile(body.path, options.folderPath);
|
|
if (
|
|
body.allowMissingBase &&
|
|
targetPath &&
|
|
!existsSync(targetPath) &&
|
|
!openedSourceFilePaths.has(targetPath)
|
|
) {
|
|
targetPath = null;
|
|
}
|
|
}
|
|
|
|
if (!targetPath) {
|
|
json(res, { ok: false, code: "not-writable", message: "This document cannot be saved to a file." }, 403);
|
|
return;
|
|
}
|
|
|
|
const result = saveSourceFileAtomic(targetPath, body.text, body.baseHash, {
|
|
allowMissingBase: body.allowMissingBase === true,
|
|
missingBaseEol: body.baseEol,
|
|
allowedRoot: options.mode === "annotate-folder" ? options.folderPath : undefined,
|
|
});
|
|
const status = result.ok
|
|
? 200
|
|
: result.code === "conflict"
|
|
? 409
|
|
: result.code === "invalid-request"
|
|
? 400
|
|
: result.code === "not-writable"
|
|
? 403
|
|
: 500;
|
|
json(res, result, status);
|
|
} else if (url.pathname === "/api/doc/exists" && req.method === "POST") {
|
|
await handleDocExistsRequest(res, req, { rootPaths: getReferenceRootPaths() });
|
|
} else if (url.pathname === "/api/obsidian/vaults") {
|
|
handleObsidianVaultsRequest(res);
|
|
} else if (url.pathname === "/api/skills" && req.method === "GET") {
|
|
handleReferenceSkillsRequest(res);
|
|
} else if (url.pathname === "/api/skills/content" && req.method === "GET") {
|
|
handleReferenceSkillContentRequest(res, url);
|
|
} else if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {
|
|
handleObsidianFilesRequest(res, url);
|
|
} else if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") {
|
|
handleObsidianDocRequest(res, url);
|
|
} else if (url.pathname === "/api/reference/files" && req.method === "GET") {
|
|
await handleFileBrowserRequest(res, url);
|
|
} else if (url.pathname === "/api/reference/files/stream" && req.method === "GET") {
|
|
handleFileBrowserStreamRequest(req, res, url);
|
|
return;
|
|
} else if (url.pathname === "/favicon.png") {
|
|
handleFavicon(res);
|
|
} else if (url.pathname === "/api/exit" && req.method === "POST") {
|
|
if (!decision.settle({ feedback: "", annotations: [], exit: true })) {
|
|
sendAlreadyDecided(res);
|
|
return;
|
|
}
|
|
deleteDraft(draftKey, readDraftGenerationFromUrl(req));
|
|
clientLease.cancel();
|
|
json(res, { ok: true });
|
|
} else if (url.pathname === "/api/approve" && req.method === "POST") {
|
|
try {
|
|
const body = await parseOptionalApprovalBody(req);
|
|
if (
|
|
(body.feedback !== undefined && typeof body.feedback !== "string") ||
|
|
(body.annotations !== undefined && !Array.isArray(body.annotations)) ||
|
|
(body.codeAnnotations !== undefined && !Array.isArray(body.codeAnnotations)) ||
|
|
(body.draftGeneration !== undefined && typeof body.draftGeneration !== "number")
|
|
) {
|
|
json(res, { error: "Invalid approval body." }, 400);
|
|
return;
|
|
}
|
|
|
|
const approvalWon = decision.settle({
|
|
feedback: (body.feedback as string | undefined) || "",
|
|
annotations: (body.annotations as unknown[] | undefined) || [],
|
|
approved: true,
|
|
// Approval notes carry the same message scoping as /api/feedback —
|
|
// without it, approve-with-notes in a multi-message annotate-last
|
|
// session anchors to the last message instead of the one the
|
|
// reviewer picked.
|
|
selectedMessageId: typeof body.selectedMessageId === "string" ? body.selectedMessageId : undefined,
|
|
feedbackScope: body.feedbackScope === "messages" ? "messages" : body.feedbackScope === "message" ? "message" : undefined,
|
|
});
|
|
if (!approvalWon) {
|
|
sendAlreadyDecided(res);
|
|
return;
|
|
}
|
|
// Approve-with-notes carries user content — make it durable before
|
|
// the draft (the reviewer's only other copy) is deleted (#678).
|
|
const approvalDurable = persistSubmittedDecision(
|
|
(body.feedback as string | undefined) || "",
|
|
(body.annotations as unknown[] | undefined) || [],
|
|
true,
|
|
);
|
|
if (approvalDurable) deleteDraft(draftKey, readDraftGenerationFromBody(body));
|
|
clientLease.cancel();
|
|
json(res, { ok: true });
|
|
} catch (err) {
|
|
json(res, { error: err instanceof Error ? err.message : "Invalid JSON body." }, 400);
|
|
}
|
|
} else if (url.pathname === "/api/feedback" && req.method === "POST") {
|
|
try {
|
|
const body = await parseBody(req);
|
|
const feedbackWon = decision.settle({
|
|
feedback: (body.feedback as string) || "",
|
|
annotations: (body.annotations as unknown[]) || [],
|
|
selectedMessageId: typeof body.selectedMessageId === "string" ? body.selectedMessageId : undefined,
|
|
feedbackScope: body.feedbackScope === "messages" ? "messages" : body.feedbackScope === "message" ? "message" : undefined,
|
|
});
|
|
if (!feedbackWon) {
|
|
sendAlreadyDecided(res);
|
|
return;
|
|
}
|
|
// Make the submitted feedback durable BEFORE deleting the draft:
|
|
// the decision promise's consumer may have timed out, and this
|
|
// record is then the only surviving copy (#678).
|
|
const feedbackDurable = persistSubmittedDecision(
|
|
(body.feedback as string) || "",
|
|
(body.annotations as unknown[]) || [],
|
|
false,
|
|
);
|
|
if (feedbackDurable) deleteDraft(draftKey, readDraftGenerationFromBody(body));
|
|
clientLease.cancel();
|
|
json(res, { ok: true });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Failed to process feedback";
|
|
json(res, { error: message }, 500);
|
|
}
|
|
} else if (url.pathname === "/api/save-notes" && req.method === "POST") {
|
|
await handleSaveNotesRequest(req, res);
|
|
} else if (url.pathname.startsWith("/api/")) {
|
|
handleApiNotFound(res, url.pathname);
|
|
} else {
|
|
html(res, options.htmlContent);
|
|
}
|
|
});
|
|
const agentTerminal = await createNodeAgentTerminalBridge({
|
|
enabled: supportsAnnotateAgentTerminalMode(options.mode || "annotate"),
|
|
cwd: options.agentCwd ?? process.cwd(),
|
|
server,
|
|
});
|
|
agentTerminalCapability = agentTerminal.capability;
|
|
|
|
const { port, portSource } = await listenOnPort(server);
|
|
|
|
if (options.liveApp) {
|
|
// Compose the proxy-served bridge body via the shared assembly (config
|
|
// prelude with the token this server owns, both editor origin forms
|
|
// with the localhost one first to match the advertised URL, then the
|
|
// bootstrap that installs the CSS, then the bridge itself). A proxy
|
|
// startup failure must not leave the annotate listener hanging.
|
|
try {
|
|
liveSessionToken = randomBytes(16).toString("hex");
|
|
const editorOrigins = buildLiveEditorOrigins(port);
|
|
liveProxy = await startLiveAppProxyNode({
|
|
targetUrl: options.liveApp.targetUrl,
|
|
editorOrigins,
|
|
bridgeJs: composeLiveBridgeJs({
|
|
token: liveSessionToken,
|
|
editorOrigins,
|
|
annotationCss: options.liveApp.annotationCss,
|
|
bridgeBootstrap: options.liveApp.bridgeBootstrap,
|
|
bridgeScript: options.liveApp.bridgeScript,
|
|
}),
|
|
});
|
|
// Advertise the proxy under the LOCALHOST spelling, carrying the
|
|
// target URL's own path and query (see buildLiveAppUrl in the
|
|
// shared core for the same-site/cookie rationale).
|
|
// PLANNOTATOR_URL_HOST is never applied here.
|
|
liveAppUrl = buildLiveAppUrl(liveProxy.port, options.liveApp.targetUrl);
|
|
} catch (error) {
|
|
// Same disposal set the normal stop() runs, each step guarded so
|
|
// the original startup error is what propagates.
|
|
for (const dispose of [
|
|
() => closeAllFileBrowserWatchers(),
|
|
() => {
|
|
clientLease.cancel();
|
|
clientLease.closeSessions();
|
|
},
|
|
() => aiRuntime?.dispose(),
|
|
() => agentTerminal.dispose(),
|
|
]) {
|
|
try {
|
|
dispose();
|
|
} catch {
|
|
// startup failure cleanup: best effort
|
|
}
|
|
}
|
|
server.close();
|
|
(server as { closeAllConnections?: () => void }).closeAllConnections?.();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Mirror the Bun server: bind first, then warm through the async shared walk.
|
|
void warmFileListCache(process.cwd(), "code");
|
|
|
|
return {
|
|
port,
|
|
portSource,
|
|
url: buildAdvertisedUrl(port),
|
|
waitForDecision: () => decisionPromise,
|
|
stop: () => {
|
|
// Per-step guard (mirrors the Bun server's runGuardedShutdown): one
|
|
// throwing disposal must not skip the steps after it — agent-terminal
|
|
// teardown is historically fragile (#1314) — and must never leave the
|
|
// listener bound.
|
|
const disposals: Array<[string, () => void]> = [
|
|
// First: watchers hold the embedded host process alive, and a
|
|
// throwing disposal below must not strand them.
|
|
["file browser watchers", () => closeAllFileBrowserWatchers()],
|
|
// Long-lived host process: an unclosed lease stream would keep its
|
|
// heartbeat timer and socket alive past the session, and would keep
|
|
// server.close() from ever completing.
|
|
["client lease", () => {
|
|
clientLease.cancel();
|
|
clientLease.closeSessions();
|
|
}],
|
|
["AI runtime", () => aiRuntime?.dispose()],
|
|
["agent terminal", () => agentTerminal.dispose()],
|
|
// Live proxy last, mirroring the Bun server's guarded order: a
|
|
// throw in the historically fragile agent-terminal teardown
|
|
// (#1314) must never orphan the proxy's listener or its
|
|
// upstream sockets.
|
|
["live proxy", () => liveProxy?.stop()],
|
|
];
|
|
try {
|
|
for (const [name, dispose] of disposals) {
|
|
try {
|
|
dispose();
|
|
} catch (error) {
|
|
console.error(`[plannotator] annotate shutdown: ${name} disposal failed:`, error);
|
|
}
|
|
}
|
|
} finally {
|
|
server.close();
|
|
// close() only stops the listener; drain browser keep-alive sockets so a
|
|
// stopped session's connections die immediately instead of at the
|
|
// browser's whim (parity with Bun's server.stop(), which closes idle
|
|
// connections). Guarded: jiti can run under hosts whose node:http lacks
|
|
// closeAllConnections.
|
|
server.closeAllConnections?.();
|
|
}
|
|
},
|
|
};
|
|
}
|