# Discovery Service Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add a discovery service that lists all active pi-remote sessions, with shared token auth and Tailscale integration.

**Architecture:** The discovery service is a detached process spawned by the first pi-remote session. It runs on port 7008 with a Tailscale route at `/pi/`. Sessions register/deregister via localhost API. When the last session deregisters, the discovery service cleans up and exits. All sessions share the same token, generated by the discovery service.

**Tech Stack:** Node.js HTTP server, server-rendered HTML (dark theme), Tailscale CLI

---

### Task 1: Extract Tailscale helpers into shared module

**Files:**
- Create: `src/tailscale.ts`
- Modify: `src/index.ts`

**Step 1: Create `src/tailscale.ts`**

Extract from `src/index.ts` into a new module:

```typescript
import { execSync } from "node:child_process";
import { statSync } from "node:fs";

const TAILSCALE_PATHS = [
	"/usr/local/bin/tailscale",
	"/usr/bin/tailscale",
	"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
];

export function findTailscaleBin(): string | null {
	for (const cmd of ["which tailscale", "command -v tailscale"]) {
		try {
			const result = execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
			if (result) return result;
		} catch {}
	}
	for (const p of TAILSCALE_PATHS) {
		try {
			if (statSync(p)) return p;
		} catch {}
	}
	return null;
}

export function getTailscaleHostname(bin: string): string | null {
	try {
		const json = execSync(`${JSON.stringify(bin)} status --json`, {
			encoding: "utf-8",
			stdio: ["pipe", "pipe", "pipe"],
		});
		const dnsName: string = JSON.parse(json)?.Self?.DNSName;
		return dnsName?.replace(/\.$/, "") ?? null;
	} catch {
		return null;
	}
}

export function tailscaleServe(bin: string, port: number, path: string): boolean {
	try {
		execSync(
			`${JSON.stringify(bin)} serve --bg --https 443 --set-path ${JSON.stringify(path)} http://localhost:${port}`,
			{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
		);
		return true;
	} catch {
		return false;
	}
}

export function tailscaleServeOff(bin: string, path: string): void {
	try {
		execSync(`${JSON.stringify(bin)} serve --https 443 --set-path ${JSON.stringify(path)} off`, {
			encoding: "utf-8",
			stdio: ["pipe", "pipe", "pipe"],
		});
	} catch {}
}
```

**Step 2: Update `src/index.ts`**

Remove the Tailscale helper functions and `TAILSCALE_PATHS` constant. Replace with:

```typescript
import { findTailscaleBin, getTailscaleHostname, tailscaleServe, tailscaleServeOff } from "./tailscale.js";
```

Remove the now-unused `statSync` import (keep `execSync` for `resolvePiPath`). Remove the standalone `getTailscaleUrl()` export (it's only used by `extension/index.ts` which doesn't import it).

**Step 3: Build and verify**

```bash
npm run build:ts
```

**Step 4: Commit**

```bash
git add src/tailscale.ts src/index.ts
git commit -m "refactor: extract tailscale helpers into shared module"
```

---

### Task 2: Create the discovery service

**Files:**
- Create: `src/discovery.ts`
- Create: `src/discovery-main.ts`

**Step 1: Create `src/discovery.ts`**

The discovery service HTTP server with session registry, web UI, and auto-shutdown.

```typescript
import { randomBytes } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { findTailscaleBin, getTailscaleHostname, tailscaleServe, tailscaleServeOff } from "./tailscale.js";

const PORT = 7008;
const HOST = "0.0.0.0";

interface Session {
	sessionId: string;
	port: number;
	cwd: string;
	startedAt: string; // ISO string
}

const sessions = new Map<string, Session>();
const TOKEN = randomBytes(16).toString("hex");

let server: Server | null = null;
let tsBin: string | null = null;
let tsHostname: string | null = null;
let tailscaleUrl: string | null = null;
const TS_SERVE_PATH = "/pi/";

function relativeTime(iso: string): string {
	const diff = Date.now() - new Date(iso).getTime();
	const mins = Math.floor(diff / 60000);
	if (mins < 1) return "just now";
	if (mins < 60) return `${mins} min ago`;
	const hrs = Math.floor(mins / 60);
	if (hrs < 24) return `${hrs}h ${mins % 60}m ago`;
	return `${Math.floor(hrs / 24)}d ago`;
}

function sessionCard(s: Session, baseUrl: string): string {
	const url = `${baseUrl}/pi/${s.sessionId}/?token=${TOKEN}`;
	return `
<a href="${url}" class="card">
	<div class="cwd">${s.cwd}</div>
	<div class="meta">${s.sessionId} · ${relativeTime(s.startedAt)}</div>
</a>`;
}

