mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
14e9aea0ec
* Refactor: shared server package with PLANNOTATOR_REMOTE env var - Create packages/server/ with shared server implementation - Add PLANNOTATOR_REMOTE=1 env var for devcontainer/SSH mode - Deprecate SSH_CONNECTION detection (still works with warning) - Both Claude Code and OpenCode now use identical server logic - OpenCode gains Obsidian/Bear integrations and remote detection - Update documentation with environment variables section Fixes #27 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Remove PLANNOTATOR_ORIGIN env var, fix onReady callback timing - Remove PLANNOTATOR_ORIGIN hack from Claude Code hook (hardcode "claude-code") - Fix onReady callback to pass port directly (was referencing undefined server) - Create tests/manual/test-server.ts for testing either origin - Update test-hook-2.sh to use new test server with opencode origin 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add devcontainer test setup, reduce logging verbosity - Add tests/devcontainer/ with devcontainer.json for testing remote mode - Add tests/opencode-local/ for local OpenCode testing - Update package.json build script for proper bundling - Remove verbose multi-line error messages, keep useful errors - Clean up unused deprecationWarned variable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add devcontainer support and documentation - Add PLANNOTATOR_REMOTE env var for container/remote detection - Remove console logging (silent operation) - Add devcontainer.md with full setup instructions - Add devcontainer section to OpenCode plugin README - Add port-only test setup to reproduce common misconfiguration - Update test devcontainers to forward port 4096 for opencode web - Bump version to 0.4.1 Fixes #27 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Update CLAUDE.md: legacy SSH detection, not deprecated Removed incorrect mention of deprecation warning - SSH_TTY/SSH_CONNECTION detection is silent by design. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix OpenCode agent switching after plan approval After approving a plan, the conversation context stayed in "plan" mode even though the TUI showed "build". This caused edits to fail because the plan agent has edit permissions denied. The fix uses session.prompt() to inject a message with agent: "build", which triggers a new agentic loop with proper build agent permissions. Fixes #29 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
258 lines
7.8 KiB
TypeScript
258 lines
7.8 KiB
TypeScript
/**
|
|
* Plannotator Shared Server
|
|
*
|
|
* Provides a consistent server implementation for both Claude Code and OpenCode plugins.
|
|
*
|
|
* Environment variables:
|
|
* PLANNOTATOR_REMOTE - Set to "1" or "true" for remote/devcontainer mode
|
|
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
|
|
* PLANNOTATOR_ORIGIN - Origin identifier ("claude-code" or "opencode")
|
|
*/
|
|
|
|
import { mkdirSync } from "fs";
|
|
import { isRemoteSession, getServerPort } from "./remote";
|
|
import { openBrowser } from "./browser";
|
|
import {
|
|
detectObsidianVaults,
|
|
saveToObsidian,
|
|
saveToBear,
|
|
type ObsidianConfig,
|
|
type BearConfig,
|
|
} from "./integrations";
|
|
|
|
// Re-export utilities
|
|
export { isRemoteSession, getServerPort } from "./remote";
|
|
export { openBrowser } from "./browser";
|
|
export * from "./integrations";
|
|
|
|
// --- Types ---
|
|
|
|
export interface ServerOptions {
|
|
/** The plan markdown content */
|
|
plan: string;
|
|
/** Origin identifier (e.g., "claude-code", "opencode") */
|
|
origin: string;
|
|
/** HTML content to serve for the UI */
|
|
htmlContent: string;
|
|
/** Called when server starts with the URL, remote status, and port */
|
|
onReady?: (url: string, isRemote: boolean, port: number) => void;
|
|
}
|
|
|
|
export interface ServerResult {
|
|
/** The port the server is running on */
|
|
port: number;
|
|
/** The full URL to access the server */
|
|
url: string;
|
|
/** Whether running in remote mode */
|
|
isRemote: boolean;
|
|
/** Wait for user decision (approve/deny) */
|
|
waitForDecision: () => Promise<{ approved: boolean; feedback?: string }>;
|
|
/** Stop the server */
|
|
stop: () => void;
|
|
}
|
|
|
|
// --- Server Implementation ---
|
|
|
|
const MAX_RETRIES = 5;
|
|
const RETRY_DELAY_MS = 500;
|
|
|
|
/**
|
|
* Start the Plannotator server
|
|
*
|
|
* Handles:
|
|
* - Remote detection and port configuration
|
|
* - All API routes (/api/plan, /api/approve, /api/deny, etc.)
|
|
* - Obsidian/Bear integrations
|
|
* - Port conflict retries
|
|
*/
|
|
export async function startPlannotatorServer(
|
|
options: ServerOptions
|
|
): Promise<ServerResult> {
|
|
const { plan, origin, htmlContent, onReady } = options;
|
|
|
|
const isRemote = isRemoteSession();
|
|
const configuredPort = getServerPort();
|
|
|
|
// Decision promise
|
|
let resolveDecision: (result: { approved: boolean; feedback?: string }) => void;
|
|
const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>(
|
|
(resolve) => {
|
|
resolveDecision = resolve;
|
|
}
|
|
);
|
|
|
|
// Start server with retry logic
|
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
try {
|
|
server = Bun.serve({
|
|
port: configuredPort,
|
|
|
|
async fetch(req) {
|
|
const url = new URL(req.url);
|
|
|
|
// API: Get plan content
|
|
if (url.pathname === "/api/plan") {
|
|
return Response.json({ plan, origin });
|
|
}
|
|
|
|
// API: Serve images (local paths or temp uploads)
|
|
if (url.pathname === "/api/image") {
|
|
const imagePath = url.searchParams.get("path");
|
|
if (!imagePath) {
|
|
return new Response("Missing path parameter", { status: 400 });
|
|
}
|
|
try {
|
|
const file = Bun.file(imagePath);
|
|
if (!(await file.exists())) {
|
|
return new Response("File not found", { status: 404 });
|
|
}
|
|
return new Response(file);
|
|
} catch {
|
|
return new Response("Failed to read file", { status: 500 });
|
|
}
|
|
}
|
|
|
|
// API: Upload image -> save to temp -> return path
|
|
if (url.pathname === "/api/upload" && req.method === "POST") {
|
|
try {
|
|
const formData = await req.formData();
|
|
const file = formData.get("file") as File;
|
|
if (!file) {
|
|
return new Response("No file provided", { status: 400 });
|
|
}
|
|
|
|
const ext = file.name.split(".").pop() || "png";
|
|
const tempDir = "/tmp/plannotator";
|
|
mkdirSync(tempDir, { recursive: true });
|
|
const tempPath = `${tempDir}/${crypto.randomUUID()}.${ext}`;
|
|
|
|
await Bun.write(tempPath, file);
|
|
return Response.json({ path: tempPath });
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : "Upload failed";
|
|
return Response.json({ error: message }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// API: Detect Obsidian vaults
|
|
if (url.pathname === "/api/obsidian/vaults") {
|
|
const vaults = detectObsidianVaults();
|
|
return Response.json({ vaults });
|
|
}
|
|
|
|
// API: Approve plan
|
|
if (url.pathname === "/api/approve" && req.method === "POST") {
|
|
// Check for note integrations
|
|
try {
|
|
const body = (await req.json().catch(() => ({}))) as {
|
|
obsidian?: ObsidianConfig;
|
|
bear?: BearConfig;
|
|
};
|
|
|
|
// Obsidian integration
|
|
if (body.obsidian?.vaultPath && body.obsidian?.plan) {
|
|
const result = await saveToObsidian(body.obsidian);
|
|
if (result.success) {
|
|
console.error(`[Obsidian] Saved plan to: ${result.path}`);
|
|
} else {
|
|
console.error(`[Obsidian] Save failed: ${result.error}`);
|
|
}
|
|
}
|
|
|
|
// Bear integration
|
|
if (body.bear?.plan) {
|
|
const result = await saveToBear(body.bear);
|
|
if (result.success) {
|
|
console.error(`[Bear] Saved plan to Bear`);
|
|
} else {
|
|
console.error(`[Bear] Save failed: ${result.error}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Don't block approval on integration errors
|
|
console.error(`[Integration] Error:`, err);
|
|
}
|
|
|
|
resolveDecision({ approved: true });
|
|
return Response.json({ ok: true });
|
|
}
|
|
|
|
// API: Deny with feedback
|
|
if (url.pathname === "/api/deny" && req.method === "POST") {
|
|
try {
|
|
const body = (await req.json()) as { feedback?: string };
|
|
resolveDecision({
|
|
approved: false,
|
|
feedback: body.feedback || "Plan rejected by user",
|
|
});
|
|
} catch {
|
|
resolveDecision({
|
|
approved: false,
|
|
feedback: "Plan rejected by user",
|
|
});
|
|
}
|
|
return Response.json({ ok: true });
|
|
}
|
|
|
|
// Serve embedded HTML for all other routes (SPA)
|
|
return new Response(htmlContent, {
|
|
headers: { "Content-Type": "text/html" },
|
|
});
|
|
},
|
|
});
|
|
|
|
break; // Success, exit retry loop
|
|
} catch (err: unknown) {
|
|
const isAddressInUse =
|
|
err instanceof Error && err.message.includes("EADDRINUSE");
|
|
|
|
if (isAddressInUse && attempt < MAX_RETRIES) {
|
|
await Bun.sleep(RETRY_DELAY_MS);
|
|
continue;
|
|
}
|
|
|
|
if (isAddressInUse) {
|
|
const hint = isRemote ? " (set PLANNOTATOR_PORT to use different port)" : "";
|
|
throw new Error(`Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}`);
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
if (!server) {
|
|
throw new Error("Failed to start server");
|
|
}
|
|
|
|
const serverUrl = `http://localhost:${server.port}`;
|
|
|
|
// Notify caller that server is ready
|
|
if (onReady) {
|
|
onReady(serverUrl, isRemote, server.port);
|
|
}
|
|
|
|
return {
|
|
port: server.port,
|
|
url: serverUrl,
|
|
isRemote,
|
|
waitForDecision: () => decisionPromise,
|
|
stop: () => server.stop(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Default behavior: open browser for local sessions
|
|
*/
|
|
export async function handleServerReady(
|
|
url: string,
|
|
isRemote: boolean,
|
|
_port: number
|
|
): Promise<void> {
|
|
if (!isRemote) {
|
|
await openBrowser(url);
|
|
}
|
|
}
|