Skip to content
Merged
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ claude --plugin-dir ./apps/hook
| `PLANNOTATOR_REMOTE` | Set to `1` / `true` for remote mode, `0` / `false` for local mode, or leave unset for SSH auto-detection. Uses a fixed port in remote mode; browser-opening behavior depends on the environment. |
| `PLANNOTATOR_AGENT_TERMINAL_REMOTE` | Set to `1` / `true` to enable the annotate-mode agent terminal while `PLANNOTATOR_REMOTE` is active. Off by default because remote mode binds beyond localhost. |
| `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. |
| `PLANNOTATOR_URL_HOST` | Display-only hostname for advertised session URLs (issue #657), e.g. a Tailscale MagicDNS name or tailnet IP, so remote-mode links are reachable from another device instead of `http://localhost:<port>`. Host only — bare hostname, IPv4, or bracketed IPv6 (`[fd7a::1]`); the runtime-chosen port is always appended, and anything carrying a scheme, port, path, credentials, or whitespace warns once on stderr and falls back to `localhost`. Strictly display-only and remote-only: binding stays governed by `PLANNOTATOR_REMOTE`; a local session ignores the override (localhost is advertised and opened, since only loopback is bound) with a once-per-process stderr warning to set `PLANNOTATOR_REMOTE=1`, and spawned agent-review jobs keep a pinned `http://127.0.0.1:<port>` API URL so a tailnet-only hostname cannot break local jobs. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }`); the env var takes precedence, and an empty-but-set env var (`PLANNOTATOR_URL_HOST=`) suppresses a config-file `urlHost`. Default: unset (`localhost`). |
| `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. |
| `PLANNOTATOR_AI` | Set to `disabled` to disable Ask AI and the Review Agents / Guided Review execution surfaces, including provider and agent-job endpoints. Persisted guide data is retained and its server APIs remain available, but the in-app history browser is hidden while AI is disabled. External agents can still open reviews and submit annotations. The explicit annotate-mode agent terminal is separate and remains controlled by its own settings. Default: enabled. |
| `PLANNOTATOR_SHARE` | Set to `disabled` to turn off URL sharing entirely. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "share": "disabled" }`); the env var takes precedence. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Plannotator is configured through environment variables, hook/plugin configurati
|----------|---------|-------------|
| `PLANNOTATOR_REMOTE` | auto-detect | Set to `1` or `true` to force remote mode, `0` or `false` to force local mode, or leave unset to auto-detect via `SSH_TTY` / `SSH_CONNECTION`. Uses a fixed port in remote mode; browser-opening behavior depends on the environment. |
| `PLANNOTATOR_PORT` | random (local) / `19432` (remote) | Fixed server port. Useful for port forwarding in remote environments. |
| `PLANNOTATOR_URL_HOST` | unset (`localhost`) | Display-only hostname for advertised session URLs in remote mode (e.g. a Tailscale MagicDNS name). The runtime-chosen port is always appended; binding is unaffected and local sessions ignore the override. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }`). |
| `PLANNOTATOR_BROWSER` | system default | Custom browser or script to open the UI. |
| `PLANNOTATOR_AI` | enabled | Set to `disabled` to disable Ask AI, Review Agents, and Guided Review. External agents can still open reviews and submit annotations; the annotate agent terminal is separate. |
| `PLANNOTATOR_SHARE` | enabled | Set to `disabled` to turn off URL sharing entirely. Can also be set via `~/.plannotator/config.json` (`{ "share": "disabled" }`). |
Expand Down
13 changes: 13 additions & 0 deletions apps/marketing/src/content/docs/guides/remote-and-devcontainers.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ Remote mode changes two behaviors:

Plannotator also detects `SSH_TTY` and `SSH_CONNECTION` environment variables for automatic remote mode when `PLANNOTATOR_REMOTE` is unset. Use `PLANNOTATOR_REMOTE=1` / `true` to force remote mode or `PLANNOTATOR_REMOTE=0` / `false` to force local mode.

## Direct-reach hosts (Tailscale, LAN)

When the machine running Plannotator is directly reachable from your other devices — over a Tailscale tailnet, a VPN, or a trusted LAN — port forwarding is unnecessary, but the advertised URL still says `localhost`, which another device cannot open. Set `PLANNOTATOR_URL_HOST` to the hostname or IP those devices can reach:

```bash
export PLANNOTATOR_REMOTE=1
export PLANNOTATOR_URL_HOST=my-machine.tailnet.ts.net
```

Plannotator then advertises `http://my-machine.tailnet.ts.net:<port>` (the port is chosen at runtime and always appended), so you can open review sessions straight from a phone or another computer. The setting is host-only and strictly display-only — it never changes which interface the server binds; remote mode (`PLANNOTATOR_REMOTE=1`) is what makes the server reachable beyond localhost, and a local session ignores the override entirely (localhost is advertised, with a warning). It can also be set persistently via `~/.plannotator/config.json` (`{ "urlHost": "my-machine.tailnet.ts.net" }`); the env var takes precedence.

