feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { tmpdir } from "node:os";
import { Readable } from "node:stream";
import {
MAX_INLINE_BASH_OUTPUT_BYTES,
openRegularFileNoFollow,
readUtf8FileWithinLimit,
resolveBashOutputPath,
} from "@/lib/bash-output";
import { isBashOutputPathReferencedBySession } from "@/lib/session-file-references";
// GET /api/agent/[id]/bash-output?path=<absPath>
// Reads a bash output temp file referenced by this session. Inline display is
// size-limited; download responses stream the file without buffering it.
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
let path: string | null = null;
let download = false;
try {
const url = new URL(_req.url);
path = url.searchParams.get("path");
download = url.searchParams.get("download") === "1";
} catch {
return NextResponse.json({ error: "invalid url" }, { status: 400 });
}
if (!path) {
return NextResponse.json({ error: "path required" }, { status: 400 });
}
const resolved = resolveBashOutputPath(path, tmpdir());
if (!resolved) {
return NextResponse.json({ error: "invalid path" }, { status: 400 });
}
if (!await isBashOutputPathReferencedBySession(resolved, id)) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
try {
if (download) {
const { handle } = await openRegularFileNoFollow(resolved);
const stream = Readable.toWeb(handle.createReadStream()) as ReadableStream<Uint8Array>;
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "attachment; filename=\"bash-output.log\"",
"Cache-Control": "no-store",
},
});
}
const result = await readUtf8FileWithinLimit(resolved);
if (result.tooLarge) {
return NextResponse.json({
error: `Full output is too large to display (limit ${MAX_INLINE_BASH_OUTPUT_BYTES} bytes)`,
data: { size: result.size, maxBytes: MAX_INLINE_BASH_OUTPUT_BYTES },
}, { status: 413 });
}
return NextResponse.json({ success: true, data: { output: result.content } });
} catch {
return NextResponse.json({ error: "full output unavailable" }, { status: 404 });
}
}
+71
View File
@@ -0,0 +1,71 @@
import { resolveSessionPath } from "@/lib/session-reader";
import { getRpcSession, startRpcSession } from "@/lib/rpc-manager";
import { SessionManager } from "@earendil-works/pi-coding-agent";
export const dynamic = "force-dynamic";
// GET /api/agent/[id]/events - SSE stream of agent events
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// Fast path: already-running session
let session = getRpcSession(id);
if (!session || !session.isAlive()) {
const filePath = await resolveSessionPath(id);
if (!filePath) {
return new Response("Session not found", { status: 404 });
}
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
try {
({ session } = await startRpcSession(id, filePath, cwd));
} catch (error) {
return new Response(`Failed to start agent: ${error}`, { status: 500 });
}
}
const stream = new ReadableStream({
start(controller) {
const encode = (data: unknown) => {
const text = `data: ${JSON.stringify(data)}\n\n`;
controller.enqueue(new TextEncoder().encode(text));
};
// Send initial connected event
encode({ type: "connected", sessionId: id });
const unsubscribe = session.onEvent((event) => {
encode(event);
});
// Heartbeat every 30s to prevent server/proxy timeout (Next.js default ~120-150s)
const heartbeat = setInterval(() => {
try {
controller.enqueue(new TextEncoder().encode(":\n\n"));
} catch {
// controller already closed
}
}, 30_000);
// Cleanup when client disconnects
const cleanup = () => {
clearInterval(heartbeat);
unsubscribe();
controller.close();
};
// Detect client disconnect via abort signal
req.signal?.addEventListener("abort", cleanup);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
+57
View File
@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { resolveSessionPath } from "@/lib/session-reader";
import { startRpcSession, getRpcSession } from "@/lib/rpc-manager";
import { SessionManager } from "@earendil-works/pi-coding-agent";
// POST /api/agent/[id] - Send a command to an existing session
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const body = await req.json() as { type: string; [key: string]: unknown };
// Fast path: already-running session
const existing = getRpcSession(id);
if (existing?.isAlive()) {
const result = await existing.send(body);
return NextResponse.json({ success: true, data: result });
}
const filePath = await resolveSessionPath(id);
if (!filePath) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
const { session } = await startRpcSession(id, filePath, cwd);
const result = await session.send(body);
return NextResponse.json({ success: true, data: result });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// GET /api/agent/[id] - Get current agent state
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const session = getRpcSession(id);
if (!session || !session.isAlive()) {
return NextResponse.json({ running: false });
}
const state = await session.send({ type: "get_state" });
return NextResponse.json({ running: true, state });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { existsSync } from "fs";
import { randomUUID } from "crypto";
import { allowFileRoot } from "@/lib/file-access";
import { invalidateSessionListCache } from "@/lib/session-reader";
import { startRpcSession } from "@/lib/rpc-manager";
// POST /api/agent/new body: { cwd: string; type: string; message?: string; ... }
// Spawns a brand-new pi session. Most calls immediately send the first command;
// type:"ensure_session" only creates the runtime so clients can query commands.
// Returns { sessionId, data } where sessionId is pi's real session id.
export async function POST(req: Request) {
try {
const body = await req.json() as { cwd?: string; [key: string]: unknown };
const { cwd, ...command } = body;
if (!cwd || typeof cwd !== "string") {
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
}
if (!existsSync(cwd)) {
return NextResponse.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
}
// Use a one-time key so startRpcSession's lock doesn't conflict with real session ids
const { provider, modelId, toolNames, thinkingLevel, ...promptCommand } = command as { provider?: string; modelId?: string; toolNames?: string[]; thinkingLevel?: string; [key: string]: unknown };
// Must be unique per request: startRpcSession coalesces concurrent callers
// that share a key onto one session. Date.now() (ms resolution) collides for
// requests in the same millisecond, merging two new sessions into one.
const tempKey = `__new__${randomUUID()}`;
const { session, realSessionId } = await startRpcSession(tempKey, "", cwd, toolNames);
// Keep the files-route allowed-roots cache (see app/api/files/[...path]/route.ts)
// in sync so the new cwd is immediately readable via /api/files. Without this,
// a file request under a brand-new cwd would 403 for up to the cache TTL.
allowFileRoot(cwd);
invalidateSessionListCache();
// Apply pre-selected model before sending the prompt
if (provider && modelId) {
await session.send({ type: "set_model", provider, modelId });
}
// Apply pre-selected thinking level before sending the prompt
if (thinkingLevel) {
await session.send({ type: "set_thinking_level", level: thinkingLevel });
}
if (promptCommand.type === "ensure_session") {
return NextResponse.json({ success: true, sessionId: realSessionId, data: null });
}
const result = await session.send(promptCommand);
return NextResponse.json({ success: true, sessionId: realSessionId, data: result });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
@@ -0,0 +1,56 @@
import { getRunningRpcSessionIds, subscribeRunningSessions } from "@/lib/rpc-manager";
export const dynamic = "force-dynamic";
// GET /api/agent/running/events - SSE stream of the set of currently-running
// session ids. Pushes an update whenever any session starts or stops working,
// so the sidebar never has to poll.
export async function GET(req: Request) {
const stream = new ReadableStream({
start(controller) {
const encode = (data: unknown) => {
const text = `data: ${JSON.stringify(data)}\n\n`;
controller.enqueue(new TextEncoder().encode(text));
};
// Subscribe BEFORE taking the initial snapshot so no state change can slip
// through the gap between snapshot and subscription.
const unsubscribe = subscribeRunningSessions((ids) => {
try {
encode({ type: "running", runningSessionIds: ids });
} catch {
// controller already closed
}
});
// Initial snapshot so the client renders the correct state immediately.
// (A duplicate frame here is harmless: the client just sets the same set.)
encode({ type: "running", runningSessionIds: getRunningRpcSessionIds() });
// Heartbeat to keep the connection alive through proxies/timeouts.
const heartbeat = setInterval(() => {
try {
controller.enqueue(new TextEncoder().encode(":\n\n"));
} catch {
// controller already closed
}
}, 30_000);
const cleanup = () => {
clearInterval(heartbeat);
unsubscribe();
try { controller.close(); } catch { /* already closed */ }
};
req.signal?.addEventListener("abort", cleanup);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
@@ -0,0 +1,40 @@
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
export const dynamic = "force-dynamic";
// Providers that use OAuth — handled separately via /api/auth/providers
const OAUTH_PROVIDER_IDS = new Set(["anthropic", "github-copilot", "openai-codex"]);
export async function GET() {
const modelRuntime = await ModelRuntime.create();
const all = modelRuntime.getModels();
// Deduplicate by provider, skip OAuth-only providers and custom providers (source=models_json_key)
const seen = new Set<string>();
const result: {
id: string;
displayName: string;
configured: boolean;
source?: string;
modelCount: number;
}[] = [];
for (const provider of modelRuntime.getProviders()) {
if (seen.has(provider.id)) continue;
seen.add(provider.id);
if (OAUTH_PROVIDER_IDS.has(provider.id) || !provider.auth.apiKey?.login) continue;
const status = modelRuntime.getProviderAuthStatus(provider.id);
// Skip providers whose key comes from models.json (those are custom providers)
if (status.source === "models_json_key") continue;
const modelCount = all.filter((model) => model.provider === provider.id).length;
result.push({
id: provider.id,
displayName: provider.name,
configured: status.configured,
source: status.source,
modelCount,
});
}
return Response.json({ providers: result });
}
@@ -0,0 +1,62 @@
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { NextResponse } from "next/server";
import { invalidateModelsCache } from "@/lib/models-cache";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ provider: string }> };
// GET /api/auth/api-key/[provider] — returns auth status (never returns the actual key)
export async function GET(_req: Request, { params }: Params) {
const { provider } = await params;
const modelRuntime = await ModelRuntime.create();
const status = modelRuntime.getProviderAuthStatus(provider);
const displayName = modelRuntime.getProvider(provider)?.name ?? provider;
const models = modelRuntime.getModels(provider).length;
return NextResponse.json({ provider, displayName, configured: status.configured, source: status.source, models });
}
// POST /api/auth/api-key/[provider] body: { apiKey: string }
export async function POST(req: Request, { params }: Params) {
const { provider } = await params;
try {
const { apiKey } = await req.json() as { apiKey?: string };
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
return NextResponse.json({ error: "apiKey is required" }, { status: 400 });
}
const modelRuntime = await ModelRuntime.create();
let keySubmitted = false;
await modelRuntime.login(provider, "api_key", {
notify: () => {},
prompt: async (prompt) => {
if (prompt.type === "select") {
const keyOption = prompt.options.find((option) => option.id === "api-key" || option.id === "bearer-token");
if (keyOption) return keyOption.id;
throw new Error(`${provider} requires interactive authentication setup`);
}
if (!keySubmitted && prompt.type === "secret") {
keySubmitted = true;
return apiKey.trim();
}
throw new Error(`${provider} requires additional authentication settings`);
},
});
invalidateModelsCache();
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// DELETE /api/auth/api-key/[provider] — removes stored API key
export async function DELETE(_req: Request, { params }: Params) {
const { provider } = await params;
try {
const modelRuntime = await ModelRuntime.create();
await modelRuntime.logout(provider);
invalidateModelsCache();
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
@@ -0,0 +1,192 @@
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { invalidateModelsCache } from "@/lib/models-cache";
export const dynamic = "force-dynamic";
// In-memory registry: loginToken -> resolve/reject for the manualCodeInput promise
declare global {
var __piLoginCallbacks: Map<string, { resolve: (v: string) => void; reject: (e: Error) => void }> | undefined;
}
function getCallbackRegistry() {
if (!globalThis.__piLoginCallbacks) globalThis.__piLoginCallbacks = new Map();
return globalThis.__piLoginCallbacks;
}
// POST /api/auth/login/[provider] — frontend sends redirect URL or auth code
export async function POST(
req: Request,
{ params }: { params: Promise<{ provider: string }> }
) {
const { provider } = await params;
const { token, code } = (await req.json()) as { token?: string; code?: string };
if (!token || !code) {
return Response.json({ error: "token and code required" }, { status: 400 });
}
const registry = getCallbackRegistry();
const callbacks = registry.get(token);
if (!callbacks) {
return Response.json({ error: "No pending login for token" }, { status: 404 });
}
// Verify token belongs to this provider (token format: "<provider>-<ts>-<random>")
if (!token.startsWith(`${provider}-`)) {
return Response.json({ error: "Token does not match provider" }, { status: 400 });
}
callbacks.resolve(code);
registry.delete(token);
return Response.json({ ok: true, provider });
}
// GET /api/auth/login/[provider] — SSE stream for OAuth flow
export async function GET(
req: Request,
{ params }: { params: Promise<{ provider: string }> }
) {
const { provider } = await params;
const encoder = new TextEncoder();
const send = (controller: ReadableStreamDefaultController, data: unknown) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
// AbortController propagates client disconnect into ModelRuntime.login().
const abort = new AbortController();
req.signal.addEventListener("abort", () => abort.abort());
const stream = new ReadableStream({
async start(controller) {
const modelRuntime = await ModelRuntime.create();
if (!modelRuntime.getProvider(provider)?.auth.oauth) {
send(controller, { type: "error", message: `Unknown provider: ${provider}` });
controller.close();
return;
}
const registry = getCallbackRegistry();
const activeTokens = new Set<string>();
let pendingManualRequest: { token: string; promise: Promise<string> } | undefined;
const createClientInputRequest = () => {
const token = `${provider}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
activeTokens.add(token);
const promise = new Promise<string>((resolve, reject) => {
registry.set(token, {
resolve: (value) => {
activeTokens.delete(token);
registry.delete(token);
resolve(value);
},
reject: (error) => {
activeTokens.delete(token);
registry.delete(token);
reject(error);
},
});
});
return { token, promise };
};
const getManualInputRequest = () => {
if (!pendingManualRequest) {
pendingManualRequest = createClientInputRequest();
pendingManualRequest.promise
.finally(() => {
pendingManualRequest = undefined;
})
.catch(() => {});
}
return pendingManualRequest;
};
// Cleanup: remove pending token and abort any waiting promise
const cleanup = () => {
for (const token of activeTokens) {
registry.get(token)?.reject(new Error("Login cancelled"));
registry.delete(token);
}
activeTokens.clear();
};
// Also cancel on client disconnect
abort.signal.addEventListener("abort", cleanup);
try {
await modelRuntime.login(provider, "oauth", {
prompt: async (prompt: AuthPrompt) => {
const request = prompt.type === "manual_code"
? getManualInputRequest()
: createClientInputRequest();
if (prompt.type === "select") {
send(controller, {
type: "select_request",
message: prompt.message,
options: prompt.options,
token: request.token,
});
} else {
send(controller, {
type: "prompt_request",
message: prompt.message,
placeholder: prompt.placeholder ?? null,
token: request.token,
});
}
return request.promise;
},
notify: (event: AuthEvent) => {
if (event.type === "auth_url") {
const request = getManualInputRequest();
send(controller, {
type: "auth",
url: event.url,
instructions: event.instructions ?? null,
token: request.token,
});
} else if (event.type === "device_code") {
send(controller, {
type: "device_code",
userCode: event.userCode,
verificationUri: event.verificationUri,
intervalSeconds: event.intervalSeconds ?? null,
expiresInSeconds: event.expiresInSeconds ?? null,
});
} else {
send(controller, { type: "progress", message: event.message });
}
},
signal: abort.signal,
});
invalidateModelsCache();
send(controller, { type: "success" });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg !== "Login cancelled") {
send(controller, { type: "error", message: msg });
} else {
send(controller, { type: "cancelled" });
}
} finally {
cleanup();
controller.close();
}
},
cancel() {
abort.abort();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
@@ -0,0 +1,18 @@
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { invalidateModelsCache } from "@/lib/models-cache";
export const dynamic = "force-dynamic";
export async function POST(
_req: Request,
{ params }: { params: Promise<{ provider: string }> }
) {
const { provider } = await params;
const modelRuntime = await ModelRuntime.create();
if (!modelRuntime.getProvider(provider)?.auth.oauth) {
return Response.json({ error: `Unknown provider: ${provider}` }, { status: 400 });
}
await modelRuntime.logout(provider);
invalidateModelsCache();
return Response.json({ ok: true });
}
+33
View File
@@ -0,0 +1,33 @@
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
export const dynamic = "force-dynamic";
export async function GET() {
const modelRuntime = await ModelRuntime.create();
const credentials = await modelRuntime.listCredentials();
const loggedInProviders = new Set(
credentials.filter((credential) => credential.type === "oauth").map((credential) => credential.providerId),
);
const providers = modelRuntime.getProviders().filter((provider) => provider.auth.oauth);
const EXCLUDED = new Set(["anthropic"]);
const DISPLAY_NAMES: Record<string, string> = {
"openai-codex": "ChatGPT Plus/Pro",
"github-copilot": "GitHub Copilot",
};
const result = await Promise.all(
providers
.filter((p) => !EXCLUDED.has(p.id))
.map(async (p) => {
return {
id: p.id,
name: DISPLAY_NAMES[p.id] ?? p.name,
usesCallbackServer: false,
loggedIn: loggedInProviders.has(p.id),
};
})
);
return Response.json({ providers: result });
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { stat } from "fs/promises";
import {
getBrowseStartDirectory,
getParentDirectory,
listDirectories,
resolveDirectory,
} from "@/lib/directory-browser";
// GET /api/cwd/browse?path=...:列出文件系统中的可读子目录。
export async function GET(request: NextRequest) {
try {
const requested = request.nextUrl.searchParams.get("path")?.trim();
const candidate = getBrowseStartDirectory(requested);
let resolved: string;
try {
resolved = await resolveDirectory(candidate);
} catch {
return NextResponse.json({ error: "Directory does not exist" }, { status: 404 });
}
const directoryStat = await stat(resolved);
if (!directoryStat.isDirectory()) {
return NextResponse.json({ error: "Path is not a directory" }, { status: 400 });
}
const directories = await listDirectories(resolved);
return NextResponse.json({
path: resolved,
parentPath: getParentDirectory(resolved),
directories,
});
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { statSync, type Stats } from "fs";
import { homedir } from "os";
import { isAbsolute, resolve } from "path";
import { allowFileRoot } from "@/lib/file-access";
function normalizeCwd(cwd: string): string {
if (cwd === "~") return homedir();
if (cwd.startsWith("~/")) return resolve(homedir(), cwd.slice(2));
return isAbsolute(cwd) ? cwd : resolve(cwd);
}
// POST /api/cwd/validate body: { cwd: string }
// Validates a candidate workspace before the UI selects it.
export async function POST(req: Request) {
try {
const body = await req.json() as { cwd?: unknown };
const cwd = typeof body.cwd === "string" ? body.cwd.trim() : "";
if (!cwd) {
return NextResponse.json({ error: "Path is required" }, { status: 400 });
}
const normalizedCwd = normalizeCwd(cwd);
let stat: Stats;
try {
stat = statSync(normalizedCwd);
} catch {
return NextResponse.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
}
if (!stat.isDirectory()) {
return NextResponse.json({ error: `Path is not a directory: ${cwd}` }, { status: 400 });
}
allowFileRoot(normalizedCwd);
return NextResponse.json({ success: true, cwd: normalizedCwd });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { mkdirSync } from "fs";
import { homedir } from "os";
import { join } from "path";
import { allowFileRoot } from "@/lib/file-access";
// POST /api/default-cwd
// Creates ~/pi-cwd-<YYYYMMDD> if it doesn't exist and returns the path.
export async function POST() {
try {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const dir = join(homedir(), `pi-cwd-${date}`);
mkdirSync(dir, { recursive: true });
allowFileRoot(dir);
return NextResponse.json({ cwd: dir });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
+169
View File
@@ -0,0 +1,169 @@
import { NextRequest, NextResponse } from "next/server";
import { execFile } from "child_process";
import { promisify } from "util";
import fs from "fs";
import path from "path";
import {
getAllowedFileRoots,
isExistingFilePathAllowed,
isFilePathAllowed,
isWindowsAbsolutePath,
} from "@/lib/file-access";
import { buildEntriesFromFiles, filterFileEntries, type FileIndexEntry } from "@/lib/file-fuzzy";
const execFileAsync = promisify(execFile);
// Same skip lists as /api/files — only used for the non-git readdir fallback.
// Git-tracked repos rely on .gitignore instead (matches the TUI's fd behavior).
const IGNORED_NAMES = new Set([
"node_modules", ".git", ".next", "dist", "build", "__pycache__",
".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache",
"target", "vendor", ".DS_Store",
]);
const IGNORED_SUFFIXES = [".pyc"];
/** Cap on the plain (no-query) response used as the client-side index */
const MAX_FILES = 5000;
/** Hard caps on the full in-memory listing that ?q= searches against */
const GIT_HARD_CAP = 200_000;
const WALK_HARD_CAP = 50_000;
const MAX_WALK_DEPTH = 8;
const MAX_QUERY_LENGTH = 500;
const CACHE_TTL_MS = 10_000;
const CACHE_MAX_ENTRIES = 20;
interface FileListing {
/** Full listing up to the hard cap (not the client cap) */
files: string[];
/** True when even the hard cap was exceeded */
hardTruncated: boolean;
}
interface CacheEntry {
listing: FileListing;
/** Derived lazily on the first ?q= search against this listing */
entries?: FileIndexEntry[];
expiresAt: number;
}
// Per-cwd cache on globalThis so it survives Next.js hot-reload; the @ menu
// re-requests on every open and searches on every keystroke, so listings must
// not be recomputed within a short window.
declare global {
var __piFileIndexCache: Map<string, CacheEntry> | undefined;
}
function getIndexCache(): Map<string, CacheEntry> {
if (!globalThis.__piFileIndexCache) globalThis.__piFileIndexCache = new Map();
return globalThis.__piFileIndexCache;
}
async function listWithGit(cwd: string): Promise<FileListing | null> {
try {
const { stdout } = await execFileAsync(
"git",
["-C", cwd, "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
{ timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } },
);
const all = stdout.split("\0").filter(Boolean);
if (all.length > GIT_HARD_CAP) {
return { files: all.slice(0, GIT_HARD_CAP), hardTruncated: true };
}
return { files: all, hardTruncated: false };
} catch {
// Not a git repo (or git unavailable) — caller falls back to readdir walk.
return null;
}
}
function listWithWalk(cwd: string): FileListing {
const files: string[] = [];
// BFS so shallow files win when the cap truncates the listing.
const queue: Array<{ abs: string; rel: string; depth: number }> = [{ abs: cwd, rel: "", depth: 0 }];
while (queue.length > 0) {
const { abs, rel, depth } = queue.shift()!;
let dirents: fs.Dirent[];
try {
dirents = fs.readdirSync(abs, { withFileTypes: true });
} catch {
continue;
}
for (const d of dirents) {
if (IGNORED_NAMES.has(d.name) || IGNORED_SUFFIXES.some((s) => d.name.endsWith(s))) continue;
const childRel = rel ? `${rel}/${d.name}` : d.name;
if (d.isDirectory()) {
if (depth + 1 <= MAX_WALK_DEPTH) {
queue.push({ abs: path.join(abs, d.name), rel: childRel, depth: depth + 1 });
}
} else if (d.isFile()) {
if (files.length >= WALK_HARD_CAP) {
return { files, hardTruncated: true };
}
files.push(childRel);
}
}
}
return { files, hardTruncated: false };
}
// GET /api/file-index?cwd=/abs/path[&q=query]
// Without q: { files: string[] (relative to cwd, capped at MAX_FILES),
// truncated: boolean } — the client-side index for local filtering.
// With q: { matches: { path, isDir }[] } — ranked against the FULL listing so
// repos larger than MAX_FILES still find deep files (cap applied after
// matching, like the TUI passing the query to fd).
// Guarded by the same allow-list as /api/files.
export async function GET(req: NextRequest) {
try {
const cwd = req.nextUrl.searchParams.get("cwd")?.trim() ?? "";
if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) {
return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 });
}
const query = req.nextUrl.searchParams.get("q")?.slice(0, MAX_QUERY_LENGTH) ?? "";
const allowedRoots = await getAllowedFileRoots();
if (!isFilePathAllowed(cwd, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
let stat: fs.Stats;
try {
stat = fs.statSync(cwd);
} catch {
return NextResponse.json({ error: "Directory not found" }, { status: 404 });
}
if (!stat.isDirectory()) {
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
}
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const cache = getIndexCache();
const now = Date.now();
let cached = cache.get(cwd);
if (!cached || cached.expiresAt <= now) {
const listing = (await listWithGit(cwd)) ?? listWithWalk(cwd);
for (const [key, entry] of cache) {
if (entry.expiresAt <= now) cache.delete(key);
}
if (cache.size >= CACHE_MAX_ENTRIES) cache.clear();
cached = { listing, expiresAt: now + CACHE_TTL_MS };
cache.set(cwd, cached);
}
if (query) {
cached.entries ??= buildEntriesFromFiles(cached.listing.files);
return NextResponse.json({ matches: filterFileEntries(cached.entries, query) });
}
const { files, hardTruncated } = cached.listing;
return NextResponse.json({
files: files.slice(0, MAX_FILES),
truncated: hardTruncated || files.length > MAX_FILES,
});
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
+611
View File
@@ -0,0 +1,611 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import {
getAllowedFileRoots,
isExistingFilePathAllowed,
isFilePathAllowed,
isWindowsAbsolutePath,
normalizeSlashes,
} from "@/lib/file-access";
import {
DOCX_PREVIEW_MAX_BYTES,
IMAGE_PREVIEW_MAX_BYTES,
TEXT_PREVIEW_MAX_BYTES,
documentPreviewKind,
getAudioMime,
getDocumentMime,
getFileExt,
getImageMime,
} from "@/lib/file-types";
import { resolveDirentIsDirectory } from "@/lib/file-dirent";
import { isFilePathReferencedBySession } from "@/lib/session-file-references";
import { isApiRequestAllowed } from "@/lib/request-security";
import {
inspectUploadTargets,
parseUploadConflictStrategy,
validateUploadFileNames,
} from "@/lib/file-upload";
import { parseFormDataWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data";
const IGNORED_NAMES = new Set([
"node_modules", ".git", ".next", "dist", "build", "__pycache__",
".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache",
"target", "vendor", ".DS_Store", ".git",
]);
const IGNORED_SUFFIXES = [".pyc"];
const FILE_REQUEST_TYPES = ["list", "read", "download", "meta", "preview", "watch"] as const;
type FileRequestType = typeof FILE_REQUEST_TYPES[number];
const FILE_REQUEST_TYPE_SET = new Set<string>(FILE_REQUEST_TYPES);
const MAX_UPLOAD_FILE_BYTES = 25 * 1024 * 1024;
const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024;
// Multipart boundaries and headers are not file bytes, but must be bounded too.
const MAX_UPLOAD_REQUEST_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024;
const EXT_TO_LANGUAGE: Record<string, string> = {
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby",
go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift",
c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp",
html: "html", htm: "html", css: "css", scss: "css", less: "css",
json: "json", jsonl: "json", yaml: "yaml", yml: "yaml",
toml: "toml", xml: "xml", md: "markdown", mdx: "markdown",
sh: "bash", bash: "bash", zsh: "bash", fish: "bash",
sql: "sql", graphql: "graphql", gql: "graphql",
dockerfile: "dockerfile", tf: "hcl", hcl: "hcl",
env: "bash", gitignore: "bash", txt: "text",
pdf: "pdf", docx: "word",
};
function getLanguage(filePath: string): string {
const base = path.basename(filePath).toLowerCase();
// Special full-name matches
if (base === "dockerfile" || base.startsWith("dockerfile.")) return "dockerfile";
if (base === ".env" || base.startsWith(".env.")) return "bash";
if (base === "makefile" || base === "gnumakefile") return "makefile";
const ext = base.split(".").pop() ?? "";
return EXT_TO_LANGUAGE[ext] ?? "text";
}
function filePathFromSegments(segments: string[]): string {
const joined = segments.join("/");
const slashJoined = normalizeSlashes(joined);
if (isWindowsAbsolutePath(slashJoined)) return slashJoined;
return "/" + joined.replace(/^\/+/, "");
}
function parseFileRequestType(value: string): FileRequestType | null {
return FILE_REQUEST_TYPE_SET.has(value) ? (value as FileRequestType) : null;
}
async function getUploadDirectory(segments: string[]): Promise<
{ directory: string } | { response: NextResponse }
> {
const directory = filePathFromSegments(segments);
const allowedRoots = await getAllowedFileRoots();
if (!isFilePathAllowed(directory, allowedRoots)) {
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
}
let stat: fs.Stats;
try {
stat = fs.statSync(directory);
} catch {
return { response: NextResponse.json({ error: "Upload directory not found" }, { status: 404 }) };
}
if (!stat.isDirectory()) {
return { response: NextResponse.json({ error: "Upload target is not a directory" }, { status: 400 }) };
}
// A browsable directory can be a symlink. Resolve both sides before writes
// so a symlink inside an allowed root cannot redirect uploads outside it.
const realDirectory = fs.realpathSync(directory);
const realRoots = new Set<string>();
for (const root of allowedRoots) {
try {
realRoots.add(fs.realpathSync(root));
} catch {
// Ignore stale session roots that no longer exist.
}
}
if (!isFilePathAllowed(realDirectory, realRoots)) {
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
}
return { directory: realDirectory };
}
function parseUploadFileNames(value: unknown): string[] | null {
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return null;
return value;
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
if (!isApiRequestAllowed(request)) {
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
}
try {
const { path: segments } = await params;
const uploadDirectory = await getUploadDirectory(segments);
if ("response" in uploadDirectory) return uploadDirectory.response;
const { directory } = uploadDirectory;
const type = request.nextUrl.searchParams.get("type") ?? "upload";
if (type === "upload-check") {
const body = await request.json().catch(() => null) as { fileNames?: unknown } | null;
const fileNames = parseUploadFileNames(body?.fileNames);
if (!fileNames) {
return NextResponse.json({ error: "fileNames must be an array of strings" }, { status: 400 });
}
const validationError = validateUploadFileNames(fileNames);
if (validationError) {
return NextResponse.json({ error: validationError }, { status: 400 });
}
return NextResponse.json(inspectUploadTargets(directory, fileNames));
}
if (type !== "upload") {
return NextResponse.json({ error: "Invalid upload request type" }, { status: 400 });
}
const strategy = parseUploadConflictStrategy(request.nextUrl.searchParams.get("conflict"));
if (!strategy) {
return NextResponse.json({ error: "Invalid conflict strategy" }, { status: 400 });
}
let formData: FormData;
try {
formData = await parseFormDataWithinLimit(request, MAX_UPLOAD_REQUEST_BYTES);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 });
}
throw error;
}
const files = formData.getAll("files").filter((entry): entry is File => typeof entry !== "string");
if (files.some((file) => file.size > MAX_UPLOAD_FILE_BYTES)) {
return NextResponse.json({ error: "Each upload must be 25MB or smaller" }, { status: 413 });
}
if (files.reduce((total, file) => total + file.size, 0) > MAX_UPLOAD_TOTAL_BYTES) {
return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 });
}
const fileNames = files.map((file) => file.name);
const validationError = validateUploadFileNames(fileNames);
if (validationError) {
return NextResponse.json({ error: validationError }, { status: 400 });
}
const inspection = inspectUploadTargets(directory, fileNames);
if (strategy === "error" && inspection.conflicts.length > 0) {
return NextResponse.json({
error: "One or more files already exist",
conflicts: inspection.conflicts,
nonReplaceable: inspection.nonReplaceable,
}, { status: 409 });
}
const conflictSet = new Set(inspection.conflicts);
const nonReplaceableSet = new Set(inspection.nonReplaceable);
const uploaded: string[] = [];
const skipped: string[] = [];
const errors: Array<{ name: string; error: string }> = [];
for (const file of files) {
const destination = path.join(directory, file.name);
if (conflictSet.has(file.name) && strategy === "skip") {
skipped.push(file.name);
continue;
}
if (conflictSet.has(file.name) && nonReplaceableSet.has(file.name)) {
errors.push({ name: file.name, error: "Cannot replace a directory or symbolic link" });
continue;
}
let bytes: Buffer;
try {
bytes = Buffer.from(await file.arrayBuffer());
} catch (error) {
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
continue;
}
if (conflictSet.has(file.name)) {
try {
fs.unlinkSync(destination);
} catch (error) {
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
continue;
}
}
try {
fs.writeFileSync(destination, bytes, { flag: "wx" });
uploaded.push(file.name);
} catch (error) {
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
}
}
return NextResponse.json(
{ uploaded, skipped, errors },
{ status: errors.length > 0 ? 207 : 200 },
);
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
}
}
function createFileBodyStream(filePath: string, range?: { start: number; end: number }): ReadableStream<Uint8Array> {
const fileStream = fs.createReadStream(filePath, range);
let closed = false;
return new ReadableStream<Uint8Array>({
start(controller) {
fileStream.on("data", (chunk: Buffer) => {
if (closed) return;
try {
controller.enqueue(new Uint8Array(chunk));
} catch {
closed = true;
fileStream.destroy();
}
});
fileStream.once("end", () => {
if (closed) return;
closed = true;
try {
controller.close();
} catch {
// The browser may cancel media probes before the file stream ends.
}
});
fileStream.once("error", (error) => {
if (closed) return;
closed = true;
try {
controller.error(error);
} catch {
// The response was already abandoned by the client.
}
});
},
cancel() {
closed = true;
fileStream.destroy();
},
});
}
function encodeHeaderValue(value: string): string {
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) =>
`%${ch.charCodeAt(0).toString(16).toUpperCase()}`
);
}
function getContentDisposition(filePath: string, asDownload = false): string {
const disposition = asDownload ? "attachment" : "inline";
const fileName = path.basename(filePath);
const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "download";
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`;
}
function streamFile(filePath: string, stat: fs.Stats, contentType: string, rangeHeader: string | null, asDownload = false): Response {
const headers = {
"Content-Type": contentType,
"Cache-Control": "no-cache",
"Accept-Ranges": "bytes",
"Content-Disposition": getContentDisposition(filePath, asDownload),
};
if (!rangeHeader) {
return new Response(createFileBodyStream(filePath), {
headers: {
...headers,
"Content-Length": String(stat.size),
},
});
}
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
if (!match) {
return new Response(null, {
status: 416,
headers: {
...headers,
"Content-Range": `bytes */${stat.size}`,
},
});
}
let start = match[1] ? Number(match[1]) : 0;
let end = match[2] ? Number(match[2]) : stat.size - 1;
if (!match[1] && match[2]) {
const suffixLength = Number(match[2]);
start = Math.max(stat.size - suffixLength, 0);
end = stat.size - 1;
}
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= stat.size) {
return new Response(null, {
status: 416,
headers: {
...headers,
"Content-Range": `bytes */${stat.size}`,
},
});
}
end = Math.min(end, stat.size - 1);
const chunkSize = end - start + 1;
return new Response(createFileBodyStream(filePath, { start, end }), {
status: 206,
headers: {
...headers,
"Content-Length": String(chunkSize),
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
},
});
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function wrapDocxPreviewHtml(bodyHtml: string, fileName: string): string {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { color-scheme: light; }
html, body { margin: 0; min-height: 100%; background: #eef1f5; color: #171717; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 28px; }
main {
box-sizing: border-box;
max-width: 840px;
min-height: calc(100vh - 56px);
margin: 0 auto;
padding: 56px 64px;
background: #fff;
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.14);
}
.file-title {
margin: 0 0 28px;
padding-bottom: 10px;
border-bottom: 1px solid #e5e7eb;
color: #6b7280;
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
word-break: break-word;
}
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.1em 0 0.45em; color: #111827; }
p { margin: 0.65em 0; line-height: 1.7; }
table { border-collapse: collapse; max-width: 100%; margin: 1em 0; }
th, td { border: 1px solid #d1d5db; padding: 6px 9px; vertical-align: top; }
img { max-width: 100%; height: auto; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
a { color: #2563eb; }
@media (max-width: 720px) {
body { padding: 0; background: #fff; }
main { min-height: 100vh; padding: 28px 22px; box-shadow: none; }
}
</style>
</head>
<body>
<main>
<div class="file-title">${escapeHtml(fileName)}</div>
${bodyHtml}
</main>
</body>
</html>`;
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
const { path: segments } = await params;
const filePath = filePathFromSegments(segments);
const rawType = request.nextUrl.searchParams.get("type") ?? "list";
const type = parseFileRequestType(rawType);
if (!type) {
return NextResponse.json({ error: "Invalid file request type" }, { status: 400 });
}
const sessionId = request.nextUrl.searchParams.get("sessionId");
const allowedRoots = await getAllowedFileRoots();
const allowedByRoot = isFilePathAllowed(filePath, allowedRoots);
const allowedBySessionReference =
!allowedByRoot &&
type !== "list" &&
await isFilePathReferencedBySession(filePath, sessionId);
if (!allowedByRoot && !allowedBySessionReference) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
let stat: fs.Stats;
try {
stat = fs.statSync(filePath);
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
if (type === "read") {
if (!stat.isFile()) {
return NextResponse.json({ error: "Not a file" }, { status: 400 });
}
const imageMime = getImageMime(filePath);
if (imageMime) {
if (stat.size > IMAGE_PREVIEW_MAX_BYTES) {
return NextResponse.json({ error: "Image too large (>10MB)" }, { status: 413 });
}
return streamFile(filePath, stat, imageMime, request.headers.get("range"));
}
const audioMime = getAudioMime(filePath);
if (audioMime) {
return streamFile(filePath, stat, audioMime, request.headers.get("range"));
}
const documentMime = getDocumentMime(filePath);
if (documentMime) {
return streamFile(filePath, stat, documentMime, request.headers.get("range"));
}
if (stat.size > TEXT_PREVIEW_MAX_BYTES) {
return NextResponse.json({ error: "File too large for preview (>256KB)" }, { status: 413 });
}
const content = fs.readFileSync(filePath, "utf-8");
const language = getLanguage(filePath);
return NextResponse.json({ content, language, size: stat.size });
}
if (type === "download") {
if (!stat.isFile()) {
return NextResponse.json({ error: "Not a file" }, { status: 400 });
}
const mime = getImageMime(filePath) || getAudioMime(filePath) || getDocumentMime(filePath) || "application/octet-stream";
return streamFile(filePath, stat, mime, request.headers.get("range"), true);
}
if (type === "meta") {
if (!stat.isFile()) {
return NextResponse.json({ error: "Not a file" }, { status: 400 });
}
const imageMime = getImageMime(filePath);
const audioMime = getAudioMime(filePath);
const documentMime = getDocumentMime(filePath);
return NextResponse.json({
size: stat.size,
language: getLanguage(filePath),
mime: imageMime || audioMime || documentMime || "text/plain",
previewKind: documentPreviewKind(filePath),
});
}
if (type === "preview") {
if (!stat.isFile()) {
return NextResponse.json({ error: "Not a file" }, { status: 400 });
}
if (getFileExt(filePath) !== "docx") {
return NextResponse.json({ error: "Preview not available for this file type" }, { status: 400 });
}
if (stat.size > DOCX_PREVIEW_MAX_BYTES) {
return NextResponse.json({ error: "DOCX too large for preview (>10MB)" }, { status: 413 });
}
const mammoth = await import("mammoth");
const result = await mammoth.convertToHtml(
{ path: filePath },
{
externalFileAccess: false,
convertImage: mammoth.images.dataUri,
}
);
const html = wrapDocxPreviewHtml(result.value, path.basename(filePath));
return new Response(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache",
"Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
},
});
}
if (type === "watch") {
if (!stat.isFile()) {
return NextResponse.json({ error: "Not a file" }, { status: 400 });
}
let watcher: fs.FSWatcher | null = null;
let lastMtimeMs = stat.mtimeMs;
let lastSize = stat.size;
const stream = new ReadableStream({
start(controller) {
const send = (eventName: string, data: Record<string, unknown>) => {
const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
try {
controller.enqueue(new TextEncoder().encode(payload));
} catch {
// client disconnected
}
};
// Send initial ping so client knows connection is live
send("connected", { filePath });
try {
watcher = fs.watch(filePath, () => {
try {
const s = fs.statSync(filePath);
// Some platforms emit watch events for file reads/attribute
// access. Ignore those or the client's refresh read loops.
if (s.mtimeMs === lastMtimeMs && s.size === lastSize) return;
lastMtimeMs = s.mtimeMs;
lastSize = s.size;
send("change", { mtime: s.mtime.toISOString(), size: s.size });
} catch {
send("change", { mtime: new Date().toISOString(), size: 0 });
}
});
watcher.on("error", () => {
try { controller.close(); } catch { /* ignore */ }
});
} catch {
send("error", { message: "Failed to watch file" });
controller.close();
}
},
cancel() {
try { watcher?.close(); } catch { /* ignore */ }
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
// type === "list"
if (!stat.isDirectory()) {
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
}
// Avoid per-entry stat calls for normal files and directories. Symlinks and
// filesystems without directory type information use the stat fallback.
const dirents = fs.readdirSync(filePath, { withFileTypes: true });
const entries = dirents
.filter((d) => !IGNORED_NAMES.has(d.name) && !IGNORED_SUFFIXES.some((s) => d.name.endsWith(s)))
.flatMap((d) => {
const isDir = resolveDirentIsDirectory(d, path.join(filePath, d.name));
return isDir === null
? []
: [{ name: d.name, isDir, size: 0, modified: "" }];
})
.sort((a, b) => {
// Dirs first, then files, both alphabetically
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
return NextResponse.json({ entries, path: filePath });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "@/lib/file-access";
import { getGitFileDiff } from "@/lib/git-changes";
export async function GET(request: NextRequest) {
try {
const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? "";
const filePath = request.nextUrl.searchParams.get("path")?.trim() ?? "";
if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) {
return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 });
}
if (!filePath || (!filePath.startsWith("/") && !isWindowsAbsolutePath(filePath))) {
return NextResponse.json({ error: "path must be an absolute path" }, { status: 400 });
}
const allowedRoots = await getAllowedFileRoots();
if (!isFilePathAllowed(cwd, allowedRoots) || !isFilePathAllowed(filePath, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
// The cwd must resolve inside an allowed root. The file itself may no
// longer exist when Git reports it as deleted; getGitFileDiff verifies
// that the requested path belongs to this repository and its status.
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
return NextResponse.json(await getGitFileDiff(cwd, filePath));
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
}
}
+35
View File
@@ -0,0 +1,35 @@
import fs from "fs";
import { NextRequest, NextResponse } from "next/server";
import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "@/lib/file-access";
import { getGitStatus } from "@/lib/git-changes";
export async function GET(request: NextRequest) {
try {
const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? "";
if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) {
return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 });
}
const allowedRoots = await getAllowedFileRoots();
if (!isFilePathAllowed(cwd, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
let stat: fs.Stats;
try {
stat = fs.statSync(cwd);
} catch {
return NextResponse.json({ error: "Directory not found" }, { status: 404 });
}
if (!stat.isDirectory()) {
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
}
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
return NextResponse.json(await getGitStatus(cwd));
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
}
}
+6
View File
@@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
import { homedir } from "os";
export async function GET() {
return NextResponse.json({ home: homedir() });
}
@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import {
flattenModelsDevCatalog,
recommendModelCatalogPreset,
searchModelCatalog,
type ModelCatalogEntry,
} from "@/lib/model-catalog";
export const dynamic = "force-dynamic";
const MODELS_DEV_URL = "https://models.dev/api.json";
const CATALOG_TTL_MS = 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 15_000;
interface CatalogCache {
entries: ModelCatalogEntry[];
expiresAt: number;
inFlight?: Promise<ModelCatalogEntry[]>;
}
declare global {
var __piModelsDevCatalogCache: CatalogCache | undefined;
}
function getCache(): CatalogCache {
return globalThis.__piModelsDevCatalogCache ??= { entries: [], expiresAt: 0 };
}
async function fetchCatalog(): Promise<ModelCatalogEntry[]> {
const response = await fetch(MODELS_DEV_URL, {
cache: "no-store",
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`models.dev returned HTTP ${response.status}`);
const entries = flattenModelsDevCatalog(await response.json());
if (entries.length === 0) throw new Error("models.dev returned an empty catalog");
return entries;
}
async function loadCatalog(): Promise<ModelCatalogEntry[]> {
const cache = getCache();
if (cache.entries.length > 0 && cache.expiresAt > Date.now()) return cache.entries;
if (!cache.inFlight) {
cache.inFlight = fetchCatalog().then((entries) => {
cache.entries = entries;
cache.expiresAt = Date.now() + CATALOG_TTL_MS;
return entries;
}).finally(() => {
cache.inFlight = undefined;
});
}
try {
return await cache.inFlight;
} catch (error) {
if (cache.entries.length > 0) return cache.entries;
throw error;
}
}
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const query = (searchParams.get("q") ?? "").slice(0, 120);
const provider = (searchParams.get("provider") ?? "").slice(0, 120);
const baseUrl = (searchParams.get("baseUrl") ?? "").slice(0, 500);
const parsedLimit = Number.parseInt(searchParams.get("limit") ?? "50", 10);
const limit = Number.isFinite(parsedLimit) ? parsedLimit : 50;
try {
const entries = await loadCatalog();
const models = searchModelCatalog(entries, query, provider, limit);
const recommendation = recommendModelCatalogPreset(entries, query, provider, baseUrl);
return NextResponse.json({ models, recommendation, source: MODELS_DEV_URL });
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 502 });
}
}
@@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { resolveModelDiscoveryAuth } from "@/lib/model-discovery-auth";
import { buildModelsListUrl, parseDiscoveredModels } from "@/lib/model-discovery";
export const dynamic = "force-dynamic";
const DISCOVERY_TIMEOUT_MS = 20_000;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasHeader(headers: Headers, name: string): boolean {
return headers.has(name);
}
function buildHeaders(api: string, apiKey: string | undefined, configured: Record<string, string>): Headers {
const headers = new Headers(configured);
if (!hasHeader(headers, "accept")) headers.set("Accept", "application/json");
if (!apiKey) return headers;
if (api === "anthropic-messages") {
if (!hasHeader(headers, "x-api-key")) headers.set("x-api-key", apiKey);
if (!hasHeader(headers, "anthropic-version")) headers.set("anthropic-version", "2023-06-01");
} else if (api === "google-generative-ai") {
if (!hasHeader(headers, "x-goog-api-key")) headers.set("x-goog-api-key", apiKey);
} else if (!hasHeader(headers, "authorization")) {
headers.set("Authorization", `Bearer ${apiKey}`);
}
return headers;
}
export async function POST(req: Request) {
try {
const body = await req.json() as { providerName?: unknown; provider?: unknown };
const providerName = typeof body.providerName === "string" ? body.providerName.trim() : "";
if (!providerName) return NextResponse.json({ error: "providerName is required" }, { status: 400 });
if (!isRecord(body.provider)) return NextResponse.json({ error: "provider is required" }, { status: 400 });
const baseUrl = typeof body.provider.baseUrl === "string" ? body.provider.baseUrl.trim() : "";
if (!baseUrl) return NextResponse.json({ error: "Base URL is required" }, { status: 400 });
const api = typeof body.provider.api === "string" && body.provider.api
? body.provider.api
: "openai-completions";
let endpoint: URL;
try {
endpoint = buildModelsListUrl(baseUrl, api);
} catch {
return NextResponse.json({ error: "Base URL is invalid" }, { status: 400 });
}
const auth = await resolveModelDiscoveryAuth(providerName, body.provider);
if (typeof body.provider.apiKey === "string" && body.provider.apiKey.trim() && !auth.apiKey) {
return NextResponse.json({ error: `No API key found for "${providerName}"` }, { status: 400 });
}
const response = await fetch(endpoint, {
cache: "no-store",
headers: buildHeaders(api, auth.apiKey, auth.headers),
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
});
const responseText = await response.text();
if (!response.ok) {
return NextResponse.json({
error: responseText.slice(0, 500) || `Upstream returned HTTP ${response.status}`,
status: response.status,
}, { status: 502 });
}
let payload: unknown;
try {
payload = JSON.parse(responseText);
} catch {
return NextResponse.json({ error: "Upstream model list was not valid JSON" }, { status: 502 });
}
const models = parseDiscoveredModels(payload);
if (models.length === 0) {
return NextResponse.json({ error: "No models found in the upstream response" }, { status: 502 });
}
return NextResponse.json({ models, endpoint: endpoint.toString() });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const status = error instanceof DOMException && error.name === "TimeoutError" ? 504 : 500;
return NextResponse.json({ error: message }, { status });
}
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { join, dirname } from "path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { invalidateModelsCache } from "@/lib/models-cache";
export const dynamic = "force-dynamic";
function getModelsPath(): string {
return join(getAgentDir(), "models.json");
}
function readModelsJson(): Record<string, unknown> {
const path = getModelsPath();
if (!existsSync(path)) return { providers: {} };
try {
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
} catch {
return { providers: {} };
}
}
function writeModelsJson(data: Record<string, unknown>): void {
const path = getModelsPath();
const dir = dirname(path);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(path, JSON.stringify(data, null, 2), "utf8");
}
export async function GET() {
return NextResponse.json(readModelsJson());
}
export async function PUT(req: Request) {
try {
const body = await req.json() as Record<string, unknown>;
writeModelsJson(body);
invalidateModelsCache();
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
+121
View File
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
import { mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { completeSimple, type AssistantMessage } from "@earendil-works/pi-ai/compat";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
export const dynamic = "force-dynamic";
const TEST_TIMEOUT_MS = 20_000;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function getAssistantText(message: AssistantMessage): string {
return message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
}
export async function POST(req: Request) {
if (!isApiRequestAllowed(req)) {
return NextResponse.json({ ok: false, error: "Untrusted API request" }, { status: 403 });
}
if (!hasJsonContentType(req)) {
return NextResponse.json(
{ ok: false, error: "Content-Type must be application/json" },
{ status: 415 },
);
}
let tempDir: string | undefined;
try {
const body = await req.json() as { providerName?: unknown; provider?: unknown; model?: unknown };
const providerName = typeof body.providerName === "string" ? body.providerName.trim() : "";
if (!providerName) return NextResponse.json({ ok: false, error: "providerName is required" }, { status: 400 });
if (!isRecord(body.provider)) return NextResponse.json({ ok: false, error: "provider is required" }, { status: 400 });
if (!isRecord(body.model)) return NextResponse.json({ ok: false, error: "model is required" }, { status: 400 });
const modelId = typeof body.model.id === "string" ? body.model.id.trim() : "";
if (!modelId) return NextResponse.json({ ok: false, error: "Model ID is required" }, { status: 400 });
tempDir = mkdtempSync(join(tmpdir(), "pi-web-model-test-"));
const modelsPath = join(tempDir, "models.json");
writeFileSync(modelsPath, JSON.stringify({
providers: {
[providerName]: {
...body.provider,
models: [{ ...body.model, id: modelId }],
},
},
}, null, 2), "utf8");
const modelRuntime = await ModelRuntime.create({ modelsPath });
const loadError = modelRuntime.getError();
if (loadError) return NextResponse.json({ ok: false, error: loadError });
const model = modelRuntime.getModel(providerName, modelId);
if (!model) return NextResponse.json({ ok: false, error: `Model not found: ${providerName}/${modelId}` });
const resolved = await modelRuntime.getAuth(model);
if (!resolved?.auth.apiKey) {
return NextResponse.json({ ok: false, error: `No API key found for "${providerName}"` });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
let status: number | undefined;
const startedAt = Date.now();
try {
const message = await completeSimple(model, {
messages: [{
role: "user",
content: "Reply with OK only.",
timestamp: Date.now(),
}],
}, {
apiKey: resolved.auth.apiKey,
headers: resolved.auth.headers,
maxTokens: 16,
timeoutMs: TEST_TIMEOUT_MS,
maxRetries: 0,
cacheRetention: "none",
signal: controller.signal,
onResponse: (response) => { status = response.status; },
});
const latencyMs = Date.now() - startedAt;
if (message.stopReason === "error" || message.stopReason === "aborted") {
return NextResponse.json({
ok: false,
error: message.errorMessage ?? (controller.signal.aborted ? "Test timed out" : "Model returned an error"),
latencyMs,
status,
});
}
return NextResponse.json({
ok: true,
latencyMs,
status,
responseText: getAssistantText(message).slice(0, 300),
});
} finally {
clearTimeout(timeout);
}
} catch (error) {
return NextResponse.json({ ok: false, error: errorMessage(error) }, { status: 500 });
} finally {
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
}
}
+123
View File
@@ -0,0 +1,123 @@
import { stat } from "fs/promises";
import { resolve } from "path";
import { createAgentSessionServices, getAgentDir, type SettingsManager } from "@earendil-works/pi-coding-agent";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import { loadModelsWithCache, withModelRuntimeError, type ModelsData } from "@/lib/models-cache";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { projectTrustReloadOptions } from "@/lib/project-trust";
import { createAppSettingsManager, getAppResourceLoaderOptions } from "@/lib/app-runtime";
export const dynamic = "force-dynamic";
const modelNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
function compareModelEntries(
a: { id: string; name: string; provider: string },
b: { id: string; name: string; provider: string }
): number {
return modelNameCollator.compare(a.name || a.id, b.name || b.id)
|| modelNameCollator.compare(a.provider, b.provider)
|| modelNameCollator.compare(a.id, b.id);
}
const THINKING_SUFFIXES = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
function stripThinkingSuffix(modelRef: string): string {
const trimmed = modelRef.trim();
const colonIndex = trimmed.lastIndexOf(":");
if (colonIndex === -1) return trimmed;
const suffix = trimmed.substring(colonIndex + 1);
return THINKING_SUFFIXES.has(suffix) ? trimmed.substring(0, colonIndex) : trimmed;
}
function filterByExactEnabledModels<T extends { id: string; provider: string }>(
available: readonly T[],
enabledModels: string[] | undefined,
): readonly T[] {
if (!enabledModels || enabledModels.length === 0) return available;
const refs = new Set(enabledModels.map(stripThinkingSuffix).filter(Boolean));
const visible = available.filter((m) => refs.has(`${m.provider}/${m.id}`) || refs.has(m.id));
return visible.length > 0 ? visible : available;
}
async function loadModels(cwd: string): Promise<ModelsData> {
const nameMap = new Map<string, string>();
let modelList: { id: string; name: string; provider: string }[] = [];
let defaultModel: { provider: string; modelId: string } | null = null;
const thinkingLevels: Record<string, string[]> = {};
const thinkingLevelMaps: Record<string, Record<string, string | null>> = {};
const agentDir = getAgentDir();
// Gate untrusted project extensions: enumerating models still imports and
// runs a repository's .pi/extensions factories, so honor project trust here
// too (see lib/project-trust.ts, #236).
const trustReloadOptions = projectTrustReloadOptions(cwd, agentDir);
const services = await createAgentSessionServices({
cwd,
agentDir,
settingsManager: createAppSettingsManager(cwd, agentDir),
resourceLoaderOptions: getAppResourceLoaderOptions(),
...(trustReloadOptions ? { resourceLoaderReloadOptions: trustReloadOptions } : {}),
});
const available = await services.modelRuntime.getAvailable();
const modelError = services.modelRuntime.getError();
const settings: SettingsManager = services.settingsManager;
const enabledModels = settings.getEnabledModels();
const visible = filterByExactEnabledModels(available, enabledModels);
modelList = visible.map((m: { id: string; name: string; provider: string }) => ({
id: m.id,
name: m.name,
provider: m.provider,
})).sort(compareModelEntries);
for (const m of visible) {
const key = `${m.provider}:${m.id}`;
nameMap.set(key, m.name);
thinkingLevels[key] = getSupportedThinkingLevels(m);
if (m.thinkingLevelMap) thinkingLevelMaps[key] = m.thinkingLevelMap;
}
const provider = settings.getDefaultProvider();
const modelId = settings.getDefaultModel();
if (provider && modelId && visible.some((m) => m.provider === provider && m.id === modelId)) {
defaultModel = { provider, modelId };
}
return withModelRuntimeError(
{ models: Object.fromEntries(nameMap), modelList, defaultModel, thinkingLevels, thinkingLevelMaps },
modelError,
);
}
const EMPTY_MODELS: ModelsData = {
models: {},
modelList: [],
defaultModel: null,
thinkingLevels: {},
thinkingLevelMaps: {},
};
export async function GET(req: Request) {
const requestedCwd = new URL(req.url).searchParams.get("cwd") || process.cwd();
const cwd = resolve(requestedCwd);
let cwdStat;
try {
cwdStat = await stat(cwd);
} catch {
return Response.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
}
if (!cwdStat.isDirectory()) {
return Response.json({ error: `Not a directory: ${cwd}` }, { status: 400 });
}
const allowedRoots = await getAllowedFileRoots();
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
return Response.json({ error: "Access denied" }, { status: 403 });
}
try {
return Response.json(await loadModelsWithCache(cwd, () => loadModels(cwd)));
} catch {
return Response.json(EMPTY_MODELS);
}
}
+379
View File
@@ -0,0 +1,379 @@
import { NextResponse } from "next/server";
import { existsSync, readFileSync, statSync } from "fs";
import { basename, dirname, extname, join, relative, resolve } from "path";
import {
DefaultPackageManager,
getAgentDir,
SettingsManager,
type PackageSource,
type ResolvedPaths,
type ResolvedResource,
} from "@earendil-works/pi-coding-agent";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
import { getProjectTrustStatus } from "@/lib/project-trust";
import { createAppSettingsManager, isManagedPath, isManagedRuntime } from "@/lib/app-runtime";
import type {
PluginDiagnostic,
PluginPackageInfo,
PluginResourceCounts,
PluginResourceInfo,
PluginResourceKind,
PluginScope,
PluginsResponse,
} from "@/lib/api-types";
export const dynamic = "force-dynamic";
type PluginAction = "install" | "remove" | "update" | "disable" | "enable";
function emptyCounts(): PluginResourceCounts {
return { extensions: 0, skills: 0, prompts: 0, themes: 0 };
}
function toPluginScope(scope: string): PluginScope {
return scope === "project" ? "project" : "global";
}
function keyFor(source: string, scope: PluginScope): string {
return `${scope}\0${source}`;
}
function getPackageSource(entry: PackageSource): string {
return typeof entry === "string" ? entry : entry.source;
}
function isDisabledPackage(entry: PackageSource): boolean {
if (typeof entry === "string") return false;
return (
Array.isArray(entry.extensions) && entry.extensions.length === 0 &&
Array.isArray(entry.skills) && entry.skills.length === 0 &&
Array.isArray(entry.prompts) && entry.prompts.length === 0 &&
Array.isArray(entry.themes) && entry.themes.length === 0
);
}
function getDisabledPackages(settingsManager: SettingsManager): Map<string, boolean> {
const disabled = new Map<string, boolean>();
for (const entry of settingsManager.getGlobalSettings().packages ?? []) {
disabled.set(keyFor(getPackageSource(entry), "global"), isDisabledPackage(entry));
}
for (const entry of settingsManager.getProjectSettings().packages ?? []) {
disabled.set(keyFor(getPackageSource(entry), "project"), isDisabledPackage(entry));
}
return disabled;
}
function setPackageDisabled(
settingsManager: SettingsManager,
source: string,
scope: PluginScope,
disabled: boolean,
): boolean {
const current = scope === "project"
? settingsManager.getProjectSettings().packages ?? []
: settingsManager.getGlobalSettings().packages ?? [];
let changed = false;
const next = current.map((entry): PackageSource => {
if (getPackageSource(entry) !== source) return entry;
changed = true;
if (disabled) {
return {
...(typeof entry === "string" ? { source: entry } : entry),
extensions: [],
skills: [],
prompts: [],
themes: [],
};
}
return getPackageSource(entry);
});
if (!changed) return false;
if (scope === "project") settingsManager.setProjectPackages(next);
else settingsManager.setPackages(next);
return true;
}
function addCount(counts: PluginResourceCounts, kind: keyof PluginResourceCounts): void {
counts[kind] += 1;
}
function getResourceName(path: string, kind: PluginResourceKind): string {
const file = basename(path);
const ext = extname(file);
if (kind === "skill" && file.toLowerCase() === "skill.md") return basename(dirname(path));
if ((kind === "extension" || kind === "theme" || kind === "prompt") && ext) {
if (kind === "extension" && /^index\.(ts|js)$/.test(file)) return basename(dirname(path));
return file.slice(0, -ext.length);
}
return file;
}
function getRelativePath(resource: ResolvedResource): string {
const baseDir = resource.metadata.baseDir;
if (!baseDir) return resource.path;
const rel = relative(baseDir, resource.path);
return rel && !rel.startsWith("..") ? rel : resource.path;
}
function getConfiguredVersion(source: string): string | undefined {
const npmSpec = source.startsWith("npm:") ? source.slice(4) : undefined;
if (npmSpec) {
const lastAt = npmSpec.lastIndexOf("@");
const packageNameEnd = npmSpec.startsWith("@") ? npmSpec.indexOf("/", 1) : 0;
if (lastAt > packageNameEnd) return npmSpec.slice(lastAt + 1) || undefined;
return undefined;
}
if (source.startsWith("git:") || /^[a-z]+:\/\//.test(source)) {
const lastAt = source.lastIndexOf("@");
const lastSlash = source.lastIndexOf("/");
const lastColon = source.lastIndexOf(":");
if (lastAt > Math.max(lastSlash, lastColon)) return source.slice(lastAt + 1) || undefined;
}
return undefined;
}
function readPackageMetadata(installedPath?: string): { packageName?: string; version?: string } {
if (!installedPath) return {};
try {
const stats = statSync(installedPath);
const packageJsonPath = stats.isDirectory()
? join(installedPath, "package.json")
: join(dirname(installedPath), "package.json");
if (!existsSync(packageJsonPath)) return {};
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
name?: unknown;
version?: unknown;
};
return {
packageName: typeof parsed.name === "string" ? parsed.name : undefined,
version: typeof parsed.version === "string" ? parsed.version : undefined,
};
} catch {
return {};
}
}
function collectResource(
resource: ResolvedResource,
kind: keyof PluginResourceCounts,
countsByPackage: Map<string, PluginResourceCounts>,
resourcesByPackage: Map<string, PluginResourceInfo[]>,
totals: PluginResourceCounts,
): void {
if (!isManagedPath(resource.path)) return;
if (!resource.enabled || resource.metadata.origin !== "package") return;
const source = resource.metadata.source;
const scope = toPluginScope(resource.metadata.scope);
const key = keyFor(source, scope);
const counts = countsByPackage.get(key) ?? emptyCounts();
addCount(counts, kind);
addCount(totals, kind);
countsByPackage.set(key, counts);
const resources = resourcesByPackage.get(key) ?? [];
const resourceKind = kind === "extensions"
? "extension"
: kind === "skills"
? "skill"
: kind === "prompts"
? "prompt"
: "theme";
resources.push({
kind: resourceKind,
name: getResourceName(resource.path, resourceKind),
path: resource.path,
relativePath: getRelativePath(resource),
});
resourcesByPackage.set(key, resources);
}
function collectResources(paths: ResolvedPaths): {
countsByPackage: Map<string, PluginResourceCounts>;
resourcesByPackage: Map<string, PluginResourceInfo[]>;
totals: PluginResourceCounts;
} {
const countsByPackage = new Map<string, PluginResourceCounts>();
const resourcesByPackage = new Map<string, PluginResourceInfo[]>();
const totals = emptyCounts();
for (const resource of paths.extensions) collectResource(resource, "extensions", countsByPackage, resourcesByPackage, totals);
for (const resource of paths.skills) collectResource(resource, "skills", countsByPackage, resourcesByPackage, totals);
for (const resource of paths.prompts) collectResource(resource, "prompts", countsByPackage, resourcesByPackage, totals);
for (const resource of paths.themes) collectResource(resource, "themes", countsByPackage, resourcesByPackage, totals);
return { countsByPackage, resourcesByPackage, totals };
}
async function readPlugins(cwd: string): Promise<PluginsResponse> {
const agentDir = getAgentDir();
const projectTrust = getProjectTrustStatus(cwd, agentDir);
const settingsManager = createAppSettingsManager(cwd, agentDir, projectTrust.trusted);
const packageManager = new DefaultPackageManager({
cwd,
agentDir,
settingsManager,
});
const diagnostics: PluginDiagnostic[] = [];
let countsByPackage = new Map<string, PluginResourceCounts>();
let resourcesByPackage = new Map<string, PluginResourceInfo[]>();
let totals = emptyCounts();
const disabledByPackage = getDisabledPackages(settingsManager);
try {
const resolved = await packageManager.resolve(async (source) => {
diagnostics.push({
type: "warning",
source,
message: "Package is configured but not installed yet.",
});
return "skip";
});
({ countsByPackage, resourcesByPackage, totals } = collectResources(resolved));
} catch (error) {
diagnostics.push({
type: "error",
message: error instanceof Error ? error.message : String(error),
});
}
const packages = packageManager.listConfiguredPackages().map((pkg) => {
const scope = toPluginScope(pkg.scope);
const key = keyFor(pkg.source, scope);
const disabled = disabledByPackage.get(key) ?? false;
const counts = countsByPackage.get(key) ?? emptyCounts();
const resources = resourcesByPackage.get(key) ?? [];
const resourceCount = counts.extensions + counts.skills + counts.prompts + counts.themes;
const packageMetadata = readPackageMetadata(pkg.installedPath);
if (!pkg.installedPath) {
diagnostics.push({
type: "warning",
source: pkg.source,
message: "Configured package path was not found.",
});
}
return {
source: pkg.source,
scope,
filtered: pkg.filtered,
disabled,
installedPath: pkg.installedPath,
packageName: packageMetadata.packageName,
version: packageMetadata.version,
configuredVersion: getConfiguredVersion(pkg.source),
counts,
resources,
status: disabled ? "disabled" : resourceCount > 0 ? "loaded" : pkg.installedPath ? "installed" : "missing",
} satisfies PluginPackageInfo;
});
return {
packages,
totals,
diagnostics,
projectResourcesLoaded: projectTrust.trusted,
};
}
function readScope(scope: unknown): PluginScope {
if (isManagedRuntime()) return "global";
return scope === "project" ? "project" : "global";
}
function isRemotePackageSource(source: string): boolean {
return source.startsWith("npm:") || source.startsWith("git:") || /^[a-z]+:\/\//i.test(source);
}
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const cwd = searchParams.get("cwd");
if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
try {
const allowedRoots = await getAllowedFileRoots();
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
return NextResponse.json(await readPlugins(cwd));
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// POST /api/plugins body: { action, source?, scope?, cwd }
export async function POST(req: Request) {
if (!isApiRequestAllowed(req)) {
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
}
if (!hasJsonContentType(req)) {
return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 });
}
try {
const body = await req.json() as {
action?: PluginAction;
source?: string;
scope?: PluginScope;
cwd?: string;
};
if (!body.cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
if (!body.action) return NextResponse.json({ error: "action required" }, { status: 400 });
const allowedRoots = await getAllowedFileRoots();
if (!isExistingFilePathAllowed(body.cwd, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const agentDir = getAgentDir();
const projectTrust = getProjectTrustStatus(body.cwd, agentDir);
const settingsManager = createAppSettingsManager(body.cwd, agentDir, projectTrust.trusted);
const scope = readScope(body.scope);
if (scope === "project" && !projectTrust.trusted) {
return NextResponse.json(
{ error: "Project resources must be trusted before modifying project plugins" },
{ status: 403 },
);
}
const packageManager = new DefaultPackageManager({
cwd: body.cwd,
agentDir,
settingsManager,
});
const source = body.source?.trim();
const local = scope === "project";
if (
source &&
isManagedRuntime() &&
!isRemotePackageSource(source) &&
!isManagedPath(resolve(body.cwd, source))
) {
return NextResponse.json(
{ error: "Local plugins must be stored inside the integrated application directory" },
{ status: 400 },
);
}
if (body.action === "install") {
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
await packageManager.installAndPersist(source, { local });
} else if (body.action === "remove") {
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
await packageManager.removeAndPersist(source, { local });
} else if (body.action === "update") {
await packageManager.update(source);
} else if (body.action === "disable") {
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
setPackageDisabled(settingsManager, source, scope, true);
await settingsManager.flush();
} else if (body.action === "enable") {
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
setPackageDisabled(settingsManager, source, scope, false);
await settingsManager.flush();
} else {
return NextResponse.json({ error: `Unsupported action: ${body.action}` }, { status: 400 });
}
return NextResponse.json(await readPlugins(body.cwd));
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
}
}
+66
View File
@@ -0,0 +1,66 @@
import { stat } from "fs/promises";
import { resolve } from "path";
import { NextResponse } from "next/server";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { invalidateModelsCache } from "@/lib/models-cache";
import { getProjectTrustStatus, trustProject } from "@/lib/project-trust";
import { destroyRpcSessionsForCwd, hasBusyRpcSessionForCwd } from "@/lib/rpc-manager";
export const dynamic = "force-dynamic";
async function validateCwd(value: unknown): Promise<
{ cwd: string } | { response: NextResponse }
> {
if (typeof value !== "string" || !value.trim()) {
return { response: NextResponse.json({ error: "cwd required" }, { status: 400 }) };
}
const cwd = resolve(value);
try {
if (!(await stat(cwd)).isDirectory()) {
return { response: NextResponse.json({ error: "cwd must be a directory" }, { status: 400 }) };
}
} catch {
return { response: NextResponse.json({ error: "Directory does not exist" }, { status: 400 }) };
}
const allowedRoots = await getAllowedFileRoots();
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
}
return { cwd };
}
export async function GET(req: Request) {
const result = await validateCwd(new URL(req.url).searchParams.get("cwd"));
if ("response" in result) return result.response;
return NextResponse.json(getProjectTrustStatus(result.cwd, getAgentDir()));
}
export async function POST(req: Request) {
try {
const body = await req.json() as { cwd?: unknown };
const result = await validateCwd(body.cwd);
if ("response" in result) return result.response;
const agentDir = getAgentDir();
const current = getProjectTrustStatus(result.cwd, agentDir);
if (!current.requiresTrust) {
return NextResponse.json({ error: "This project has no resources that require trust" }, { status: 409 });
}
if (hasBusyRpcSessionForCwd(result.cwd)) {
return NextResponse.json({ error: "Wait for the active session to finish before trusting this project" }, { status: 409 });
}
const status = trustProject(result.cwd, agentDir);
invalidateModelsCache();
destroyRpcSessionsForCwd(result.cwd);
return NextResponse.json(status);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 500 },
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import {
getManagedRuntimePaths,
isManagedRuntime,
} from "@/lib/app-runtime";
export const dynamic = "force-dynamic";
export async function GET() {
if (!isManagedRuntime()) {
return Response.json({ managed: false, agentDir: getAgentDir() });
}
const paths = getManagedRuntimePaths();
return Response.json({
managed: true,
appRoot: paths.appRoot,
dataDir: paths.dataDir,
agentDir: paths.agentDir,
resourcesDir: paths.resourcesDir,
skillRoots: paths.managedSkillRoots,
});
}
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent";
import { generateSessionTitle } from "@/lib/session-title";
import { getRpcSession, startRpcSession } from "@/lib/rpc-manager";
import { invalidateSessionListCache, resolveSessionPath } from "@/lib/session-reader";
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const filePath = await resolveSessionPath(id);
if (!filePath) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
const existing = getRpcSession(id);
const { session } = existing?.isAlive()
? { session: existing }
: await startRpcSession(id, filePath, cwd);
// globalThis keeps wrappers alive across dev hot reloads; older instances
// may predate waitUntilReady(), but those have already completed startup.
await session.waitUntilReady?.();
const result = await generateSessionTitle(session.inner as unknown as AgentSession);
if (!session.isAlive()) {
return NextResponse.json(
{ error: "The session was closed while its title was being generated. Please try again." },
{ status: 409 },
);
}
session.inner.setSessionName(result.title);
invalidateSessionListCache();
return NextResponse.json({ title: result.title, usage: result.usage ?? null });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import { resolveSessionPath, buildSessionContext } from "@/lib/session-reader";
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const url = new URL(req.url);
const leafId = url.searchParams.get("leafId") ?? undefined;
const deferThinking = url.searchParams.has("deferThinking");
const deferToolResultImages = url.searchParams.has("deferMedia");
try {
const filePath = await resolveSessionPath(id);
if (!filePath) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
const sm = SessionManager.open(filePath);
const context = buildSessionContext(sm.getEntries() as never, leafId, {
deferThinking,
deferToolResultImages,
});
return NextResponse.json({ context });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getSessionEntries, resolveSessionPath } from "@/lib/session-reader";
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string; entryId: string }> },
) {
const { id, entryId } = await params;
const blockIndexParam = new URL(req.url).searchParams.get("blockIndex");
const blockIndex = blockIndexParam === null ? Number.NaN : Number(blockIndexParam);
if (!Number.isSafeInteger(blockIndex) || blockIndex < 0) {
return NextResponse.json({ error: "Valid blockIndex is required" }, { status: 400 });
}
try {
const filePath = await resolveSessionPath(id);
if (!filePath) return NextResponse.json({ error: "Session not found" }, { status: 404 });
// SessionManager-backed parsing preserves the SDK's malformed-line tolerance.
const entry = getSessionEntries(filePath).find((candidate) => candidate.id === entryId);
if (!entry || entry.type !== "message" || entry.message.role !== "assistant") {
return NextResponse.json({ error: "Assistant message not found" }, { status: 404 });
}
const block = entry.message.content[blockIndex];
if (!block || block.type !== "thinking") {
return NextResponse.json({ error: "Thinking block not found" }, { status: 404 });
}
return NextResponse.json({ thinking: block.thinking });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
@@ -0,0 +1,282 @@
import { randomUUID } from "crypto";
import { execFile } from "child_process";
import { existsSync, mkdirSync, readFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import { basename, dirname, join } from "path";
import { promisify } from "util";
import { fileURLToPath, pathToFileURL } from "url";
import { NextResponse } from "next/server";
import { resolveSessionPath } from "@/lib/session-reader";
const execFileAsync = promisify(execFile);
export const runtime = "nodejs";
type PiCodingAgentModule = {
getPackageDir: () => string;
};
type ExportHtmlModule = {
exportFromFile: (inputPath: string, outputPath: string) => Promise<string>;
};
async function getPiPackageDir(): Promise<string | null> {
try {
const { getPackageDir } = (await import("@earendil-works/pi-coding-agent")) as PiCodingAgentModule;
return getPackageDir();
} catch {
return null;
}
}
function encodeHeaderValue(value: string): string {
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) =>
`%${ch.charCodeAt(0).toString(16).toUpperCase()}`
);
}
function getContentDisposition(fileName: string, inline: boolean): string {
const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "session.html";
const disposition = inline ? "inline" : "attachment";
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`;
}
async function getPiCliPath(): Promise<string | null> {
const candidates = new Set<string>();
const packageDir = await getPiPackageDir();
if (packageDir) {
candidates.add(join(packageDir, "dist", "cli.js"));
}
try {
const resolver = (import.meta as ImportMeta & {
resolve?: (specifier: string) => string | Promise<string>;
}).resolve;
if (typeof resolver === "function") {
const indexUrl = await resolver("@earendil-works/pi-coding-agent");
candidates.add(join(dirname(fileURLToPath(indexUrl)), "cli.js"));
}
} catch {
// Next.js production bundles can strip import.meta.resolve.
}
candidates.add(
join(
process.cwd(),
"node_modules",
"@earendil-works",
"pi-coding-agent",
"dist",
"cli.js"
)
);
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
return null;
}
/**
* Patch the exported HTML to fix recursive functions that overflow
* the call stack on deep linear session trees (e.g., 5000+ entries).
*
* ## Root Cause
* pi-coding-agent's template.js uses recursive helpers to render and
* navigate the session tree in the exported HTML:
*
* 1. sortChildren(node) — recursively sorts children of every node.
* Calls itself via node.children.forEach(sortChildren).
* On a 5527-entry linear chain (no branches), this recurses 5527
* levels deep → stack overflow.
*
* 2. mapNodes(node) — recursively indexes tree nodes the first time
* a tree item is clicked. Same depth -> same overflow.
*
* 3. markActive(node) — recursively marks nodes on the active path.
* Calls itself via markActive(child) for each child.
* Same depth → same overflow.
*
* Both functions are inlined in the HTML by pi-coding-agent at export
* time. We cannot modify template.js directly (it's in node_modules
* and would be overwritten on npm install). Instead, we patch the
* generated HTML string before returning it to the client.
*
* ## Fix
* Replace each recursive function with an iterative equivalent:
*
* sortChildren → explicit stack (DFS pre-order, push children in
* reverse to maintain order)
* mapNodes → explicit stack (DFS pre-order)
* markActive → two-stack post-order (stack1 for traversal,
* stack2 for processing children before parent)
*
* ## Line Ending Normalization
* This file (route.ts) uses CRLF (Windows), while template.js uses LF
* (Unix). The template strings in the backtick literals inherit the
* file's CRLF line endings. At runtime, readFileSync() also returns
* CRLF on Windows. We normalize everything to LF before matching.
*
* The helper `n(s)` strips \r\n → \n on both the HTML and the
* replacement strings, ensuring cross-platform matching.
*/
function patchExportHtml(html: string): string {
// Normalize line endings: route.ts is CRLF, template.js is LF.
// Without this, the replace() below would fail on Windows.
const n = (s: string) => s.replace(/\r\n/g, "\n");
html = n(html);
const replaceRequired = (source: string, name: string, search: string, replacement: string) => {
const normalizedSearch = n(search);
const normalizedReplacement = n(replacement);
const matches = source.split(normalizedSearch).length - 1;
if (matches !== 1) {
throw new Error(`Failed to patch exported HTML: ${name} expected 1 match, found ${matches}`);
}
return source.replace(normalizedSearch, normalizedReplacement);
};
html = replaceRequired(
html,
"sortChildren",
` function sortChildren(node) {
node.children.sort((a, b) =>
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
);
node.children.forEach(sortChildren);
}`,
` function sortChildren(root) {
const stack = [root];
while (stack.length) {
const node = stack.pop();
node.children.sort((a, b) =>
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
);
for (let i = node.children.length - 1; i >= 0; i--) {
stack.push(node.children[i]);
}
}
}`
);
html = replaceRequired(
html,
"mapNodes",
` function mapNodes(node) {
treeNodeMap.set(node.entry.id, node);
node.children.forEach(mapNodes);
}
tree.forEach(mapNodes);`,
` const stack = [...tree].reverse();
while (stack.length) {
const node = stack.pop();
treeNodeMap.set(node.entry.id, node);
for (let i = node.children.length - 1; i >= 0; i--) {
stack.push(node.children[i]);
}
}`
);
html = replaceRequired(
html,
"markActive",
` function markActive(node) {
let has = activePathIds.has(node.entry.id);
for (const child of node.children) {
if (markActive(child)) has = true;
}
containsActive.set(node, has);
return has;
}`,
` function markActive(root) {
// Post-order traversal using two stacks
const stack1 = [root];
const stack2 = [];
while (stack1.length) {
const node = stack1.pop();
stack2.push(node);
for (const child of node.children) {
stack1.push(child);
}
}
while (stack2.length) {
const node = stack2.pop();
let has = activePathIds.has(node.entry.id);
for (const child of node.children) {
if (containsActive.get(child)) has = true;
}
containsActive.set(node, has);
}
}`
);
return html;
}
async function exportSession(filePath: string, outputPath: string): Promise<void> {
const cliPath = await getPiCliPath();
if (cliPath) {
await execFileAsync(process.execPath, [cliPath, "--export", filePath, outputPath], {
cwd: process.cwd(),
timeout: 30_000,
env: {
...process.env,
PI_OFFLINE: "1",
PI_SKIP_VERSION_CHECK: "1",
},
maxBuffer: 1024 * 1024,
});
return;
}
const packageDir = await getPiPackageDir();
if (!packageDir) throw new Error("pi CLI not found");
const exporterUrl = pathToFileURL(join(packageDir, "dist", "core", "export-html", "index.js")).href;
const { exportFromFile } = (await import(exporterUrl)) as ExportHtmlModule;
await exportFromFile(filePath, outputPath);
}
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const inline = new URL(req.url).searchParams.get("inline") === "1";
try {
const filePath = await resolveSessionPath(id);
if (!filePath) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
const tempDir = join(tmpdir(), "pi-web-export");
mkdirSync(tempDir, { recursive: true });
const sessionBase = basename(filePath, ".jsonl");
const fileName = `pi-session-${sessionBase}.html`;
const outputPath = join(tempDir, `${randomUUID()}.html`);
try {
await exportSession(filePath, outputPath);
const html = readFileSync(outputPath, "utf8");
const patchedHtml = patchExportHtml(html);
return new Response(patchedHtml, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Disposition": getContentDisposition(fileName, inline),
"Cache-Control": "no-cache",
"Content-Security-Policy": "frame-ancestors 'none'",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
},
});
} finally {
rmSync(outputPath, { force: true });
}
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
+248
View File
@@ -0,0 +1,248 @@
import { NextResponse } from "next/server";
import { readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import {
resolveSessionPath,
resolveSessionIdByPath,
invalidateSessionPathCache,
invalidateSessionListCache,
buildSessionContext,
readSessionHeader,
} from "@/lib/session-reader";
import { sessionPathKey } from "@/lib/session-path";
import { getRpcSession } from "@/lib/rpc-manager";
// BranchNavigator still traverses recursively, so keep the response tree shallow.
const MAX_PROJECTED_TREE_DEPTH = 200;
/**
* Project the session tree into the shallow navigation tree sent to the client.
* Keeps roots, branch points, and leaves while contracting single-child chains
* without recursive traversal. Contracted entry IDs are attached to the next
* visible node so the UI can still recognize an active leaf inside the chain.
*/
function projectTreeForResponse<T extends { entry: { id: string }; children: T[]; compressedEntryIds?: string[] }>(
nodes: T[]
): T[] {
const keep = new Set<T>();
const roots = new Set(nodes);
const seen = new Set<T>();
const stack = [...nodes];
while (stack.length > 0) {
const node = stack.pop()!;
if (seen.has(node)) continue;
seen.add(node);
if (
roots.has(node) ||
node.children.length !== 1
) {
keep.add(node);
}
for (const child of node.children) {
stack.push(child);
}
}
const cloneNode = (node: T, compressedEntryIds?: string[]): T => ({
...node,
children: [],
...(compressedEntryIds?.length ? { compressedEntryIds } : {}),
});
const projectedRoots = nodes.map((node) => cloneNode(node));
const tasks = nodes.map((source, index) => ({
source,
projected: projectedRoots[index],
depth: 1,
}));
const appendFlattenedKeptDescendants = (source: T, projectedParent: T) => {
const pending = [{ node: source, compressedEntryIds: [] as string[] }];
const flattenedSeen = new Set<T>();
while (pending.length > 0) {
const { node, compressedEntryIds } = pending.pop()!;
if (flattenedSeen.has(node)) continue;
flattenedSeen.add(node);
if (keep.has(node)) {
projectedParent.children.push(cloneNode(node, compressedEntryIds));
}
for (let i = node.children.length - 1; i >= 0; i--) {
pending.push({
node: node.children[i],
compressedEntryIds: keep.has(node)
? []
: [...compressedEntryIds, node.entry.id],
});
}
}
};
while (tasks.length > 0) {
const { source, projected, depth } = tasks.pop()!;
for (const sourceChild of source.children) {
let child = sourceChild;
if (depth >= MAX_PROJECTED_TREE_DEPTH) {
appendFlattenedKeptDescendants(child, projected);
continue;
}
const compressedEntryIds: string[] = [];
while (!keep.has(child) && child.children.length === 1) {
compressedEntryIds.push(child.entry.id);
child = child.children[0];
}
if (!keep.has(child)) {
continue;
}
const projectedChild = cloneNode(child, compressedEntryIds);
projected.children.push(projectedChild);
tasks.push({ source: child, projected: projectedChild, depth: depth + 1 });
}
}
return projectedRoots;
}
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const filePath = await resolveSessionPath(id);
if (!filePath) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
const sm = SessionManager.open(filePath);
const entries = sm.getEntries() as never;
const leafId = sm.getLeafId();
const tree = projectTreeForResponse(sm.getTree());
const searchParams = new URL(req.url).searchParams;
const deferThinking = searchParams.has("deferThinking");
const deferToolResultImages = searchParams.has("deferMedia");
const context = buildSessionContext(entries, leafId, { deferThinking, deferToolResultImages });
const header = sm.getHeader();
let modified = header?.timestamp ?? new Date().toISOString();
try { modified = statSync(filePath).mtime.toISOString(); } catch { /* use header timestamp */ }
const parentSessionId = header?.parentSession
? await resolveSessionIdByPath(header.parentSession)
: undefined;
const info = header ? {
path: filePath,
id: header.id,
cwd: header.cwd ?? "",
name: sm.getSessionName(),
created: header.timestamp,
modified,
messageCount: context.messages.length,
firstMessage: context.messages.find((m) => m.role === "user")
? (() => {
const msg = context.messages.find((m) => m.role === "user")!;
const c = (msg as { content: unknown }).content;
return typeof c === "string" ? c : (Array.isArray(c) ? (c.find((b: { type: string }) => b.type === "text") as { text: string } | undefined)?.text ?? "" : "") || "(no messages)";
})()
: "(no messages)",
parentSessionId,
} : null;
return NextResponse.json({
sessionId: id,
filePath,
info,
leafId,
tree,
context,
});
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// PATCH /api/sessions/[id] body: { name: string }
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const { name } = await req.json() as { name?: string };
if (typeof name !== "string") {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
const filePath = await resolveSessionPath(id);
if (!filePath) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
const sm = SessionManager.open(filePath);
sm.appendSessionInfo(name.trim());
invalidateSessionListCache();
return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// DELETE /api/sessions/[id]
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const filePath = await resolveSessionPath(id);
if (!filePath) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
// Read only the bounded header before deleting.
const parentSessionPath = readSessionHeader(filePath)?.parentSession;
// Re-attach all direct children to this session's parent (cascade re-parent)
// Scan sibling files in the same directory
const targetPathKey = sessionPathKey(filePath);
const dir = dirname(filePath);
try {
const files = readdirSync(dir).filter(
(file) => file.endsWith(".jsonl") && sessionPathKey(join(dir, file)) !== targetPathKey,
);
for (const file of files) {
const childPath = join(dir, file);
try {
const content = readFileSync(childPath, "utf8");
const lines = content.split("\n");
const header = JSON.parse(lines[0]) as { type?: string; parentSession?: string };
if (
header.type === "session" &&
header.parentSession &&
sessionPathKey(header.parentSession) === targetPathKey
) {
// Rewrite header with new parentSession
header.parentSession = parentSessionPath;
lines[0] = JSON.stringify(header);
writeFileSync(childPath, lines.join("\n"));
}
} catch { /* skip malformed */ }
}
} catch { /* skip if dir unreadable */ }
getRpcSession(id)?.destroy();
unlinkSync(filePath);
invalidateSessionPathCache(id);
invalidateSessionListCache();
return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { getRpcSession } from "@/lib/rpc-manager";
import { resolveSessionPath } from "@/lib/session-reader";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
if (!await resolveSessionPath(id)) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
const rpc = getRpcSession(id);
if (!rpc?.isAlive()) return NextResponse.json({ running: false });
const state = await rpc.send({ type: "get_state" });
return NextResponse.json({ running: true, state });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { listAllSessions } from "@/lib/session-reader";
import { getRunningRpcSessionIds } from "@/lib/rpc-manager";
export async function GET() {
try {
const sessions = await listAllSessions();
return NextResponse.json({ sessions, runningSessionIds: getRunningRpcSessionIds() });
} catch (error) {
return NextResponse.json(
{ error: String(error) },
{ status: 500 }
);
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import type { SkillInstallScope } from "@/lib/api-types";
import { checkSkillUpdates } from "@/lib/skill-updates";
import { loadSkillsWithInstallInfo } from "@/lib/skills-service";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
try {
const body = await req.json() as {
cwd?: unknown;
package?: unknown;
scope?: unknown;
};
const cwd = typeof body.cwd === "string" ? body.cwd : "";
if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
const allowedRoots = await getAllowedFileRoots();
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const pkg = typeof body.package === "string" ? body.package : undefined;
const scope = body.scope === "global" || body.scope === "project"
? body.scope as SkillInstallScope
: undefined;
if ((pkg && !scope) || (!pkg && scope)) {
return NextResponse.json({ error: "package and scope must be provided together" }, { status: 400 });
}
const { skills } = await loadSkillsWithInstallInfo(cwd);
const installs = skills
.map((skill) => skill.install)
.filter((install): install is NonNullable<typeof install> => Boolean(install))
.filter((install) => !pkg || (install.package === pkg && install.scope === scope));
if (pkg && installs.length === 0) {
return NextResponse.json({ error: "Installed skill not found" }, { status: 404 });
}
const updates = await checkSkillUpdates(installs, {
githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
});
return NextResponse.json({ updates });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 500 },
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { runNpx } from "@/lib/npx";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
import { getProjectTrustStatus } from "@/lib/project-trust";
import { getSkillsCliEnvironment, isManagedRuntime } from "@/lib/app-runtime";
export const dynamic = "force-dynamic";
const ANSI_RE = /\x1B\[[0-9;]*m/g;
// POST /api/skills/install body: { package: string; scope: "global" | "project"; cwd?: string }
export async function POST(req: Request) {
if (!isApiRequestAllowed(req)) {
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
}
if (!hasJsonContentType(req)) {
return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 });
}
try {
const { package: pkg, scope, cwd } = await req.json() as { package?: string; scope?: string; cwd?: string };
if (!pkg?.trim()) return NextResponse.json({ error: "package required" }, { status: 400 });
const isGlobal = isManagedRuntime() || scope !== "project";
if (!isGlobal) {
if (!cwd) return NextResponse.json({ error: "cwd required for project install" }, { status: 400 });
const allowedRoots = await getAllowedFileRoots();
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
if (!getProjectTrustStatus(cwd, getAgentDir()).trusted) {
return NextResponse.json(
{ error: "Project resources must be trusted before installing project skills" },
{ status: 403 },
);
}
}
const args = ["skills", "add", pkg.trim(), "-y", "--agent", "pi"];
if (isGlobal) args.push("-g");
console.log(`[skills/install] running: npx ${args.join(" ")}`);
const { stdout, stderr } = await runNpx(args, {
timeout: 60000,
cwd: !isGlobal && cwd ? cwd : undefined,
env: getSkillsCliEnvironment(),
});
const output = (stdout + stderr).replace(ANSI_RE, "");
const success = /Installation complete|Installed \d+ skill/.test(output);
if (!success) {
return NextResponse.json({ error: output.slice(-300) || "Install failed" }, { status: 500 });
}
return NextResponse.json({ success: true, output });
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; message?: string };
const output = ((err.stdout ?? "") + (err.stderr ?? "")).replace(ANSI_RE, "");
return NextResponse.json({ error: output || (err.message ?? String(e)) }, { status: 500 });
}
}
+77
View File
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import path from "path";
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
import { loadSkillsWithInstallInfo } from "@/lib/skills-service";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { getManagedRuntimePaths, isManagedRuntime } from "@/lib/app-runtime";
export const dynamic = "force-dynamic";
// GET /api/skills?cwd=<path>
// Uses DefaultResourceLoader (same logic as AgentSession startup) so settings.json
// skill paths, package skills, and .agents/skills directories are all included.
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const cwd = searchParams.get("cwd");
if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
try {
const allowedRoots = await getAllowedFileRoots();
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
return NextResponse.json(await loadSkillsWithInstallInfo(cwd));
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
}
// PATCH /api/skills — toggle disable-model-invocation on a SKILL.md file
export async function PATCH(req: Request) {
try {
const body = await req.json() as { filePath: string; disableModelInvocation: boolean };
const { filePath, disableModelInvocation } = body;
if (!filePath) return NextResponse.json({ error: "filePath required" }, { status: 400 });
if (!existsSync(filePath)) return NextResponse.json({ error: "file not found" }, { status: 404 });
const allowedRoots = new Set(await getAllowedFileRoots());
allowedRoots.add(getAgentDir());
if (isManagedRuntime()) {
for (const root of getManagedRuntimePaths().managedSkillRoots) {
if (existsSync(root)) allowedRoots.add(root);
}
} else {
// Upstream-compatible mode keeps the CLI's user-wide skill root.
const globalSkillsDir = path.join(homedir(), ".agents", "skills");
if (existsSync(globalSkillsDir)) allowedRoots.add(globalSkillsDir);
}
if (!isExistingFilePathAllowed(filePath, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const content = readFileSync(filePath, "utf8");
const key = "disable-model-invocation";
// Use parseFrontmatter to check current value, then do a surgical line edit
// to preserve the original YAML formatting of all other fields.
const { frontmatter } = parseFrontmatter<Record<string, unknown>>(content);
const alreadySet = Boolean(frontmatter[key]);
let updated = content;
if (disableModelInvocation && !alreadySet) {
// Add key after the opening --- line
updated = content.replace(/^---\r?\n/, `---\n${key}: true\n`);
// If no frontmatter exists, create one
if (updated === content) updated = `---\n${key}: true\n---\n${content}`;
} else if (!disableModelInvocation && alreadySet) {
// Remove the key line entirely
updated = content.replace(new RegExp(`^${key}\\s*:.*\\r?\\n`, "m"), "");
}
writeFileSync(filePath, updated, "utf8");
return NextResponse.json({ success: true });
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
}
+116
View File
@@ -0,0 +1,116 @@
import { NextResponse } from "next/server";
import { runNpx } from "@/lib/npx";
import type { SkillSearchResult } from "@/lib/api-types";
export const dynamic = "force-dynamic";
const ANSI_RE = /\x1B\[[0-9;]*m/g;
const DEFAULT_LIMIT = 50;
const MIN_LIMIT = 1;
const MAX_LIMIT = 50;
const SEARCH_API_BASE = process.env.SKILLS_API_URL || "https://skills.sh";
interface SkillsApiSkill {
id?: string;
name?: string;
source?: string;
installs?: number;
}
interface SkillsApiResponse {
skills?: SkillsApiSkill[];
}
function parseLimit(value: unknown): number {
const num = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(num)) return DEFAULT_LIMIT;
return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, Math.floor(num)));
}
function formatInstalls(count?: number): string {
if (!count || count <= 0) return "";
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`;
return `${count} install${count === 1 ? "" : "s"}`;
}
function parseSearchOutput(raw: string): SkillSearchResult[] {
const clean = raw.replace(ANSI_RE, "");
const results: SkillSearchResult[] = [];
const lines = clean.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// package line: "owner/repo@skill NNK installs"
const pkgMatch = line.match(/^([\w.\-]+\/[\w.\-@:]+)\s+([\d.,]+[KMB]?\s+installs)$/);
if (pkgMatch) {
const urlLine = lines[i + 1]?.trim().replace(/^└\s*/, "");
results.push({
package: pkgMatch[1],
installs: pkgMatch[2],
url: urlLine?.startsWith("https://") ? urlLine : "",
});
}
}
return results;
}
async function searchSkillsApi(query: string, limit: number): Promise<SkillSearchResult[]> {
const url = `${SEARCH_API_BASE}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`skills.sh search failed: HTTP ${res.status}`);
const data = (await res.json()) as SkillsApiResponse;
return (data.skills ?? [])
.map((skill) => {
const name = skill.name?.trim();
const source = skill.source?.trim();
const slug = skill.id?.trim();
if (!name || (!source && !slug)) return null;
const pkg = `${source || slug}@${name}`;
return {
package: pkg,
installs: formatInstalls(skill.installs),
url: slug ? `${SEARCH_API_BASE}/${slug}` : "",
};
})
.filter((skill): skill is SkillSearchResult => skill !== null)
.sort((a, b) => parseInstallCount(b.installs) - parseInstallCount(a.installs));
}
function parseInstallCount(installs: string): number {
const match = installs.match(/^([\d.]+)([KMB])?\s+installs?$/);
if (!match) return 0;
const value = Number(match[1]);
if (!Number.isFinite(value)) return 0;
const multiplier = match[2] === "B" ? 1_000_000_000 : match[2] === "M" ? 1_000_000 : match[2] === "K" ? 1_000 : 1;
return value * multiplier;
}
// POST /api/skills/search body: { query: string, limit?: number }
export async function POST(req: Request) {
try {
const { query, limit: rawLimit } = await req.json() as { query?: string; limit?: unknown };
if (!query?.trim()) return NextResponse.json({ error: "query required" }, { status: 400 });
const limit = parseLimit(rawLimit);
try {
const results = await searchSkillsApi(query.trim(), limit);
return NextResponse.json({ results });
} catch {
const { stdout, stderr } = await runNpx(["skills", "find", query.trim()], {
timeout: 20000,
env: { ...process.env, FORCE_COLOR: "0" },
});
const results = parseSearchOutput(stdout + stderr).slice(0, limit);
return NextResponse.json({ results });
}
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; message?: string };
const raw = (err.stdout ?? "") + (err.stderr ?? "");
const results = raw ? parseSearchOutput(raw) : [];
if (results.length > 0) return NextResponse.json({ results });
return NextResponse.json({ error: err.message ?? String(e) }, { status: 500 });
}
}
+65
View File
@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { runNpx } from "@/lib/npx";
import type { SkillInstallScope } from "@/lib/api-types";
import { buildSkillUpdateArgs } from "@/lib/skill-updates";
import { loadSkillsWithInstallInfo } from "@/lib/skills-service";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { getSkillsCliEnvironment, isManagedRuntime } from "@/lib/app-runtime";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
try {
const body = await req.json() as {
cwd?: unknown;
package?: unknown;
scope?: unknown;
};
const cwd = typeof body.cwd === "string" ? body.cwd : "";
const pkg = typeof body.package === "string" ? body.package : "";
const scope = body.scope === "global" || body.scope === "project"
? body.scope as SkillInstallScope
: undefined;
if (!cwd || !pkg || !scope) {
return NextResponse.json({ error: "cwd, package, and scope are required" }, { status: 400 });
}
const allowedRoots = await getAllowedFileRoots();
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const { skills } = await loadSkillsWithInstallInfo(cwd);
const skill = skills.find(
(item) => item.install?.package === pkg && item.install.scope === scope,
);
if (!skill?.install) {
return NextResponse.json({ error: "Installed skill not found" }, { status: 404 });
}
if (!skill.install.canCheckForUpdates) {
return NextResponse.json({ error: "This skill cannot be updated automatically" }, { status: 400 });
}
const { stdout, stderr } = await runNpx(buildSkillUpdateArgs(skill.install), {
timeout: 60_000,
cwd: !isManagedRuntime() && scope === "project" ? cwd : undefined,
env: getSkillsCliEnvironment(),
});
const refreshed = await loadSkillsWithInstallInfo(cwd);
const updatedSkill = refreshed.skills.find(
(item) => item.install?.package === pkg && item.install.scope === scope,
);
return NextResponse.json({
success: true,
skill: updatedSkill,
output: `${stdout}${stderr}`.slice(-500),
});
} catch (error: unknown) {
const detail = error as { stdout?: string; stderr?: string; message?: string };
const output = `${detail.stdout ?? ""}${detail.stderr ?? ""}`;
return NextResponse.json(
{ error: output || detail.message || String(error) },
{ status: 500 },
);
}
}
+97
View File
@@ -0,0 +1,97 @@
import { NextResponse } from "next/server";
import { existsSync } from "fs";
import { addWorktree, listWorktrees, removeWorktree, resolveProject } from "@/lib/worktree";
import { allowFileRoot, getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed } from "@/lib/file-access";
/** Same gate as /api/files: only session cwds / project roots / explicitly
* allowed dirs may be inspected or mutated through this endpoint. */
async function checkCwdAllowed(cwd: string): Promise<NextResponse | null> {
const allowedRoots = await getAllowedFileRoots();
if (!isFilePathAllowed(cwd, allowedRoots) || !isExistingFilePathAllowed(cwd, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
return null;
}
// GET /api/worktrees?cwd= → { projectRoot, isGit, isTopLevel, worktrees }
export async function GET(req: Request) {
try {
const cwd = new URL(req.url).searchParams.get("cwd");
if (!cwd) {
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
}
const denied = await checkCwdAllowed(cwd);
if (denied) return denied;
const project = await resolveProject(cwd);
let worktrees: Awaited<ReturnType<typeof listWorktrees>> = [];
let isGit = true;
try {
// For a removed-worktree cwd (session of a deleted worktree), fall back
// to the inferred project root so the switcher still shows the project.
worktrees = await listWorktrees(existsSync(cwd) ? cwd : project.projectRoot);
} catch {
isGit = false;
}
// Every listed path is a git-verified worktree of this project; allow the
// file explorer to browse them even before they have any session (the
// in-memory allowlist from addWorktree does not survive server restarts).
for (const w of worktrees) allowFileRoot(w.path);
return NextResponse.json({
projectRoot: project.projectRoot,
isGit,
isTopLevel: project.isTopLevel,
worktrees,
});
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// POST /api/worktrees body: { cwd, branch } → { path, branch }
export async function POST(req: Request) {
try {
const body = await req.json() as { cwd?: string; branch?: string };
if (!body.cwd || typeof body.cwd !== "string") {
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
}
if (!body.branch || typeof body.branch !== "string") {
return NextResponse.json({ error: "branch is required" }, { status: 400 });
}
const denied = await checkCwdAllowed(body.cwd);
if (denied) return denied;
if (!existsSync(body.cwd)) {
return NextResponse.json({ error: `Directory does not exist: ${body.cwd}` }, { status: 400 });
}
const result = await addWorktree(body.cwd, body.branch);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: message }, { status: 400 });
}
}
// DELETE /api/worktrees body: { cwd, path, force? }
export async function DELETE(req: Request) {
try {
const body = await req.json() as { cwd?: string; path?: string; force?: boolean };
if (!body.cwd || typeof body.cwd !== "string") {
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
}
if (!body.path || typeof body.path !== "string") {
return NextResponse.json({ error: "path is required" }, { status: 400 });
}
const denied = await checkCwdAllowed(body.cwd);
if (denied) return denied;
await removeWorktree(body.cwd, body.path, body.force === true);
return NextResponse.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// git refuses to remove dirty worktrees without --force; surface that so
// the UI can offer a force-remove confirmation.
const dirty = /contains modified or untracked files|is dirty/i.test(message);
return NextResponse.json({ error: message, dirty }, { status: dirty ? 409 : 400 });
}
}