diff --git a/CLAUDE.md b/CLAUDE.md index fa9bc2f8..204786c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,9 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. | | `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. | | `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. | +| `PLANNOTATOR_HOSTNAME` | Explicit hostname for remote URLs (e.g. `mybox.ts.net`). Auto-detected from Tailscale if not set. | +| `CLAUDE_HOOKS_NTFY_URL` | Full ntfy URL (e.g. `https://ntfy.sh/mytopic`). When set, remote URLs are sent as push notifications. | +| `CLAUDE_HOOKS_NTFY_TOKEN` | Optional ntfy auth token (Bearer). | **Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected. Prefer `PLANNOTATOR_REMOTE=1` for explicit control. diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 98b87db0..50f3aeac 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -158,7 +158,7 @@ if (args[0] === "sessions") { handleReviewServerReady(url, isRemote, port); if (isRemote && sharingEnabled && rawPatch) { - await writeRemoteShareLink(rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => {}); + await writeRemoteShareLink(rawPatch, url, shareBaseUrl, "review changes", "diff only", pasteApiUrl).catch(() => {}); } }, }); @@ -243,7 +243,7 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {}); + await writeRemoteShareLink(markdown, url, shareBaseUrl, "annotate", "document only", pasteApiUrl).catch(() => {}); } }, }); @@ -310,7 +310,7 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink(planContent, url, shareBaseUrl, "review the plan", "plan only", pasteApiUrl).catch(() => {}); } }, }); diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index 949caadf..1297ecb2 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -353,7 +353,7 @@ Do NOT proceed with implementation until your plan is approved. onReady: async (url, isRemote, port) => { handleServerReady(url, isRemote, port); if (isRemote && await getSharingEnabled()) { - await writeRemoteShareLink(args.plan, getShareBaseUrl(), "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink(args.plan, url, getShareBaseUrl(), "review the plan", "plan only").catch(() => {}); } }, }); diff --git a/bun.lock b/bun.lock index 858d55ef..f6b18204 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "plannotator", @@ -50,7 +51,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.11.4", + "version": "0.12.0", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -71,7 +72,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.11.4", + "version": "0.12.0", "peerDependencies": { "@mariozechner/pi-coding-agent": ">=0.53.0", }, @@ -151,7 +152,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.11.4", + "version": "0.12.0", "dependencies": { "@plannotator/shared": "workspace:*", }, diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 286c3817..cbd06b71 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -11,7 +11,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { isRemoteSession, getServerPort } from "./remote"; +import { isRemoteSession, getServerPort, getServerHostname } from "./remote"; import { getRepoInfo } from "./repo"; import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete } from "./shared-handlers"; import { handleDoc } from "./reference-handlers"; @@ -86,6 +86,7 @@ export async function startAnnotateServer( const isRemote = isRemoteSession(); const configuredPort = getServerPort(); + const hostname = await getServerHostname(); const draftKey = contentHash(markdown); // Detect repo info (cached for this session) @@ -110,6 +111,7 @@ export async function startAnnotateServer( try { server = Bun.serve({ port: configuredPort, + hostname: hostname !== "localhost" ? "0.0.0.0" : undefined, async fetch(req) { const url = new URL(req.url); @@ -213,7 +215,7 @@ export async function startAnnotateServer( throw new Error("Failed to start server"); } - const serverUrl = `http://localhost:${server.port}`; + const serverUrl = `http://${hostname}:${server.port}`; // Notify caller that server is ready if (onReady) { diff --git a/packages/server/index.ts b/packages/server/index.ts index e8fe38d7..a3fa2541 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -10,7 +10,7 @@ */ import { resolve } from "path"; -import { isRemoteSession, getServerPort } from "./remote"; +import { isRemoteSession, getServerPort, getServerHostname } from "./remote"; import { openEditorDiff } from "./ide"; import { saveToObsidian, @@ -39,7 +39,7 @@ import { handleDoc, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc import { createEditorAnnotationHandler } from "./editor-annotations"; // Re-export utilities -export { isRemoteSession, getServerPort } from "./remote"; +export { isRemoteSession, getServerPort, getServerHostname } from "./remote"; export { openBrowser } from "./browser"; export * from "./integrations"; export * from "./storage"; @@ -109,6 +109,7 @@ export async function startPlannotatorServer( const isRemote = isRemoteSession(); const configuredPort = getServerPort(); + const hostname = await getServerHostname(); const draftKey = contentHash(plan); const editorAnnotations = createEditorAnnotationHandler(); @@ -158,6 +159,7 @@ export async function startPlannotatorServer( try { server = Bun.serve({ port: configuredPort, + hostname: hostname !== "localhost" ? "0.0.0.0" : undefined, async fetch(req) { const url = new URL(req.url); @@ -452,7 +454,7 @@ export async function startPlannotatorServer( throw new Error("Failed to start server"); } - const serverUrl = `http://localhost:${server.port}`; + const serverUrl = `http://${hostname}:${server.port}`; // Notify caller that server is ready if (onReady) { diff --git a/packages/server/remote.ts b/packages/server/remote.ts index 75c2f04a..2c88b6ff 100644 --- a/packages/server/remote.ts +++ b/packages/server/remote.ts @@ -1,15 +1,18 @@ /** - * Remote session detection and port configuration + * Remote session detection, port configuration, and hostname resolution * * Environment variables: - * PLANNOTATOR_REMOTE - Set to "1" or "true" to force remote mode (preferred) - * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) + * PLANNOTATOR_REMOTE - Set to "1" or "true" to force remote mode (preferred) + * PLANNOTATOR_PORT - Fixed port to use (default: random) + * PLANNOTATOR_HOSTNAME - Explicit hostname for remote URLs (e.g. "mybox.ts.net") * * Legacy (still supported): SSH_TTY, SSH_CONNECTION + * + * When Tailscale is available, the server URL uses the Tailscale hostname + * so remote users can connect directly without port forwarding. This also + * allows random ports, so parallel sessions work. */ -const DEFAULT_REMOTE_PORT = 19432; - /** * Check if running in a remote session (SSH, devcontainer, etc.) */ @@ -29,7 +32,11 @@ export function isRemoteSession(): boolean { } /** - * Get the server port to use + * Get the server port to use. + * + * Always uses random port (0) unless PLANNOTATOR_PORT is explicitly set. + * The old default of 19432 for remote is no longer needed since we resolve + * the actual hostname (Tailscale/explicit) instead of relying on port forwarding. */ export function getServerPort(): number { // Explicit port from environment takes precedence @@ -44,6 +51,69 @@ export function getServerPort(): number { ); } - // Remote sessions use fixed port for port forwarding; local uses random - return isRemoteSession() ? DEFAULT_REMOTE_PORT : 0; + return 0; +} + +let cachedHostname: string | null | undefined; + +/** + * Get the hostname to use in server URLs. + * + * Priority: + * 1. PLANNOTATOR_HOSTNAME env var (explicit override) + * 2. Tailscale hostname (auto-detected via `tailscale status`) + * 3. "localhost" (fallback) + */ +export async function getServerHostname(): Promise { + if (cachedHostname !== undefined) { + return cachedHostname ?? "localhost"; + } + + // 1. Explicit env var + const envHostname = process.env.PLANNOTATOR_HOSTNAME; + if (envHostname) { + cachedHostname = envHostname; + return envHostname; + } + + // 2. Auto-detect Tailscale + if (isRemoteSession()) { + const tsHostname = await detectTailscaleHostname(); + if (tsHostname) { + cachedHostname = tsHostname; + return tsHostname; + } + } + + // 3. Fallback + cachedHostname = null; + return "localhost"; +} + +/** + * Detect the Tailscale DNS name by running `tailscale status --self --json`. + * Returns null if Tailscale is not available or not running. + */ +async function detectTailscaleHostname(): Promise { + try { + const proc = Bun.spawn(["tailscale", "status", "--self", "--json"], { + stdout: "pipe", + stderr: "ignore", + }); + + const text = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) return null; + + const data = JSON.parse(text); + // DNSName has a trailing dot, e.g. "a4000.chaco-dory.ts.net." + const dnsName = data?.Self?.DNSName; + if (typeof dnsName === "string" && dnsName.length > 1) { + return dnsName.replace(/\.$/, ""); + } + + return null; + } catch { + return null; + } } diff --git a/packages/server/review.ts b/packages/server/review.ts index bb16c9f9..193d3d1f 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -9,7 +9,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { isRemoteSession, getServerPort } from "./remote"; +import { isRemoteSession, getServerPort, getServerHostname } from "./remote"; import { type DiffType, type GitContext, runGitDiff, getFileContentsForDiff, gitAddFile, gitResetFile, parseWorktreeDiffType, validateFilePath } from "./git"; import { getRepoInfo } from "./repo"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, type OpencodeClient } from "./shared-handlers"; @@ -95,6 +95,7 @@ export async function startReviewServer( const isRemote = isRemoteSession(); const configuredPort = getServerPort(); + const hostname = await getServerHostname(); // Detect repo info (cached for this session) const repoInfo = await getRepoInfo(); @@ -120,6 +121,7 @@ export async function startReviewServer( try { server = Bun.serve({ port: configuredPort, + hostname: hostname !== "localhost" ? "0.0.0.0" : undefined, async fetch(req) { const url = new URL(req.url); @@ -309,7 +311,7 @@ export async function startReviewServer( throw new Error("Failed to start server"); } - const serverUrl = `http://localhost:${server.port}`; + const serverUrl = `http://${hostname}:${server.port}`; // Notify caller that server is ready if (onReady) { diff --git a/packages/server/share-url.ts b/packages/server/share-url.ts index 9b7528fc..f24e712d 100644 --- a/packages/server/share-url.ts +++ b/packages/server/share-url.ts @@ -1,13 +1,24 @@ /** - * Server-side share URL generation for remote sessions + * Server-side share URL generation and notification for remote sessions. * - * Generates a share.plannotator.ai URL from plan content so remote users - * can open the review in their local browser without port forwarding. + * When Tailscale (or PLANNOTATOR_HOSTNAME) is available, the server URL + * is directly reachable from the user's local browser — no port forwarding + * needed. The tmux popup / ntfy notification shows this URL. + * + * As a fallback, a read-only share.plannotator.ai URL is also generated + * (plan-only, no approve/deny capability). + * + * Notification priority: + * 1. tmux display-popup (if $TMUX is set) + * 2. ntfy push notification (if PLANNOTATOR_NTFY_TOPIC is set) + * 3. stderr (fallback — works for OpenCode, logging, etc.) */ import { compress } from "@plannotator/shared/compress"; +import { encrypt } from "@plannotator/shared/crypto"; const DEFAULT_SHARE_BASE = "https://share.plannotator.ai"; +const DEFAULT_PASTE_API = "https://plannotator-paste.plannotator.workers.dev"; /** * Generate a share URL from plan markdown content. @@ -24,6 +35,41 @@ export async function generateRemoteShareUrl( return `${base}/#${hash}`; } +/** + * Try to create a short URL via the paste service. + * + * Compresses, encrypts (AES-256-GCM), and uploads the ciphertext. + * The decryption key stays in the URL fragment (never sent to server). + * Returns null if the paste service is unavailable. + */ +async function createShortUrl( + content: string, + shareBaseUrl?: string, + pasteApiUrl?: string +): Promise { + const pasteApi = pasteApiUrl ?? DEFAULT_PASTE_API; + const shareBase = shareBaseUrl ?? DEFAULT_SHARE_BASE; + + try { + const compressed = await compress({ p: content, a: [] }); + const { ciphertext, key } = await encrypt(compressed); + + const response = await fetch(`${pasteApi}/api/paste`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: ciphertext }), + signal: AbortSignal.timeout(5_000), + }); + + if (!response.ok) return null; + + const result = (await response.json()) as { id: string }; + return `${shareBase}/p/${result.id}#key=${key}`; + } catch { + return null; + } +} + /** * Format byte size as human-readable string */ @@ -34,20 +80,147 @@ export function formatSize(bytes: number): string { } /** - * Generate a remote share URL and write it to stderr for the user. + * Show URL via tmux display-popup. + * Returns true if tmux was available and the popup was launched. + */ +async function notifyTmux( + serverUrl: string, + shareUrl: string | null, + verb: string +): Promise { + if (!process.env.TMUX) return false; + + try { + const lines = [ + "", + ` Open in your browser to ${verb}:`, + "", + ` ${serverUrl}`, + ]; + + if (shareUrl) { + lines.push("", ` Read-only: ${shareUrl}`); + } + + lines.push("", " Press Enter to dismiss."); + + const message = lines.join("\n"); + const maxLineLen = Math.max(...lines.map((l) => l.length)); + + const proc = Bun.spawn( + [ + "tmux", + "display-popup", + "-T", + " Plannotator ", + "-h", + String(lines.length + 2), + "-w", + String(Math.min(Math.max(maxLineLen + 4, 50), 120)), + "-E", + `printf '${message.replace(/'/g, "'\\''")}'; read`, + ], + { stdout: "ignore", stderr: "ignore" } + ); + // Don't await — let the popup show while we continue + // But do check it launched successfully + await new Promise((r) => setTimeout(r, 100)); + return proc.exitCode === null || proc.exitCode === 0; + } catch { + return false; + } +} + +/** + * Send URL via ntfy push notification. + * + * Reads config from env vars (compatible with claude-code ntfy hooks): + * CLAUDE_HOOKS_NTFY_URL - Full ntfy URL (e.g. https://ntfy.sh/mytopic) + * CLAUDE_HOOKS_NTFY_TOKEN - Optional auth token + * + * Returns true if the notification was sent successfully. + */ +async function notifyNtfy(url: string, verb: string): Promise { + const ntfyUrl = process.env.CLAUDE_HOOKS_NTFY_URL; + if (!ntfyUrl) return false; + + const headers: Record = { + Title: `Plannotator: ${verb}`, + Click: url, + Tags: "clipboard", + }; + + const token = process.env.CLAUDE_HOOKS_NTFY_TOKEN; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + try { + const response = await fetch(ntfyUrl, { + method: "POST", + headers, + body: url, + signal: AbortSignal.timeout(5_000), + }); + return response.ok; + } catch { + return false; + } +} + +/** + * Notify the remote user about the plan review server URL. + * + * When the server URL uses a reachable hostname (Tailscale, explicit), + * it's shown as the primary link (full approve/deny capability). + * A read-only share URL is generated as a fallback. + * + * Notifies via tmux popup, ntfy, and/or stderr. * Silently does nothing on failure. */ export async function writeRemoteShareLink( content: string, + serverUrl: string, shareBaseUrl: string | undefined, verb: string, - noun: string + noun: string, + pasteApiUrl?: string ): Promise { - const shareUrl = await generateRemoteShareUrl(content, shareBaseUrl); - const size = formatSize(new TextEncoder().encode(shareUrl).length); - process.stderr.write( - `\n Open this link on your local machine to ${verb}:\n` + - ` ${shareUrl}\n\n` + - ` (${size} — ${noun}, annotations added in browser)\n\n` + const isServerReachable = !serverUrl.includes("localhost"); + + // Generate share URL (read-only fallback, or primary if server is localhost-only) + let shareUrl: string | null = null; + if (!isServerReachable) { + const shortUrl = await createShortUrl(content, shareBaseUrl, pasteApiUrl); + shareUrl = + shortUrl ?? (await generateRemoteShareUrl(content, shareBaseUrl)); + } + + const primaryUrl = isServerReachable ? serverUrl : (shareUrl!); + + // Try tmux popup (non-blocking — popup stays open for the user) + const tmuxOk = await notifyTmux( + isServerReachable ? serverUrl : shareUrl!, + isServerReachable ? null : null, // no second URL needed when server is reachable + verb ); + + // Try ntfy push notification + const ntfyOk = await notifyNtfy(primaryUrl, verb); + + // Always write to stderr as well (visible in OpenCode, useful for logging) + const via = [tmuxOk && "tmux", ntfyOk && "ntfy"].filter(Boolean).join("+"); + const lines = [`\n Open in your browser to ${verb}:\n ${primaryUrl}\n`]; + if (isServerReachable) { + lines.push(` (${noun} — full review with approve/deny)`); + } else { + const label = shareUrl!.length < 100 + ? "short link" + : formatSize(new TextEncoder().encode(shareUrl!).length); + lines.push(` (${label} — ${noun}, read-only, annotations added in browser)`); + } + if (via) lines.push(` [notified via ${via}]`); + lines.push("\n"); + + process.stderr.write(lines.join("\n")); }