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
8 changes: 8 additions & 0 deletions apps/marketing/src/content/docs/guides/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ plannotator sessions --open 2 # reopens session #2

Stale sessions from crashed processes are cleaned up automatically. You can also force cleanup with `plannotator sessions --clean`.

Every session also prints its URL on one line to stderr when it starts:

```
Plannotator session ready: http://localhost:54321
```

That prefix is a stable format, so an agent or a script can grep the line out of the output it already captures. For a session whose output you no longer have, `plannotator sessions` above is the place to look: it lists every server that is still alive, not merely every one that was started.

## Where does Plannotator store data?

Plannotator-managed files live under `~/.plannotator/` by default:
Expand Down
26 changes: 26 additions & 0 deletions apps/opencode-plugin/cli-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getRecentAssistantMessages,
injectSessionPrompt,
} from "./cli-bridge";
import { SESSION_READY_LINE_PREFIX } from "@plannotator/server";
import { getReviewApprovedPrompt, getReviewDeniedSuffix } from "@plannotator/shared/prompts";
import { OpenCodePromptDeliveryError } from "./prompt-delivery-error";

Expand Down Expand Up @@ -158,9 +159,34 @@ describe("OpenCode CLI bridge helpers", () => {
expect(formatUserFacingCliStderrLine(" (1.2 KB - plan only, annotations added in browser)")).toBe(
"(1.2 KB - plan only, annotations added in browser)",
);
// The session URL line every session prints, plus its remote follow-up.
expect(formatUserFacingCliStderrLine(" Plannotator session ready: http://localhost:54321")).toBe(
"Plannotator session ready: http://localhost:54321",
);
expect(formatUserFacingCliStderrLine(" Open it on your local machine (forward port 19432 if needed).")).toBe(
"Open it on your local machine (forward port 19432 if needed).",
);
// Regression: this line was dropped, so a user on a headless box got the
// URL but no explanation for the tab that never appeared. The em-dash is
// part of the real product copy.
expect(
formatUserFacingCliStderrLine(" Could not open a browser automatically — open the URL above."),
).toBe("Could not open a browser automatically — open the URL above.");
expect(formatUserFacingCliStderrLine("Fetching: https://example.com")).toBeUndefined();
});

// The filters above match hardcoded text, so nothing in this file notices if
// the server changes what it emits. Build the line from the server's own
// exported constant and push it through the real filter: if the two drift
// apart, OpenCode users lose the URL, and this is the test that says so.
test("forwards a session-ready line built from the server's own prefix constant", () => {
const url = "http://localhost:54321";
// Byte-for-byte what handleServerReady writes, indent included.
const emitted = ` ${SESSION_READY_LINE_PREFIX}${url}`;

expect(formatUserFacingCliStderrLine(emitted)).toBe(`${SESSION_READY_LINE_PREFIX}${url}`);
});