Note that the session is served over plain `http`, so some in-app features that require a secure context (such as creating short share links from the UI) are unavailable from other devices unless you put the session behind HTTPS (e.g. `tailscale serve`). The core review, annotate, and approve flows work over plain `http`.

## VS Code Remote / devcontainers

VS Code sets the `BROWSER` environment variable in devcontainers to a helper script that opens URLs on your local machine. Plannotator respects this — in most cases, the browser opens automatically with no extra configuration.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ All Plannotator environment variables and their defaults.
|----------|---------|-------------|
| `PLANNOTATOR_REMOTE` | auto-detect | Set to `1` or `true` to force remote mode, `0` or `false` to force local mode, or leave unset to auto-detect via `SSH_TTY` / `SSH_CONNECTION`. Uses a fixed port in remote mode; browser-opening behavior depends on the environment. |
| `PLANNOTATOR_PORT` | random (local) / `19432` (remote) | Fixed server port or inclusive range such as `19432-19463`. A range uses the first available port. When not set, local sessions use a random port; remote sessions default to `19432`. |
| `PLANNOTATOR_URL_HOST` | unset (`localhost`) | Display-only hostname for advertised session URLs, e.g. a Tailscale MagicDNS name or tailnet IP, so remote-mode links are reachable from another device instead of `http://localhost:<port>`. Host only: bare hostname, IPv4, or bracketed IPv6 such as `[fd7a::1]` — the runtime-chosen port is always appended, and a value carrying a scheme, port, path, or whitespace warns on stderr and falls back to `localhost`. Strictly display-only and remote-only: it never changes which interface the server binds (that stays governed by `PLANNOTATOR_REMOTE`), and a local session ignores the override — the advertised URL stays `localhost`, with a stderr warning to set `PLANNOTATOR_REMOTE=1`. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }`); the env var takes precedence, and setting it to an empty value suppresses a config-file `urlHost`. |
| `PLANNOTATOR_BROWSER` | system default | Custom browser to open the UI in. macOS: app name or path. Linux/Windows: executable path. Can also be a script. Takes priority over `BROWSER`. Also settable per-invocation with `--browser`. |
| `BROWSER` | (none) | Standard env var for specifying a browser. VS Code sets this automatically in devcontainers. Used as fallback when `PLANNOTATOR_BROWSER` is not set. |
| `PLANNOTATOR_ORIGIN` | auto-detect | Explicit agent-origin override. Valid values: `claude-code`, `amp`, `droid`, `opencode`, `codex`, `copilot-cli`, `pi`, `gemini-cli`, `kiro-cli`. Invalid values silently fall through to env-based detection. |
Expand Down
11 changes: 7 additions & 4 deletions apps/pi-extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import {
type Phase,
stripPlanningOnlyTools,
} from "./tool-scope.ts";
import { isRemoteSession } from "./server/network.ts";
import { isRemoteSession, isUrlHostOverridden } from "./server/network.ts";
import { isBrowserSessionStoppedError } from "./browser-session-error.ts";
import { classifyAnnotateOutcome } from "./annotate-outcome.ts";

Expand Down Expand Up @@ -163,9 +163,12 @@ function safeNotify(
* after-turn notify inside openBrowserForServer fires too late to render.
*/
function sessionOpenedMessage(label: string, url: string): string {
return isRemoteSession()
? `${label} — open ${url} on your local machine (forward the port if needed). You can keep chatting while it runs.`
: `${label}. You can keep chatting while it runs.`;
if (!isRemoteSession()) return `${label}. You can keep chatting while it runs.`;
// With an advertised-URL host override the link is directly reachable
// (e.g. over a tailnet), so the port-forwarding advice would be wrong.
return isUrlHostOverridden()
? `${label} — open ${url} on your device. You can keep chatting while it runs.`
: `${label} — open ${url} on your local machine (forward the port if needed). You can keep chatting while it runs.`;
}

function reportBackgroundError(ctx: ExtensionContext, message: string, err: unknown, origin?: PiSessionIdentity): void {
Expand Down
6 changes: 6 additions & 0 deletions apps/pi-extension/server/agent-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export function whichCmd(cmd: string): boolean {

export interface AgentJobHandlerOptions {
mode: "plan" | "review" | "annotate";
/**
* Returns the server's base URL for spawned subprocesses (e.g.,
* "http://127.0.0.1:12345"). Late-bound. Jobs run on the same machine, so
* this must stay loopback-reachable regardless of any advertised-URL host
* override.
*/
getServerUrl: () => string;
getCwd: () => string;
/** Build the command server-side for a given provider. */
Expand Down
48 changes: 48 additions & 0 deletions apps/pi-extension/server/network.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
import { createServer } from "node:http";
import { closeServer, occupyConsecutivePorts } from "../../../tests/helpers/ports.ts";
import {
buildAdvertisedUrl,
getServerHostname,
getServerPort,
getServerPorts,
Expand All @@ -19,6 +20,7 @@ const envKeys = [
"SSH_CONNECTION",
"PLANNOTATOR_BROWSER",
"BROWSER",
"PLANNOTATOR_URL_HOST",
];

function clearEnv() {
Expand Down Expand Up @@ -282,3 +284,49 @@ describe("pi browser no-op sentinels", () => {
});
});
});

describe("pi buildAdvertisedUrl", () => {
test("defaults to localhost", () => {
clearEnv();
process.env.PLANNOTATOR_REMOTE = "1";
// An empty (but set) env var suppresses any urlHost in the developer's
// real config.json, isolating the default path.
process.env.PLANNOTATOR_URL_HOST = "";
expect(buildAdvertisedUrl(19432)).toBe("http://localhost:19432");
});

test("a local session ignores the override and advertises localhost", () => {
clearEnv();
process.env.PLANNOTATOR_URL_HOST = "my-machine.tailnet.ts.net";
expect(buildAdvertisedUrl(1234)).toBe("http://localhost:1234");
});

test("appends the runtime port to the override host", () => {
clearEnv();
process.env.PLANNOTATOR_REMOTE = "1";
process.env.PLANNOTATOR_URL_HOST = "my-machine.tailnet.ts.net";
expect(buildAdvertisedUrl(19432)).toBe("http://my-machine.tailnet.ts.net:19432");
});

test("keeps bracketed IPv6 hosts intact", () => {
clearEnv();
process.env.PLANNOTATOR_REMOTE = "1";
process.env.PLANNOTATOR_URL_HOST = "[fd7a::1]";
expect(buildAdvertisedUrl(9999)).toBe("http://[fd7a::1]:9999");
});

test("an invalid host falls back to localhost instead of throwing", () => {
clearEnv();
process.env.PLANNOTATOR_REMOTE = "1";
process.env.PLANNOTATOR_URL_HOST = "https://evil.example/path";
expect(buildAdvertisedUrl(1234)).toBe("http://localhost:1234");
});

test("the override never affects the bind hostname", () => {
clearEnv();
process.env.PLANNOTATOR_URL_HOST = "my-machine.tailnet.ts.net";
expect(getServerHostname()).toBe("127.0.0.1");
process.env.PLANNOTATOR_REMOTE = "1";
expect(getServerHostname()).toBe("0.0.0.0");
});
});
35 changes: 34 additions & 1 deletion apps/pi-extension/server/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { existsSync } from "node:fs";
import type { Server } from "node:http";
import { release } from "node:os";
import { delimiter, join } from "node:path";
import { loadConfig, resolveUseGlimpse } from "../generated/config.ts";
import { loadConfig, resolveUrlHost, resolveUseGlimpse } from "../generated/config.ts";
import { parsePortSelection } from "../generated/port-range.ts";

const DEFAULT_REMOTE_PORT = 19432;
Expand Down Expand Up @@ -116,6 +116,39 @@ export function getServerHostname(): string {
return isRemoteSession() ? "0.0.0.0" : LOOPBACK_HOST;
}

/** True when the advertised-URL host is overridden away from localhost. */
export function isUrlHostOverridden(): boolean {
return resolveUrlHost(loadConfig()) !== undefined;
}

let warnedLocalUrlHost = false;

/**
* Compose the URL advertised to the user for a bound port (issue #657).
* Display-only: the PLANNOTATOR_URL_HOST / urlHost override changes what is
* printed and opened, never which interface the server listens on
* (getServerHostname). Remote sessions only: a local session binds loopback,
* so honoring the override would advertise (and auto-open) a URL nothing is
* listening on — the override is ignored with a once-per-process warning.
* Same-machine subprocesses must not use this — they get a loopback URL so a
* tailnet-only hostname can't break local agent jobs.
* Mirrors packages/server/remote.ts — keep the two behaviorally identical.
*/
export function buildAdvertisedUrl(port: number): string {
const host = resolveUrlHost(loadConfig());
if (host === undefined) return `http://localhost:${port}`;
if (!isRemoteSession()) {
if (!warnedLocalUrlHost) {
warnedLocalUrlHost = true;
process.stderr.write(
`[plannotator] Warning: advertised URL host ${JSON.stringify(host)} ignored — this is a local session, so the server binds loopback and only localhost is reachable. Set PLANNOTATOR_REMOTE=1 to use the override.\n`,
);
}
return `http://localhost:${port}`;
}
return `http://${host}:${port}`;
}

