Files
backnotprop__plannotator/packages/server/index.ts
T
Michael Ramos 14e9aea0ec Fix/devcontainer & plan auto switching (opencode) (#31)
* 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>
2026-01-06 14:25:19 -08:00

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);
}
}