test("resolves Windows CLI commands to an executable without shell mode", () => {
const dir = mkdtempSync(path.join(tmpdir(), "plannotator-cli-"));
try {
Expand Down
15 changes: 11 additions & 4 deletions apps/opencode-plugin/cli-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,18 @@ function logCliWarnings(client: OpenCodeClient, stderr: string): void {
export function formatUserFacingCliStderrLine(line: string): string | undefined {
const trimmed = line.trim();
if (!trimmed) return undefined;
if (/^Open this link on your local machine to\b/.test(trimmed)) return trimmed;
// Current binary phrasing ("Plannotator session ready — open on your local
// machine (forward port N if needed):"); the older "Open this link" match is
// kept for users running an older plannotator binary.
// "Open this link ..." is the share-link header; "Open it ..." is the remote
// follow-up to the session-ready line below.
if (/^Open (?:this link|it) on your local machine\b/.test(trimmed)) return trimmed;
// Current binary phrasing ("Plannotator session ready: <url>", one line on
// every start); older binaries put the URL on the following line, which the
// bare-URL match below still forwards.
if (/^Plannotator session ready\b/.test(trimmed)) return trimmed;
// The browser-launch failure that follows the session-ready line on a
// headless box. Without it OpenCode hands the user a URL and never says why
// no tab appeared. Anchored on the prefix only, so the em-dash in the real
// copy stays out of the pattern.
if (/^Could not open a browser automatically\b/.test(trimmed)) return trimmed;
if (/^https?:\/\/\S+/.test(trimmed)) return trimmed;
if (/^\(.+annotations added in browser\)$/.test(trimmed)) return trimmed;
return undefined;
Expand Down
11 changes: 7 additions & 4 deletions apps/pi-extension/plannotator-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,13 @@ function delay(ms: number): Promise<void> {

async function openBrowserForServer(serverUrl: string, ctx: ExtensionContext): Promise<void> {
const browserResult = await openBrowser(serverUrl);
if (isRemoteSession()) {
ctx.ui.notify(`[Plannotator] ${serverUrl}`, "info");
} else if (!browserResult.opened) {
ctx.ui.notify(`Open this URL to review: ${serverUrl}`, "info");
// Unconditional, mirroring the Bun runtime's stderr line (upstream #1134):
// announcing the URL only for remote sessions or failed browser launches left
// a closed tab unrecoverable on the common local path. Pi's notification is
// the analog of stderr here — the TUI owns the terminal.
ctx.ui.notify(`Plannotator session ready: ${serverUrl}`, "info");
if (!isRemoteSession() && !browserResult.opened) {
ctx.ui.notify("Could not open a browser automatically — open the URL above.", "info");
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export { isRemoteSession, getServerPort } from "./remote";
export { openBrowser } from "./browser";
export * from "./integrations";
export * from "./storage";
export { handleServerReady } from "./shared-handlers";
export { handleServerReady, SESSION_READY_LINE_PREFIX } from "./shared-handlers";
export { type VaultNode, buildFileTree } from "@plannotator/shared/reference-common";

// --- Types ---
Expand Down
2 changes: 0 additions & 2 deletions packages/server/port-startup-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ const envKeys = [
"PLANNOTATOR_REMOTE",
"PLANNOTATOR_DATA_DIR",
"PLANNOTATOR_SKIP_BROWSER_OPEN",
"__CFBundleIdentifier",
] as const;
const environment = createTestEnvironment(envKeys, "plannotator-port-compat-");

Expand All @@ -20,7 +19,6 @@ describe("Bun startup port compatibility", () => {
environment.reset();
process.env.PLANNOTATOR_REMOTE = "0";
process.env.PLANNOTATOR_DATA_DIR = environment.makeTempDir();
process.env.__CFBundleIdentifier = "com.apple.Terminal";
let ready: { url: string; isRemote: boolean; port: number } | undefined;

const server = await startPlannotatorServer({
Expand Down
186 changes: 99 additions & 87 deletions packages/server/shared-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,41 @@ import { tmpdir } from "node:os";
import {
handleSaveNotes,
handleServerReady,
isCodexDesktopHost,
SESSION_READY_LINE_PREFIX,
writeServerReadyMetadata,
} from "./shared-handlers";

/** Run `fn` with stderr captured, so assertions see it and the test log doesn't. */
async function captureStderr(fn: () => Promise<void>): Promise<string> {
const writes: string[] = [];
const original = process.stderr.write.bind(process.stderr);
(process.stderr as { write: unknown }).write = (chunk: unknown) => {
writes.push(String(chunk));
return true;
};
try {
await fn();
} finally {
(process.stderr as { write: unknown }).write = original;
}
return writes.join("");
}

/**
* The session URL must appear exactly once, on the stable one-line format.
*
* The expected line is written out literally rather than interpolated from
* `SESSION_READY_LINE_PREFIX`, because interpolating it would assert the
* constant against itself: every one of these tests would stay green while
* consumers matching the old text (see `formatUserFacingCliStderrLine` in
* `apps/opencode-plugin/cli-bridge.ts`) silently stopped forwarding the URL.
* The two-space indent and the newlines around the line are part of the format.
*/
function expectSingleSessionReadyLine(output: string, url: string): void {
expect(output.split(url).length - 1).toBe(1);
expect(output).toContain(`\n Plannotator session ready: ${url}\n`);
}

function saveNotesRequest(body: unknown): Request {
return new Request("http://localhost/api/save-notes", {
method: "POST",
Expand Down Expand Up @@ -108,125 +139,106 @@ describe("writeServerReadyMetadata", () => {
});
});

describe("handleServerReady", () => {
test("detects the Codex Desktop app host", () => {
expect(isCodexDesktopHost({ __CFBundleIdentifier: "com.openai.codex" })).toBe(true);
expect(isCodexDesktopHost({ __CFBundleIdentifier: "com.apple.Terminal" })).toBe(false);
describe("SESSION_READY_LINE_PREFIX", () => {
// Pinned to the literal bytes, because the prefix is a cross-component
// contract rather than an implementation detail: `cli-bridge.ts` matches it
// with its own hardcoded regex, and the docs quote it as the line agents
// grep. Changing it is a breaking change and has to fail here first.
test("is the exact text consumers match on", () => {
expect(SESSION_READY_LINE_PREFIX).toBe("Plannotator session ready: ");
});
});

describe("handleServerReady", () => {
test("does not open a browser when host-plugin mode handles it", async () => {
let opened = false;
const originalBundleIdentifier = process.env.__CFBundleIdentifier;
process.env.__CFBundleIdentifier = "com.apple.Terminal";

try {
await captureStderr(async () => {
await handleServerReady("http://localhost:12345", false, 12345, {
skipBrowserOpen: true,
openBrowser: async () => {
opened = true;
},
});
} finally {
if (originalBundleIdentifier === undefined) {
delete process.env.__CFBundleIdentifier;
} else {
process.env.__CFBundleIdentifier = originalBundleIdentifier;
}
}
});

expect(opened).toBe(false);
});

// Regression: a remote session must surface a reachable URL in the terminal
// regardless of URL sharing — otherwise a sharing-disabled remote user is left
// with no URL and the agent hangs waiting on the review.
test("prints the reachable URL to stderr for a remote session", async () => {
const writes: string[] = [];
const original = process.stderr.write.bind(process.stderr);
(process.stderr as { write: unknown }).write = (chunk: unknown) => {
writes.push(String(chunk));
return true;
};
try {
await handleServerReady("http://localhost:19432", true, 19432, {
skipBrowserOpen: true,
});
} finally {
(process.stderr as { write: unknown }).write = original;
}
expect(writes.join("")).toContain("http://localhost:19432");
});

test("does not print the URL for a local session when the browser opens", async () => {
const writes: string[] = [];
// Regression (upstream #1134): the URL used to be printed only when the
// session was remote, when the Codex desktop host was detected, or when the
// browser failed to open. A local session whose browser opened fine printed
// nothing, so a closed tab left neither the user nor the agent driving the
// session with any way back to it.
test("prints the stable URL line for a local session when the browser opens", async () => {
let opened = "";
const original = process.stderr.write.bind(process.stderr);
const originalBundleIdentifier = process.env.__CFBundleIdentifier;
(process.stderr as { write: unknown }).write = (chunk: unknown) => {
writes.push(String(chunk));
return true;
};
process.env.__CFBundleIdentifier = "com.apple.Terminal";
try {

const output = await captureStderr(async () => {
await handleServerReady("http://localhost:3000", false, 3000, {
openBrowser: async (u: string) => {
opened = u;
return true;
},
});
} finally {
(process.stderr as { write: unknown }).write = original;
if (originalBundleIdentifier === undefined) {
delete process.env.__CFBundleIdentifier;
} else {
process.env.__CFBundleIdentifier = originalBundleIdentifier;
}
}
expect(writes.join("")).not.toContain("http://localhost:3000");
});

expectSingleSessionReadyLine(output, "http://localhost:3000");
expect(opened).toBe("http://localhost:3000");
});

test("prints the URL for a local Codex Desktop session even when the browser opens", async () => {
const writes: string[] = [];
const originalWrite = process.stderr.write.bind(process.stderr);
const originalBundleIdentifier = process.env.__CFBundleIdentifier;
(process.stderr as { write: unknown }).write = (chunk: unknown) => {
writes.push(String(chunk));
return true;
};
process.env.__CFBundleIdentifier = "com.openai.codex";
try {
await handleServerReady("http://localhost:3000", false, 3000, {
openBrowser: async () => true,
// The URL is greppable, so it has to be on one line and it has to be the same
// line in every mode — including the modes that add their own context.
test("prints the reachable URL once for a remote session, with forwarding context", async () => {
const output = await captureStderr(async () => {
await handleServerReady("http://localhost:19432", true, 19432, {
skipBrowserOpen: true,
});
} finally {
(process.stderr as { write: unknown }).write = originalWrite;
if (originalBundleIdentifier === undefined) {
delete process.env.__CFBundleIdentifier;
} else {
process.env.__CFBundleIdentifier = originalBundleIdentifier;
}
}
expect(writes.join("")).toContain("http://localhost:3000");
});

expectSingleSessionReadyLine(output, "http://localhost:19432");
expect(output).toContain("forward port 19432");
});

// Regression: a local session whose browser can't be opened (headless box,
// devcontainer with no display) must still surface the URL, or the agent
// hangs at waitForDecision with the user having no link to visit.
test("prints the URL for a local session when the browser fails to open", async () => {
const writes: string[] = [];
const original = process.stderr.write.bind(process.stderr);
(process.stderr as { write: unknown }).write = (chunk: unknown) => {
writes.push(String(chunk));
return true;
};
try {
// devcontainer with no display) must say so, or the user waits on a tab that
// never appears — but the URL still prints exactly once.
test("prints the URL once and reports the failure when the browser won't open", async () => {
const output = await captureStderr(async () => {
await handleServerReady("http://localhost:4000", false, 4000, {
openBrowser: async () => false,
});
});

expectSingleSessionReadyLine(output, "http://localhost:4000");
expect(output).toContain("Could not open a browser automatically");
});

test("publishes ready metadata to the PLANNOTATOR_READY_FILE side channel", async () => {
const dir = mkdtempSync(join(tmpdir(), "plannotator-ready-env-"));
const readyFile = join(dir, "ready.jsonl");
const original = process.env.PLANNOTATOR_READY_FILE;
process.env.PLANNOTATOR_READY_FILE = readyFile;

try {
await captureStderr(async () => {
await handleServerReady("http://localhost:5000", false, 5000, {
openBrowser: async () => true,
});
});

const [line] = readFileSync(readyFile, "utf8").trim().split(/\r?\n/);
expect(JSON.parse(line)).toEqual({
url: "http://localhost:5000",
isRemote: false,
port: 5000,
});
} finally {
(process.stderr as { write: unknown }).write = original;
if (original === undefined) {
delete process.env.PLANNOTATOR_READY_FILE;
} else {
process.env.PLANNOTATOR_READY_FILE = original;
}
rmSync(dir, { recursive: true, force: true });
}
expect(writes.join("")).toContain("http://localhost:4000");
});
});
Loading