Skip to content
Closed
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
7 changes: 7 additions & 0 deletions apps/pi-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ URL targets work too. A loopback `http` URL that answers with an HTML page (a ru

Run `/plannotator-last` to annotate the agent's most recent response. The message opens in the annotation UI where you can highlight text, add comments, and send structured feedback back to the agent.

### Tailscale access

Run `/plannotator-tailscale` once to publish new Plannotator browser sessions privately through `tailscale serve`. Reviews stay bound to loopback, and Plannotator prints an HTTPS URL that another device on the same tailnet can open. This works in an already-running Pi session; no restart or environment variables are needed.

Use `/plannotator-tailscale off` to stop publishing new sessions. Existing reviews remain available until they close, then their serve mappings are removed.

### Archive browser

The Plannotator archive browser is available through the shared event API as `archive`, which opens the saved plan/decision browser for future callers. The orchestrator does not expose a dedicated archive command yet.
Expand All @@ -292,6 +298,7 @@ During execution, the agent marks completed steps with `[DONE:n]` markers. Progr
| `/plannotator-review` | Open code review UI for current changes |
| `/plannotator-annotate <file>` | Open markdown file in annotation UI |
| `/plannotator-last` | Annotate the last assistant message |
| `/plannotator-tailscale [on\|off\|status]` | Enable private tailnet URLs for new browser sessions without restarting Pi |

## Flags

Expand Down
31 changes: 28 additions & 3 deletions apps/pi-extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* - [DONE:n] markers for execution progress tracking
* - /plannotator-review command for code review
* - /plannotator-annotate command for markdown annotation
* - /plannotator-tailscale command for tailnet browser access
*/

import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
Expand Down Expand Up @@ -75,6 +76,7 @@ import {
stripPlanningOnlyTools,
} from "./tool-scope.ts";
import { isRemoteSession, isUrlHostOverridden } from "./server/network.ts";
import { isTailscaleModeEnabled, setTailscaleModeEnabled } from "./tailscale-mode.ts";
import { isBrowserSessionStoppedError } from "./browser-session-error.ts";
import { classifyAnnotateOutcome } from "./annotate-outcome.ts";

Expand Down Expand Up @@ -188,9 +190,9 @@ function safeNotify(
*/
function sessionOpenedMessage(label: string, url: string): string {
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()
// A command-enabled Tailscale session and an advertised-URL host override
// are directly reachable, so port-forwarding advice would be wrong.
return isTailscaleModeEnabled() || 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.`;
}
Expand Down Expand Up @@ -659,6 +661,29 @@ export default function plannotator(pi: ExtensionAPI): void {

// ── Commands & Shortcuts ─────────────────────────────────────────────

pi.registerCommand("plannotator-tailscale", {
description: "Enable private Tailscale URLs for new Plannotator browser sessions; pass off to disable",
handler: async (args, ctx) => {
const action = (args ?? "").trim().toLowerCase();
if (action === "status") {
ctx.ui.notify(`Plannotator Tailscale mode is ${isTailscaleModeEnabled() ? "enabled" : "disabled"}.`, "info");
return;
}
if (action !== "" && action !== "on" && action !== "off") {
ctx.ui.notify("Usage: /plannotator-tailscale [on|off|status]", "error");
return;
}
const enabled = action !== "off";
setTailscaleModeEnabled(enabled);
ctx.ui.notify(
enabled
? "Plannotator Tailscale mode enabled. New reviews will print a private URL you can open on another tailnet device."
: "Plannotator Tailscale mode disabled for new reviews.",
"info",
);
},
});

pi.registerCommand("plannotator-plan-mode", {
description: "Toggle plannotator planning mode",
handler: async (_args, ctx) => {
Expand Down
1 change: 1 addition & 0 deletions apps/pi-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"plannotator-browser-runtime.ts",
"plannotator-events.ts",
"server.ts",
"tailscale-mode.ts",
"tool-scope.ts",
"config.ts",
"plannotator.json",
Expand Down
19 changes: 19 additions & 0 deletions apps/pi-extension/phase-tools-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import plannotator from "./index.ts";
import {
isTailscaleModeEnabled,
resetTailscaleModeForTests,
} from "./tailscale-mode.ts";

type Handler = (event: unknown, context: ReturnType<typeof createContext>) => unknown;

Expand All @@ -21,6 +25,7 @@ function restoreEnv(name: string, value: string | undefined): void {
}

afterEach(() => {
resetTailscaleModeForTests();
restoreEnv("HOME", originalHome);
restoreEnv("PI_CODING_AGENT_DIR", originalAgentDir);

Expand Down Expand Up @@ -129,6 +134,20 @@ function createRuntime(initialTools: string[]) {
};
}

describe("Plannotator Tailscale command", () => {
test("enables and disables tailnet publishing without restarting Pi", async () => {
const runtime = createRuntime([]);
const context = createContext();
const command = runtime.commands.get("plannotator-tailscale");

expect(command).toBeDefined();
await command?.handler("", context);
expect(isTailscaleModeEnabled()).toBe(true);
await command?.handler("off", context);
expect(isTailscaleModeEnabled()).toBe(false);
});
});

describe("Plannotator phase tool ownership", () => {
test("leaving planning removes only tools Plannotator added", async () => {
const cwd = makeWorkspace();
Expand Down
13 changes: 9 additions & 4 deletions apps/pi-extension/plannotator-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "./server.ts";
import { BROWSER_SESSION_STOPPED } from "./browser-session-error.ts";
import { openBrowser, isRemoteSession } from "./server/network.ts";
import { publishBrowserServerOverTailscale } from "./tailscale-mode.ts";
import { detectProjectName } from "./server/project.ts";
import { parsePRUrl, checkPRAuth, fetchPR } from "./server/pr.ts";
import {
Expand Down Expand Up @@ -324,7 +325,8 @@ export async function startPlanReviewBrowserSession(
pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined,
}));

const session = startBrowserDecisionSession(server, ctx, server.waitForDecision, signal);
const browserServer = publishBrowserServerOverTailscale(server);
const session = startBrowserDecisionSession(browserServer, ctx, server.waitForDecision, signal);
server.onDecision(() => {
setTimeout(() => session.stop(), 1500);
});
Expand Down Expand Up @@ -703,7 +705,8 @@ async function createCodeReviewBrowserSession(
onCleanup: worktreeCleanup,
}));

return startBrowserDecisionSession(server, ctx, server.waitForDecision);
const browserServer = publishBrowserServerOverTailscale(server);
return startBrowserDecisionSession(browserServer, ctx, server.waitForDecision);
}

export async function openMarkdownAnnotation(
Expand Down Expand Up @@ -807,7 +810,8 @@ export async function startMarkdownAnnotationSession(
project: detectProjectName(),
}));

return startBrowserDecisionSession(server, ctx, server.waitForDecision);
const browserServer = publishBrowserServerOverTailscale(server);
return startBrowserDecisionSession(browserServer, ctx, server.waitForDecision);
}

export async function openLastMessageAnnotation(
Expand Down Expand Up @@ -865,7 +869,8 @@ export async function openArchiveBrowserAction(
pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined,
}));

return openBrowserAndWait(server, ctx, async () => {
const browserServer = publishBrowserServerOverTailscale(server);
return openBrowserAndWait(browserServer, ctx, async () => {
if (server.waitForDone) {
await server.waitForDone();
}
Expand Down
20 changes: 20 additions & 0 deletions apps/pi-extension/server/network.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import {
listenOnPort,
openBrowser,
} from "./network.ts";
import {
resetTailscaleModeForTests,
setTailscaleModeEnabled,
} from "../tailscale-mode.ts";

const savedEnv: Record<string, string | undefined> = {};
const envKeys = [
Expand All @@ -42,6 +46,7 @@ function clearEnv() {
}

afterEach(() => {
resetTailscaleModeForTests();
for (const key of envKeys) {
if (savedEnv[key] !== undefined) {
process.env[key] = savedEnv[key];
Expand Down Expand Up @@ -100,6 +105,13 @@ describe("pi remote detection", () => {
process.env.SSH_TTY = "/dev/pts/0";
expect(isRemoteSession()).toBe(true);
});

test("uses remote security behavior when Tailscale mode is enabled", () => {
clearEnv();
process.env.PLANNOTATOR_REMOTE = "false";
setTailscaleModeEnabled(true);
expect(isRemoteSession()).toBe(true);
});
});

describe("pi port selection", () => {
Expand All @@ -124,6 +136,14 @@ describe("pi port selection", () => {
expect(getServerPort()).toEqual({ port: 9999, portSource: "env" });
});

test("Tailscale mode keeps the listener loopback-only on a random port", () => {
clearEnv();
process.env.SSH_CONNECTION = "192.168.1.1 12345 192.168.1.2 22";
setTailscaleModeEnabled(true);
expect(getServerPort()).toEqual({ port: 0, portSource: "random" });
expect(getServerHostname()).toBe("127.0.0.1");
});

test("expands an inclusive port range", () => {
clearEnv();
process.env.PLANNOTATOR_PORT = "19432-19435";
Expand Down
12 changes: 12 additions & 0 deletions apps/pi-extension/server/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { delimiter, join } from "node:path";
import { loadConfig, resolveUrlHost, resolveUseGlimpse } from "../generated/config.ts";
import { parsePortSelection } from "../generated/port-range.ts";
import { isAutoUrlHost, resolveAutoHostCached } from "../generated/tailscale.ts";
import { isTailscaleModeEnabled } from "../tailscale-mode.ts";

const DEFAULT_REMOTE_PORT = 19432;
const LOOPBACK_HOST = "127.0.0.1";
Expand Down Expand Up @@ -85,6 +86,9 @@ function getRemoteOverride(): boolean | null {
}

export function isRemoteSession(): boolean {
// Tailnet-published sessions need remote security behavior even though
// tailscale serve proxies to a loopback-only listener.
if (isTailscaleModeEnabled()) return true;
const remoteOverride = getRemoteOverride();
if (remoteOverride !== null) {
return remoteOverride;
Expand Down Expand Up @@ -130,6 +134,11 @@ function getServerPortConfiguration(): {
}
// Invalid port - fall back silently, caller can check env var themselves
}
// `tailscale serve` publishes the chosen loopback port, so let the OS pick
// a free one unless PLANNOTATOR_PORT explicitly pins it.
if (isTailscaleModeEnabled()) {
return { ports: [0], portSource: "random", isRange: false };
}
if (isRemoteSession()) {
return {
ports: [DEFAULT_REMOTE_PORT],
Expand All @@ -149,6 +158,9 @@ export function getServerPort(): {
}

export function getServerHostname(): string {
// Tailscale serves as the network edge; the Plannotator listener itself
// must remain loopback-only.
if (isTailscaleModeEnabled()) return LOOPBACK_HOST;
return isRemoteSession() ? "0.0.0.0" : LOOPBACK_HOST;
}

Expand Down
78 changes: 78 additions & 0 deletions apps/pi-extension/tailscale-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, test } from "bun:test";
import type { TailscaleRunner } from "./generated/tailscale.ts";
import { resetTailscaleServeForTests } from "./generated/tailscale-serve.ts";
import {
isTailscaleModeEnabled,
publishBrowserServerOverTailscale,
resetTailscaleModeForTests,
setTailscaleModeEnabled,
} from "./tailscale-mode.ts";

afterEach(() => {
resetTailscaleModeForTests();
resetTailscaleServeForTests();
});

describe("Pi Tailscale mode", () => {
test("is disabled until the slash command enables it", () => {
expect(isTailscaleModeEnabled()).toBe(false);
setTailscaleModeEnabled(true);
expect(isTailscaleModeEnabled()).toBe(true);
setTailscaleModeEnabled(false);
expect(isTailscaleModeEnabled()).toBe(false);
});

test("publishes and removes the serve mapping with the browser session", () => {
setTailscaleModeEnabled(true);
const calls: string[][] = [];
const run: TailscaleRunner = (args) => {
calls.push(args);
if (args[0] === "serve" && args[1] === "status") {
return { status: 0, stdout: "null", stderr: "" };
}
if (args.at(-1) === "off") {
return { status: 0, stdout: "", stderr: "" };
}
return {
status: 0,
stdout: "Available within your tailnet:\nhttps://phone-test.tailnet.ts.net:43123/",
stderr: "",
};
};
let serverStops = 0;
const published = publishBrowserServerOverTailscale({
port: 43123,
url: "http://localhost:43123",
stop: () => { serverStops += 1; },
}, run);

expect(published.url).toBe("https://phone-test.tailnet.ts.net:43123");
expect(calls).toEqual([
["serve", "status", "--json"],
["serve", "--bg", "--https=43123", "http://127.0.0.1:43123"],
]);

published.stop();
published.stop();
expect(calls.at(-1)).toEqual(["serve", "--https=43123", "off"]);
expect(serverStops).toBe(1);
});

test("stops the local server when publishing fails", () => {
setTailscaleModeEnabled(true);
let serverStops = 0;
const run: TailscaleRunner = () => ({
status: null,
stdout: "",
stderr: "",
error: Object.assign(new Error("missing"), { code: "ENOENT" }),
});

expect(() => publishBrowserServerOverTailscale({
port: 43123,
url: "http://localhost:43123",
stop: () => { serverStops += 1; },
}, run)).toThrow("tailscale` CLI not found");
expect(serverStops).toBe(1);
});
});
Loading