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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,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. Remote ready messages also render a terminal QR code of the advertised URL when stderr is a TTY and the advertised host is overridden away from localhost (a QR of a localhost URL scans to nowhere), so another device can join without retyping the URL. |
| `PLANNOTATOR_AGENT_TERMINAL_REMOTE` | Set to `1` / `true` to enable the annotate-mode agent terminal while `PLANNOTATOR_REMOTE` is active or the session is published with `--tailscale`. Off by default in both cases because the session is reachable by network peers and the PTY token is not an auth boundary. |
| `PLANNOTATOR_INBOX_PHONES` | Switch for the Plannotator Inbox's whole phone surface (see "Phones" under "Plannotator Inbox"): pairing, the device door `/api/inbox/device/*`, the tailnet and Wi-Fi listeners, the relay connection and Settings' Phones block. **Default: off**, until the iPhone app and the relay ship. Off, the Inbox does nothing phone-related: every pairing, device and reach-switch route answers the unknown-route `404 { code: "not_found" }`, no LAN or door listener opens, nothing connects to the relay, no certificate is made and no `openssl`, `tailscale` or Bonjour process runs, even when `inbox.json`, `relay.json` or `devices.jsonl` hold state from a run that had it on (that state is kept for the next run with it on). `1` / `true` / `on` turn it on, `0` / `false` / `off` / `disabled` off; empty or unrecognized counts as unset. Also `~/.plannotator/config.json` `{ "inboxPhones": true }`; the env var wins (`resolveInboxPhones` in `packages/shared/config.ts`). Read once when the Inbox starts (`startInboxServer`'s `phones` option overrides it); `GET /api/inbox/settings` carries it as `phones`, which is how the window decides to draw the block. The iPhone app's proofs (`apps/ios/scripts/proof.ts`, `workspaces-proof.ts`), the relay proof, `tests/e2e/inbox-phones.spec.ts`, the phone server tests (`createInboxWorld(..., { phones: true })`, `phones: true`) and the DAST target set it; `packages/server/inbox-phones-gate.test.ts` proves both states. Not in the public docs. |
| `PLANNOTATOR_RELAY_URL` | The Inbox relay a new mailbox is made at (`packages/server/inbox-relay.ts`). Default `https://relay.plannotator.ai`. Proofs point it at a `wrangler dev` relay; tests at a closed loopback port. A mailbox, once made, stays at the URL `inbox/relay.json` names. |
| `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. The Plannotator Inbox ignores it (and `PLANNOTATOR_REMOTE`): it always binds `127.0.0.1` on its last port or a random one; see "Plannotator Inbox". |
| `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. The sentinel `auto` resolves the host from Tailscale once per process, at first use in a remote session: `tailscale status --json` → `Self.DNSName` (trailing dot stripped), falling back to the single `tailscale ip -4` CGNAT (100.64.0.0/10) address; detection failure warns once on stderr and falls back to `localhost`, and detection never changes binding — `auto` is as display-only as any explicit host. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }` or `{ "urlHost": "auto" }`); the env var takes precedence, and an empty-but-set env var (`PLANNOTATOR_URL_HOST=`) suppresses a config-file `urlHost`. Default: unset (`localhost`). |
Expand Down Expand Up @@ -2047,6 +2048,7 @@ Sorting and the rest:
**On a phone**: `adr/implementation/inbox-mobile.md` is the wire contract the iPhone app and its server steps build against: the pairing offer, the device token door `/api/inbox/device/*`, the LAN listener, the relay, the surface bridge and the idempotency rule. Built (P1, P2, R1, R2): pairing, the door, the tailnet path, the LAN listener, and the relay's mailboxes, push and carriage; the iPhone app `apps/ios` (M1: pairing by QR or typed address and six digits, the list, threads, picks, Send, Resolve, Delete, Settings with the tailnet row; M2: attachments full screen in the bundled surface with comments by touch, the "N annotations" sheet, the changed line, Share, and annotations riding the next Send; M3: the decision switch opening the decision card as a sheet, the Decisions tab per project, and New message from the reply bar to a live session, a menu when several are live, the desktop's "not running" words when none is; M4: a guided review full screen in the surface, its sections with their reviewed ticks and Continue, one section per screen with Previous, Reviewed and Next, the bar's title and back following the surface's `section` messages, the wrap button, each tick saved through the door; its proof is `bun apps/ios/scripts/proof.ts --binary <compiled plannotator>`, run by `.github/workflows/ios.yml`, whose New message steps drive live Claude Code sessions made of the mod's own code (`apps/hook/hooks/mod/testing/claude-session.ts`) and which also runs the relay under `wrangler dev`; M5: push registration from Settings' Allow notifications, the notification service extension, the lock-screen answer as one Send with the pick inside it, through the relay's command path when the computer cannot be reached; M7: Workspaces as a second source behind the `WORKSPACES_HOST` build setting (Debug and TestFlight on, Release off), see `apps/ios/README.md`; M6: the paths of 9.2 in order, the Wi-Fi listener from the QR's `lan` and `fp` with its certificate pinned, the tailnet, then the relay as a reading and answering path (down items applied to the threads the app keeps, the list, threads, attachments and HTML assets read as GET commands while the computer is online, commands sealed under the up key with the door's idempotency keys, a Send held for an offline computer shown as "Sent. Waiting for your computer" until it lands or is refused), the relay switch on from pairing (Allow notifications registers the push token only)). The Inbox writes no event when a thread is deleted: a phone learns it at its next read of that thread. Not built: Bonjour discovery in 1.3 (a Wi-Fi address that moves is followed only by scanning the code again).

**Phones** (`packages/server/inbox-devices.ts`, records in `packages/shared/inbox/devices.ts`; contract sections 1, 2 and 6).
- Off unless `PLANNOTATOR_INBOX_PHONES=1` (or config.json `inboxPhones: true`) is set when the Inbox starts: until the iPhone app ships, a released Inbox has no pairing, no door, no reach switches, no relay and no Phones block, and every one of those routes answers the unknown-route 404 (see the env table). Everything below describes the Inbox with the switch on.
- Pairing: Settings' "Pair a phone" calls `POST /api/inbox/pairing`, which opens one offer: a `plannotator://pair` link (drawn as a QR code with `uqr`, `packages/inbox/qr.ts`) carrying the computer's name, the tailnet address when it is on, the LAN address and the certificate's SHA-256 (`lan`, `fp`) when the Wi-Fi is on, a 32-byte secret and six digits. One offer is open at a time, in memory only. It closes when redeemed, when the window makes another, 10 minutes after it was made, or after 5 wrong codes: the two limits pairing needs, and the only ones. The phone redeems the secret or the digits at `POST /api/inbox/device/pair` for its own `tok_` bearer token, returned once.
- Records under `inbox/`: `devices.jsonl` (the store's line rules; `dev_` id, name, platform, the token's SHA-256 and never the token, created, last seen, revoked, `carriage`), `device-secrets/<dev id>` (the pairing secret, 0600 in a 0700 folder, for the relay's keys; deleted on revoke) and `device-commands.jsonl` (the door's answers by key). Last seen is kept in memory on each request and written when its UTC day changes.
- The relay (`packages/server/inbox-relay.ts`; the Worker in `apps/relay`; contract section 4):
Expand Down
3 changes: 2 additions & 1 deletion apps/ios/scripts/proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ const env: Record<string, string> = {
PATH: process.env.PATH ?? '',
HOME: join(tmp, 'home'),
PLANNOTATOR_DATA_DIR: dataDir,
PLANNOTATOR_BROWSER: 'none',
PLANNOTATOR_BROWSER: 'none', // Phones are hidden in releases until the iPhone app ships; the app's proofs turn them on.
PLANNOTATOR_INBOX_PHONES: '1',
};
// Every plannotator process this script starts gets exactly this env: a temp data dir, never ~/.plannotator.
if (!env.PLANNOTATOR_DATA_DIR.startsWith(tmpdir()) || env.PLANNOTATOR_DATA_DIR.includes('/.plannotator')) {
Expand Down
3 changes: 2 additions & 1 deletion apps/ios/scripts/workspaces-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ const env: Record<string, string> = {
PATH: process.env.PATH ?? '',
HOME: join(tmp, 'home'),
PLANNOTATOR_DATA_DIR: join(tmp, 'data'),
PLANNOTATOR_BROWSER: 'none',
PLANNOTATOR_BROWSER: 'none', // Phones are hidden in releases until the iPhone app ships; the app's proofs turn them on.
PLANNOTATOR_INBOX_PHONES: '1',
};
// The Inbox runs on a temp data dir, never the person's own (row 5109).
if (!env.PLANNOTATOR_DATA_DIR!.startsWith(tmpdir()) || env.PLANNOTATOR_DATA_DIR!.includes('.plannotator')) throw new Error('not a temp data dir');
Expand Down
4 changes: 2 additions & 2 deletions apps/relay/test/relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ describe("the relay with an Inbox, under wrangler dev", () => {
apple = await fakeApns(key.publicKey);
relay = await startRelay("inbox", { APNS_KEY: key.pkcs8, APNS_KEY_ID: "KEY0000000", APNS_TEAM_ID: "TEAM000000", APNS_ORIGIN: `http://127.0.0.1:${apple.port}` });
stubs = stubBuiltHtml();
w = createInboxWorld("plannotator-relay-", "relay", "relay");
w = createInboxWorld("plannotator-relay-", "relay", "relay", { phones: true });
env = { ...(process.env as Record<string, string>), ...worldEnv(w), PLANNOTATOR_RELAY_URL: relay.url };
startTheInbox();
agent = await SimAgent.connect({ binary: join(w.bin, "plannotator"), env, name: "Claude Code", host: "claude-code", cwd: w.project });
Expand Down Expand Up @@ -604,7 +604,7 @@ describe("the carriage: a phone reads and answers the Inbox through the relay al
beforeAll(async () => {
relay = await startRelay("carriage", {});
stubs = stubBuiltHtml();
w = createInboxWorld("plannotator-relay-carriage-", "carriage", "R2");
w = createInboxWorld("plannotator-relay-carriage-", "carriage", "R2", { phones: true });
if (proofDir) mkdirSync(proofDir, { recursive: true });
env = { ...(process.env as Record<string, string>), ...worldEnv(w), PLANNOTATOR_RELAY_URL: relay.url };
// The files an agent attaches: a plan, and an HTML page with an image beside it (1.4 MB: its envelope passes one storage row).
Expand Down
2 changes: 2 additions & 0 deletions packages/inbox/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export interface SettingsModel {
data_dir: string;
inbox_tool: { hosts: Record<AgentToolHost, boolean>; env: boolean | null };
notifications: NotificationSettings;
/** Whether this Inbox serves phones (PLANNOTATOR_INBOX_PHONES): the Phones block shows only then. */
phones: boolean;
store: {
dir: string;
bytes: number;
Expand Down
7 changes: 5 additions & 2 deletions packages/inbox/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,10 +222,13 @@ export function SettingsPage({
</div>
<div className="ib-sblock">
<h2>Connect an agent</h2>
<p>Agents on this computer reach the Inbox at 127.0.0.1, and it keeps its port between runs when it can. Phones reach it only on a path you turn on below.</p>
<p>
Agents on this computer reach the Inbox at 127.0.0.1, and it keeps its port between runs when it can.
{settings?.phones && ' Phones reach it only on a path you turn on below.'}
</p>
{context && <ConnectPicker harnesses={HARNESSES} initial="other" context={context} compact />}
</div>
{settings && <PhonesBlock />}
{settings?.phones && <PhonesBlock />}
{settings && (
<div className="ib-sblock">
<h2>Stored on this machine</h2>
Expand Down
14 changes: 7 additions & 7 deletions packages/server/inbox-devices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe("the device door, on the binary", () => {

beforeAll(async () => {
stubs = stubBuiltHtml();
w = createInboxWorld("plannotator-inbox-devices-", "device-door", "device-door");
w = createInboxWorld("plannotator-inbox-devices-", "device-door", "device-door", { phones: true });
const entry = startInbox(w);
base = `http://127.0.0.1:${entry.port}`;
serverSession = ((await (await fetch(`${base}/api/inbox/health`)).json()) as Json).serverSession;
Expand Down Expand Up @@ -538,7 +538,7 @@ describe("the device door, in process", () => {
const dataDir = mkdtempSync(join(tmpdir(), "plannotator-inbox-devices-clock-"));
roots.push(dataDir);
let clock = Date.parse("2026-10-08T10:00:00.000Z");
const inbox = await startInboxServer({ dataDir, binaryPath: null, now: () => new Date(clock) });
const inbox = await startInboxServer({ dataDir, binaryPath: null, phones: true, now: () => new Date(clock) });
try {
const base = `http://127.0.0.1:${inbox.port}`;
const made = (await (await fetch(`${base}/api/inbox/pairing`, { method: "POST", body: "{}" })).json()) as Json;
Expand Down Expand Up @@ -582,7 +582,7 @@ describe("the device door, in process", () => {
};
const refused = async (response: Response) => [response.status, ((await response.json()) as Json).code];
try {
const inbox = await startInboxServer({ dataDir, binaryPath: null, tailscale });
const inbox = await startInboxServer({ dataDir, binaryPath: null, phones: true, tailscale });
const main = `http://127.0.0.1:${inbox.port}`;
const on = await fetch(`${main}/api/inbox/tailnet`, { method: "POST", body: JSON.stringify({ on: true }) });
expect(((await on.json()) as Json).tailnet).toMatchObject({ on: true, address: "macbook-pro.tail0000.ts.net:8443" });
Expand Down Expand Up @@ -649,7 +649,7 @@ describe("the device door, in process", () => {
expect(serve.has(8443)).toBe(false);
expect(JSON.parse(readFileSync(join(dataDir, "inbox", "inbox.json"), "utf8")).tailnet.https_port).toBe(8443);
let atRestart: string | undefined = "not called";
const next = await startInboxServer({ dataDir, binaryPath: null, tailscale, onRestartRequested: () => (atRestart = serve.get(8443)) });
const next = await startInboxServer({ dataDir, binaryPath: null, phones: true, tailscale, onRestartRequested: () => (atRestart = serve.get(8443)) });
expect(serve.get(8443)).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(serve.get(8443)).not.toBe(`http://127.0.0.1:${next.port}`);
// Restart to update takes the mapping down before the new run starts (no race with its publish).
Expand All @@ -658,13 +658,13 @@ describe("the device door, in process", () => {
expect(atRestart).toBeUndefined();

// After a crash (kill -9): the mapping still points at the dead run's door listener.
const crashed = await startInboxServer({ dataDir, binaryPath: null, tailscale });
const crashed = await startInboxServer({ dataDir, binaryPath: null, phones: true, tailscale });
const deadDoor = serve.get(8443)!;
crashed.stop();
serve.set(8443, deadDoor);
// The next start that cannot publish takes that leftover down rather than leave it.
down.serve = true;
const unpublished = await startInboxServer({ dataDir, binaryPath: null, tailscale });
const unpublished = await startInboxServer({ dataDir, binaryPath: null, phones: true, tailscale });
expect(serve.has(8443)).toBe(false);
unpublished.stop();
down.serve = false;
Expand All @@ -676,7 +676,7 @@ describe("the device door, in process", () => {
expect(takeDownInboxTailnet(dataDir, tailscale)).toBe("none");

// A mapping someone else put on 8443 is never taken down.
const third = await startInboxServer({ dataDir, binaryPath: null, tailscale });
const third = await startInboxServer({ dataDir, binaryPath: null, phones: true, tailscale });
serve.set(8443, "http://127.0.0.1:3000");
third.stop();
expect(serve.get(8443)).toBe("http://127.0.0.1:3000");
Expand Down
10 changes: 5 additions & 5 deletions packages/server/inbox-lan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ describe("Reach from this Wi-Fi, on the binary", () => {

beforeAll(async () => {
stubs = stubBuiltHtml();
w = createInboxWorld("plannotator-inbox-lan-", "lan-listener", "lan-listener");
w = createInboxWorld("plannotator-inbox-lan-", "lan-listener", "lan-listener", { phones: true });
await restart();
computerName = ((await (await win("/api/inbox/pairing", { serverSession })).json()) as Json).computer.name;
}, 60_000);
Expand Down Expand Up @@ -344,7 +344,7 @@ describe("Reach from this Wi-Fi, in process", () => {
chmodSync(join(bin, "openssl"), 0o755);
const path = process.env.PATH;
process.env.PATH = `${bin}:${path ?? ""}`;
const inbox = await startInboxServer({ dataDir: join(root, "data"), binaryPath: null });
const inbox = await startInboxServer({ dataDir: join(root, "data"), binaryPath: null, phones: true });
try {
const main = `http://127.0.0.1:${inbox.port}`;
const on = await fetch(`${main}/api/inbox/lan`, { method: "POST", body: JSON.stringify({ on: true }) });
Expand All @@ -365,7 +365,7 @@ describe("Reach from this Wi-Fi, in process", () => {
test("a listener that cannot open at start shows the switch on with the reason, and turning it off works", async () => {
const root = mkdtempSync(join(tmpdir(), "plannotator-inbox-lan-startfail-"));
const dataDir = join(root, "data");
const first = await startInboxServer({ dataDir, binaryPath: null });
const first = await startInboxServer({ dataDir, binaryPath: null, phones: true });
const on = await fetch(`http://127.0.0.1:${first.port}/api/inbox/lan`, { method: "POST", body: JSON.stringify({ on: true }) });
const port = Number((((await on.json()) as Json).lan.address ?? ":0").split(":")[1]);
first.stop();
Expand All @@ -377,7 +377,7 @@ describe("Reach from this Wi-Fi, in process", () => {
chmodSync(join(bin, "openssl"), 0o755);
const path = process.env.PATH;
process.env.PATH = `${bin}:${path ?? ""}`;
const inbox = await startInboxServer({ dataDir, binaryPath: null });
const inbox = await startInboxServer({ dataDir, binaryPath: null, phones: true });
try {
const main = `http://127.0.0.1:${inbox.port}`;
const state = ((await (await fetch(`${main}/api/inbox/lan`)).json()) as Json).lan;
Expand All @@ -396,7 +396,7 @@ describe("Reach from this Wi-Fi, in process", () => {

test("the switch's route keeps the window's guards: a foreign page and a stale tab are refused", async () => {
const root = mkdtempSync(join(tmpdir(), "plannotator-inbox-lan-guards-"));
const inbox = await startInboxServer({ dataDir: join(root, "data"), binaryPath: null });
const inbox = await startInboxServer({ dataDir: join(root, "data"), binaryPath: null, phones: true });
try {
const main = `http://127.0.0.1:${inbox.port}`;
const foreign = await fetch(`${main}/api/inbox/lan`, { method: "POST", headers: { Origin: "https://evil.example" }, body: JSON.stringify({ on: true }) });
Expand Down
Loading
Loading