Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
}
},
});
Expand Down Expand Up @@ -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(() => {});
}
},
});
Expand Down Expand Up @@ -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(() => {});
}
},
});
Expand Down
2 changes: 1 addition & 1 deletion apps/opencode-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
}
},
});
Expand Down
7 changes: 4 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions packages/server/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 5 additions & 3 deletions packages/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
86 changes: 78 additions & 8 deletions packages/server/remote.ts
Original file line number Diff line number Diff line change
@@ -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.)
*/
Expand All @@ -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
Expand All @@ -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<string> {
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<string | null> {
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;
}
}
6 changes: 4 additions & 2 deletions packages/server/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
Loading