function renderPage(): string {
	const baseUrl = tailscaleUrl ?? `http://127.0.0.1:${PORT}`;
	const sessionList = Array.from(sessions.values());

	const cardsHtml = sessionList.length > 0
		? sessionList.map((s) => sessionCard(s, baseUrl)).join("\n")
		: '<div class="empty">No active sessions</div>';

	return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>pi remote</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:#0a0a0a;color:#d4d4d4;font-family:system-ui,sans-serif}
body{display:flex;align-items:center;justify-content:center;padding:20px}
.container{max-width:400px;width:100%}
h1{font-size:18px;color:#e0e0e0;margin-bottom:16px;text-align:center}
.card{display:block;background:#1a1a1a;border:1px solid #333;border-radius:10px;padding:16px 20px;margin-bottom:12px;text-decoration:none;color:inherit;transition:border-color 0.15s}
.card:hover{border-color:#555}
.cwd{font-size:14px;color:#e0e0e0;font-family:ui-monospace,monospace;word-break:break-all}
.meta{font-size:11px;color:#666;margin-top:6px}
.empty{background:#1a1a1a;border:1px solid #333;border-radius:10px;padding:24px 28px;text-align:center}
.empty{font-size:12px;color:#888}
</style>
</head>
<body>
<div class="container">
<h1>pi remote</h1>
${cardsHtml}
</div>
</body>
</html>`;
}

function styledErrorPage(title: string, message: string): string {
	return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>pi remote</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:#0a0a0a;color:#d4d4d4;font-family:system-ui,sans-serif}
body{display:flex;align-items:center;justify-content:center}
.card{background:#1a1a1a;border:1px solid #333;border-radius:10px;padding:24px 28px;max-width:300px;width:90%;text-align:center}
h2{margin-bottom:8px;font-size:16px;color:#e0e0e0}
p{font-size:12px;color:#888}
</style>
</head>
<body>
<div class="card">
<h2>${title}</h2>
<p>${message}</p>
</div>
</body>
</html>`;
}

function isLocalhost(req: IncomingMessage): boolean {
	const addr = req.socket.remoteAddress;
	return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
}

function shutdown(): void {
	if (tsBin && tailscaleUrl) tailscaleServeOff(tsBin, TS_SERVE_PATH);
	server?.close();
	process.exit(0);
}

async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
	const parsedUrl = new URL(req.url ?? "/", `http://${req.headers.host}`);
	const url = parsedUrl.pathname;
	const method = req.method ?? "GET";

	// Localhost-only API (no token needed)
	if (isLocalhost(req)) {
		if (url === "/api/token" && method === "GET") {
			res.writeHead(200, { "Content-Type": "application/json" });
			res.end(JSON.stringify({ token: TOKEN }));
			return;
		}

		if (url === "/api/sessions" && method === "GET") {
			res.writeHead(200, { "Content-Type": "application/json" });
			res.end(JSON.stringify({ sessions: Array.from(sessions.values()) }));
			return;
		}

		if (url === "/api/sessions" && method === "POST") {
			const chunks: Buffer[] = [];
			for await (const chunk of req) chunks.push(chunk as Buffer);
			const body = JSON.parse(Buffer.concat(chunks).toString());
			const { sessionId, port, cwd } = body;
			sessions.set(sessionId, { sessionId, port, cwd, startedAt: new Date().toISOString() });
			res.writeHead(200, { "Content-Type": "application/json" });
			res.end(JSON.stringify({ ok: true }));
			return;
		}

		if (url.startsWith("/api/sessions/") && method === "DELETE") {
			const sessionId = url.slice("/api/sessions/".length);
			sessions.delete(sessionId);
			res.writeHead(200, { "Content-Type": "application/json" });
			res.end(JSON.stringify({ ok: true }));
			// Shut down if no sessions remain
			if (sessions.size === 0) setTimeout(shutdown, 500);
			return;
		}
	}

	// Token auth for web UI
	if (url === "/" && method === "GET") {
		const urlToken = parsedUrl.searchParams.get("token");
		if (urlToken !== TOKEN) {
			res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" });
			res.end(styledErrorPage("Access denied", "Invalid or missing access token."));
			return;
		}
		res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
		res.end(renderPage());
		return;
	}

	res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
	res.end(styledErrorPage("Not found", "This page does not exist."));
}

export function startDiscoveryService(): Promise<void> {
	return new Promise((resolve, reject) => {
		const httpServer = createServer(handleRequest);

		httpServer.listen(PORT, HOST, () => {
			server = httpServer;

			// Set up Tailscale
			tsBin = findTailscaleBin();
			if (tsBin) {
				tsHostname = getTailscaleHostname(tsBin);
				if (tsHostname) {
					const served = tailscaleServe(tsBin, PORT, TS_SERVE_PATH);
					if (served) {
						tailscaleUrl = `https://${tsHostname}`;
					}
				}
			}

			resolve();
		});

		httpServer.on("error", (err: NodeJS.ErrnoException) => {
			reject(err);
		});
	});
}
```

**Step 2: Create `src/discovery-main.ts`**

Entry point for the detached process:

```typescript
#!/usr/bin/env node
import { startDiscoveryService } from "./discovery.js";

startDiscoveryService().catch((err) => {
	process.stderr.write(`[discovery] failed to start: ${err}\n`);
	process.exit(1);
});
```

**Step 3: Build and verify**

```bash
npm run build:ts
```

**Step 4: Commit**

```bash
git add src/discovery.ts src/discovery-main.ts
git commit -m "feat: add discovery service with session registry and web UI"
```

---

### Task 3: Integrate discovery service into session startup/shutdown

**Files:**
- Modify: `src/index.ts`
- Modify: `src/server.ts`

**Step 1: Add discovery client helpers to `src/index.ts`**

Add functions to communicate with the discovery service:

```typescript
import { spawn } from "node:child_process";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

const DISCOVERY_PORT = 7008;
const DISCOVERY_URL = `http://127.0.0.1:${DISCOVERY_PORT}`;

async function fetchDiscovery(path: string, options?: RequestInit): Promise<Response | null> {
	try {
		return await fetch(`${DISCOVERY_URL}${path}`, options);
	} catch {
		return null;
	}
}

async function getDiscoveryToken(): Promise<string | null> {
	const res = await fetchDiscovery("/api/token");
	if (!res?.ok) return null;
	const { token } = await res.json();
	return token;
}

async function ensureDiscoveryService(): Promise<string> {
	// Try to reach existing discovery service
	let token = await getDiscoveryToken();
	if (token) return token;

	// Spawn discovery service as detached process
	const __dirname = dirname(fileURLToPath(import.meta.url));
	const entryPoint = join(__dirname, "discovery-main.js");
	const child = spawn(process.execPath, [entryPoint], {
		detached: true,
		stdio: "ignore",
	});
	child.unref();

	// Poll until ready (max 5 seconds)
	for (let i = 0; i < 50; i++) {
		await new Promise((r) => setTimeout(r, 100));
		token = await getDiscoveryToken();
		if (token) return token;
	}
	throw new Error("Discovery service failed to start within 5 seconds");
}

async function registerSession(sessionId: string, port: number, cwd: string): Promise<void> {
	await fetchDiscovery("/api/sessions", {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify({ sessionId, port, cwd }),
	});
}

async function deregisterSession(sessionId: string): Promise<void> {
	await fetchDiscovery(`/api/sessions/${sessionId}`, { method: "DELETE" });
}
```

**Step 2: Update `startRemote()` in `src/index.ts`**

Replace the token generation and Tailscale setup. The session still does its own Tailscale serve for `/pi/{sessionId}/`, but gets the token from the discovery service.

Key changes:
- Call `ensureDiscoveryService()` to get the shared token before starting the HTTP server
- Pass the shared token to `startServer()` instead of generating one
- Call `registerSession()` after the server is running
- Call `deregisterSession()` in cleanup/exit handlers
- The session still sets up its own Tailscale route at `/pi/{sessionId}/`

**Step 3: Update `src/server.ts`**

Change `ACCESS_TOKEN` from a generated constant to a mutable value set externally:

```typescript
// Replace: export const ACCESS_TOKEN = randomBytes(16).toString("hex");
// With:
let ACCESS_TOKEN = "";
export function setAccessToken(token: string): void { ACCESS_TOKEN = token; }
export function getAccessToken(): string { return ACCESS_TOKEN; }
```

Remove the `randomBytes` import if no longer needed.

**Step 4: Build and verify**

```bash
npm run build:ts
```

**Step 5: Commit**

```bash
git add src/index.ts src/server.ts
git commit -m "feat: integrate discovery service into session startup/shutdown"
```

---

### Task 4: Update extension widget to show discovery URL

**Files:**
- Modify: `extension/index.ts`

**Step 1: Update widget**

Add a `PI_REMOTE_DISCOVERY_URL` env var check and show the discovery URL in the widget:

```typescript
const discoveryUrl = process.env.PI_REMOTE_DISCOVERY_URL;
if (discoveryUrl) {
	contentLines.push("  \x1b[1;32mAll sessions:\x1b[0m " + discoveryUrl);
}
```

The discovery URL is set by `index.ts` when passing env to pi: `PI_REMOTE_DISCOVERY_URL: https://host.ts.net/pi/?token=...`

**Step 2: Commit**

```bash
git add extension/index.ts
git commit -m "feat: show discovery URL in TUI widget"
```

---

### Task 5: Update tsconfig and package.json

**Files:**
- Modify: `tsconfig.build.json` (if needed — new files should be auto-included)
- Modify: `package.json` — add `discovery-main.js` to bin or ensure it's in dist

**Step 1: Verify new files compile**

```bash
npm run build
```

**Step 2: Commit any config changes**

```bash
git add -A
git commit -m "chore: build config for discovery service"
```

---

### Task 6: Test end-to-end

1. Start first session: `/remote` → verify discovery service spawns, widget shows all-sessions URL
2. Start second session in another terminal: verify it gets the same token, registers
3. Open discovery URL in browser → verify card layout shows both sessions
4. Click a session card → verify it opens the terminal
5. Exit one session → verify its card disappears from discovery
6. Exit last session → verify discovery service shuts down and Tailscale route is cleaned up

---

### Task 7: Update README and publish

- Document discovery service in README
- Bump version to `0.3.0`
- Commit, tag, publish