const MAX_RETRIES = 5;
const RETRY_DELAY_MS = 500;

Expand Down
4 changes: 2 additions & 2 deletions apps/pi-extension/server/serverAnnotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
import { handleApiNotFound, html, json, parseBody, requestUrl } from "./helpers.ts";
import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.ts";

import { isRemoteSession, listenOnPort } from "./network.ts";
import { buildAdvertisedUrl, isRemoteSession, listenOnPort } from "./network.ts";
import { getAvailableOpenInApps, openFileInApp } from "./open-in-apps.ts";

import { getRepoInfo } from "./project.ts";
Expand Down Expand Up @@ -850,7 +850,7 @@ export async function startAnnotateServer(options: {
return {
port,
portSource,
url: `http://localhost:${port}`,
url: buildAdvertisedUrl(port),
waitForDecision: () => decisionPromise,
stop: () => {
// try/finally: a throwing dispose must never leave the listener bound.
Expand Down
4 changes: 2 additions & 2 deletions apps/pi-extension/server/serverPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
saveToObsidian,
saveToOctarine,
} from "./integrations.ts";
import { listenOnPort } from "./network.ts";
import { buildAdvertisedUrl, listenOnPort } from "./network.ts";

import { loadConfig, saveConfig, detectGitUser, getServerConfig, resolveAIEnabled, resolveSharingEnabled } from "../generated/config.ts";
import { readImprovementHook, getImprovementHookExpectedPath } from "../generated/improvement-hooks.ts";
Expand Down Expand Up @@ -467,7 +467,7 @@ export async function startPlanReviewServer(options: {
reviewId,
port,
portSource,
url: `http://localhost:${port}`,
url: buildAdvertisedUrl(port),
waitForDecision: () => decisionPromise,
onDecision: (listener) => {
decisionListeners.add(listener);
Expand Down
13 changes: 9 additions & 4 deletions apps/pi-extension/server/serverReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ import {
import { handleApiNotFound, html, json, parseBody, requestUrl, send } from "./helpers.ts";
import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.ts";

import { isRemoteSession, listenOnPort } from "./network.ts";
import { buildAdvertisedUrl, isRemoteSession, listenOnPort } from "./network.ts";
import { getAvailableOpenInApps, openFileInApp } from "./open-in-apps.ts";
import { resolveOpenInTarget } from "../generated/html-assets-node.ts";
import {
Expand Down Expand Up @@ -683,8 +683,12 @@ export async function startReviewServer(options: {
);
}

// Agent jobs — background process manager (late-binds serverUrl via getter)
// Agent jobs — background process manager (late-binds serverUrl via getter).
// Spawned jobs run on this machine, so their API URL is pinned to loopback
// and never inherits the advertised-URL host override (a tailnet-only
// hostname must not break local agent jobs).
let serverUrl = "";
let agentApiUrl = "";
function resolveAgentCwd(): string {
if (workspace) return workspace.root;
if (options.worktreePool && prMeta) {
Expand Down Expand Up @@ -880,7 +884,7 @@ export async function startReviewServer(options: {

const agentJobs = createAgentJobHandler({
mode: "review",
getServerUrl: () => serverUrl,
getServerUrl: () => agentApiUrl,
getCwd: resolveAgentCwd,

async buildCommand(provider, config) {
Expand Down Expand Up @@ -2743,7 +2747,8 @@ export async function startReviewServer(options: {
});

const { port, portSource } = await listenOnPort(server);
serverUrl = `http://localhost:${port}`;
serverUrl = buildAdvertisedUrl(port);
agentApiUrl = `http://127.0.0.1:${port}`;
const exitHandler = () => agentJobs.killAll();
process.once("exit", exitHandler);

Expand Down
7 changes: 6 additions & 1 deletion packages/server/agent-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@ const SERVER_BUILT_PROVIDERS: ReadonlySet<string> = new Set([
export interface AgentJobHandlerOptions {
/** Which server mode this handler is mounted in. */
mode: "plan" | "review" | "annotate";
/** Returns the server's base URL (e.g., "http://localhost:12345"). Late-bound. */
/**
* Returns the server's base URL for spawned subprocesses (e.g.,
* "http://127.0.0.1:12345"). Late-bound. Jobs run on the same machine, so
* this must stay loopback-reachable regardless of any advertised-URL host
* override.
*/
getServerUrl: () => string;
/** Returns the working directory for spawned processes. */
getCwd: () => string;
Expand Down
4 changes: 2 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 or inclusive range (default: random locally, 19432 for remote)
*/

import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort } from "./remote";
import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort, buildAdvertisedUrl } from "./remote";
import { getRepoInfo } from "./repo";
import type { Origin } from "@plannotator/shared/agents";
import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleApiNotFound, handleFavicon, handleSaveNotes, readDraftGenerationFromBody, readDraftGenerationFromUrl } from "./shared-handlers";
Expand Down Expand Up @@ -902,7 +902,7 @@ export async function startAnnotateServer(
);

const port = server.port!;
const serverUrl = `http://localhost:${port}`;
const serverUrl = buildAdvertisedUrl(port);

// The cache warm must never gate the listening socket. Its async filesystem
// walk yields between directories while requests remain serviceable.
Expand Down
4 changes: 2 additions & 2 deletions packages/server/goal-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
type GoalSetupQuestionAnswer,
type GoalSetupResult,
} from "@plannotator/shared/goal-setup";
import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort } from "./remote";
import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort, buildAdvertisedUrl } from "./remote";
import { getRepoInfo } from "./repo";
import {
handleFavicon,
Expand Down Expand Up @@ -208,7 +208,7 @@ export async function startGoalSetupServer(
);

const port = server.port!;
const serverUrl = `http://localhost:${port}`;
const serverUrl = buildAdvertisedUrl(port);
onReady?.(serverUrl, isRemote, port);

return {
Expand Down
Loading