diff --git a/docs/adrs/024.client.focus-existing-tab.md b/docs/adrs/024.client.focus-existing-tab.md new file mode 100644 index 0000000..db437af --- /dev/null +++ b/docs/adrs/024.client.focus-existing-tab.md @@ -0,0 +1,61 @@ +# ADR 024: Client — Focus existing tab via BroadcastChannel (dropped) + +**SPEC:** [deep-link](../specs/deep-link.md) +**Status:** Rejected +**Date:** 2026-04-06 + +--- + +## Context + +`webtty go ` opens a new browser tab every time. If a tab for that session is already open, the user ends up with two tabs attached to the same PTY. The goal was to detect the duplicate and focus the existing tab instead. + +The only same-origin mechanism available without a native helper is `BroadcastChannel`: the new tab posts a `focus-request`; the existing tab calls `window.focus()` and replies with `focus-ack`; the new tab skips mounting a terminal and shows a fallback UI. + +--- + +## Decision + +Do not implement BroadcastChannel focus handshake. `webtty go ` continues to open a new browser tab unconditionally. + +--- + +## Reasons + +### `window.focus()` cannot switch tabs on macOS + +Browsers block tab-switching from JavaScript without a direct user gesture. `window.focus()` raises the browser *window* to the front, but it does not switch to the tab that called it. The existing tab remains unfocused. The user still has to manually find and click it. + +### `window.close()` is blocked for non-script-opened tabs + +The new (duplicate) tab could show a "Session already open" message and close itself. But `window.close()` is only permitted for windows opened via `window.open()`. Tabs opened by the OS `open` command or by the user directly cannot self-close. The fallback UI is therefore a dead end: the user sees an unhelpful message in a tab they cannot close programmatically. + +### Net result is worse UX than doing nothing + +With the handshake: two tabs open, one shows a blank "Session already open" message with no action the user can take. Without the handshake: two tabs open, both show a live terminal — the user can at least close the unwanted one manually. + +--- + +## Considered Options + +### Option A: BroadcastChannel focus handshake (rejected — described above) + +### Option B: Skip `openBrowser` if session is `connected` + +`GET /api/sessions/` returns `connected: true` when a WebSocket client is attached (i.e. a browser tab is open). The CLI could skip calling `openBrowser` and print the URL instead. + +Rejected for now — `connected` is a proxy for "tab is open" but is not exact: a session can be `connected: false` between reconnects, or `connected: true` from a programmatic WebSocket client that is not a browser tab. The heuristic would produce false negatives (no browser opened when it should be). + +### Option C: Native helper / URL scheme + +A `webtty://` URL scheme registered via a native shim (Electron or Swift) could receive the open request and route to the existing tab. This is the only technically sound solution. + +Deferred — webtty is an npm CLI with no native bundle. The infrastructure cost is not justified at this stage. + +--- + +## Consequences + +- `webtty go ` always opens a new browser tab. Duplicate tabs are the user's responsibility to close. +- The BroadcastChannel code is removed from `src/client/index.ts`. +- Future tab-focus support requires a native helper (Option C above). diff --git a/docs/specs/deep-link.md b/docs/specs/deep-link.md new file mode 100644 index 0000000..b94d6ae --- /dev/null +++ b/docs/specs/deep-link.md @@ -0,0 +1,174 @@ +# SPEC: Deep Link + +**Last Updated:** 2026-04-06 (amended: focus-existing-tab dropped — see ADR 024) + +--- + +## Scope and Plan + +### Problem + +Two related problems: + +1. **Duplicate tabs** — `webtty go ` opens a new browser tab every time. If the session is already open, the user ends up with two identical tabs. *(Focus-existing-tab was attempted via BroadcastChannel but dropped — see ADR 024.)* +2. **No PID-based navigation** — Third-party tools (e.g. Vibe Island) track AI agent processes by PTY shell PID, not by session name. They have no way to map a PID to a webtty session or navigate directly to it. + +### What this spec covers + +| Area | Change | +|------|--------| +| ~~Client~~ | ~~BroadcastChannel focus handshake~~ — dropped, see ADR 024 | +| Server API | Expose `pid` in `GET /api/sessions` response | +| Server routing | `GET /p/` — redirect to the session that owns that PTY PID | + +### Out of scope + +- `webtty://` custom URL scheme — requires a native app bundle (`Info.plist`). webtty is an npm CLI with no bundle. Documented as a future path in the [3rd Party Integration](#3rd-party-integration) section. +- CLI changes — `webtty go ` is unchanged. Focus logic is entirely client-side. +- SSE event stream — out of scope for this spec; the WebSocket already serves real-time output. + +--- + +## Inspection + +> Research findings that inform the design decisions above. + +### How Vibe Island works + +[Vibe Island](https://vibeisland.app) is a native macOS app that sits in the notch and monitors AI coding agents (Claude Code, OpenCode, Gemini CLI, Cursor, etc.). When a task completes, it sends a macOS notification. Clicking it jumps to the exact terminal tab where the agent ran. + +The integration model varies by tool: + +| Tool | How Vibe Island connects | How "jump" works | +|------|--------------------------|------------------| +| Claude Code | Hook entries written to `~/.claude/settings.json`; local Unix socket bridge | PID matching via macOS Accessibility API | +| Cursor | Hook entries written to `~/.cursor/hooks.json` | VSIX extension receives `cursor://vibeisland/jump?pid=`, walks `vscode.window.terminals`, matches `terminal.processId` | +| Gemini CLI | Hook entries written to `~/.gemini/settings.json`; Unix socket bridge | PID matching | +| OpenCode | HTTP SSE event stream — no hook injection needed | PID matching + port discovery | + +### The PID-based jump mechanism (VS Code/Cursor VSIX) + +Vibe Island's Cursor/VS Code extension: + +```js +vscode.window.registerUriHandler({ + async handleUri(uri) { + const params = new URLSearchParams(uri.query); + const pids = params.getAll('pid').map(p => parseInt(p, 10)); + + for (const terminal of vscode.window.terminals) { + const termPid = await terminal.processId; + if (pids.includes(termPid)) { + terminal.show(false); // focus the tab + return; + } + } + } +}); +``` + +Vibe Island knows the **shell PID** of each agent process from its monitoring hooks. On jump, it opens `cursor://vibeisland/jump?pid=12345`. The extension walks all open terminals, matches the PTY shell PID, and focuses the right one. + +### The gap for webtty + +webtty's current `GET /api/sessions` response is: + +```json +[{ "id": "main", "createdAt": 1700000000000, "connected": true }] +``` + +The PTY PID is never exposed. Vibe Island cannot map a shell PID to a webtty session, and therefore cannot construct the jump URL `http://127.0.0.1:PORT/s/`. + +Both PTY backends already have the PID available: +- **node-pty**: `ptyProc.pid` (property on `IPty`) +- **Bun**: `proc.pid` (property on `Bun.spawn` result) + +It just needs to be surfaced through `PtyProcess`, `Session`, and `sessionToJson`. + +### Port discovery + +Vibe Island discovers running OpenCode instances via "multi-layer port discovery" (their phrasing). OpenCode's HTTP server starts automatically as part of normal operation — there is no separate `serve` command to opt into. The exact discovery mechanism is not published, but the likeliest approach is trying a known default port then falling back to a port range scan. webtty uses a fixed default port (`2346`) and respects the `PORT` env var — the same convention works with port-scan-based discovery. + +### BroadcastChannel (focus-existing-tab) + +The browser cannot focus an existing tab from outside. The only same-origin mechanism is `BroadcastChannel`: a new tab loading `/s/` posts a focus-request; the existing tab for that session receives it, calls `window.focus()`, and acks; the new tab shows a fallback UI instead of mounting a second terminal to the same PTY. + +Constraints: +- `window.close()` is blocked for tabs not opened by script — the new tab cannot self-close when opened via `open ` on macOS. Show a "Session already open in another tab" message instead. +- `window.focus()` on macOS raises the window but browser security policy may not switch tabs without a user gesture. Best-effort — no workaround without a native helper. + +### macOS URL scheme — future path + +iTerm2 registers `iterm2://` via `CFBundleURLTypes` in `Info.plist`. VS Code registers `vscode://` the same way. Both are native app bundles. + +webtty is an npm CLI — no `Info.plist`, no bundle. A `webtty://` scheme would require a small native shim (Electron or Swift) that registers the protocol and proxies to the local server. This would unlock notification-click → open-webtty without going through a browser URL bar. Deferred — the `http://` URL is sufficient for now. + +--- + +## Features + +| Feature | Description | ADR | Done? | +|---------|-------------|-----|-------| +| ~~Focus existing tab~~ | ~~BroadcastChannel handshake~~ | [ADR 024](../adrs/024.client.focus-existing-tab.md) | ❌ dropped | +| PID in session API | `GET /api/sessions` includes `pid: number \| null` per session (null before first WS connection spawns the PTY) | — | ✅ | +| PID-based navigation | `GET /p/` — server resolves the PTY PID to a session and responds with `302 Location: /s/`; 404 if no match | — | ✅ | + +### Focus existing tab — detail + +**Status: dropped. See [ADR 024](../adrs/024.client.focus-existing-tab.md).** + +`window.focus()` on macOS raises the browser window but cannot switch tabs without a user gesture — a hard browser security constraint. `window.close()` is blocked for tabs not opened by script. The BroadcastChannel handshake was implemented but removed: the new tab cannot meaningfully self-close or pull focus to the existing tab. `webtty go ` continues to open a new browser tab unconditionally. + +### PID in session API — detail + +**`PtyProcess` interface** (`src/pty/types.ts`): add `pid: number`. + +**Backends**: +- `src/pty/node.ts`: return `pid: ptyProc.pid` +- `src/pty/bun.ts`: return `pid: proc.pid` + +**`Session`** (`src/server/session.ts`): no change to the Session struct — `pty.pid` is read directly from the PtyProcess when serializing. + +**`sessionToJson`**: include `pid: s.pty?.pid ?? null`. + +**API response** (updated shape): + +```json +[{ "id": "main", "createdAt": 1700000000000, "connected": true, "pid": 12345 }] +``` + +`pid` is `null` if the PTY has not been spawned yet (session created but no WebSocket client has connected). + +### PID-based navigation — detail + +**New server route** (`src/server/routes.ts`): + +``` +GET /p/ +``` + +1. Parse `` as integer; return 404 if not a valid positive integer +2. Walk `sessionRegistry`, find the session where `session.pty?.pid === pid` +3. If found: `302 Location: /s/` — browser lands at the canonical session URL +4. If not found: 404 + +This is the URL Vibe Island (or any tool) opens to jump to a webtty session by PID: + +``` +open http://127.0.0.1:2346/p/12345 +``` + +### 3rd Party Integration + +With the above three features in place, tools like Vibe Island can integrate with webtty with no changes on their side beyond recognising webtty as a target: + +| Action | How | +|--------|-----| +| Discover running webtty | `GET http://127.0.0.1:2346/api/sessions` — 200 + JSON array means running | +| List sessions with PIDs | Same endpoint — returns `[{ id, createdAt, connected, pid }]` | +| Watch session output | WebSocket `ws://127.0.0.1:2346/ws/?cols=80&rows=24` | +| Jump by session ID | `open http://127.0.0.1:2346/s/` | +| Jump by PTY PID | `open http://127.0.0.1:2346/p/` — server responds with `302 Location: /s/` for the matching session | +| Custom port | Respect `PORT` env var; default `2346` | + +**No Unix socket bridge, no config file injection, no hook setup.** The REST API + PID-based navigation is the complete integration surface. diff --git a/src/client/index.ts b/src/client/index.ts index 8cef13b..b5d7649 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -46,6 +46,7 @@ interface ClientConfig { } const sessionId = window.location.pathname.split('/s/')[1] ?? 'main'; + const config: ClientConfig = await fetch('/api/config').then((r) => r.json()); document.title = `${sessionId} | webtty`; diff --git a/src/pty/bun.ts b/src/pty/bun.ts index 7330c1a..cf5ced7 100644 --- a/src/pty/bun.ts +++ b/src/pty/bun.ts @@ -38,6 +38,7 @@ export function spawn( }); return { + pid: proc.pid, onData(cb) { onDataCb = cb; }, diff --git a/src/pty/node-pty.d.ts b/src/pty/node-pty.d.ts index 9ae325b..0abe9ff 100644 --- a/src/pty/node-pty.d.ts +++ b/src/pty/node-pty.d.ts @@ -15,6 +15,7 @@ */ declare module '@lydell/node-pty' { interface IPty { + pid: number; onData(cb: (data: string) => void): void; onExit(cb: (e: { exitCode: number; signal?: number }) => void): void; write(data: string): void; diff --git a/src/pty/node.ts b/src/pty/node.ts index f6c0ebd..f5fe706 100644 --- a/src/pty/node.ts +++ b/src/pty/node.ts @@ -28,6 +28,7 @@ export function spawn( }); return { + pid: ptyProc.pid, onData(cb) { ptyProc.onData(cb); }, diff --git a/src/pty/types.ts b/src/pty/types.ts index 1aefa7e..739353a 100644 --- a/src/pty/types.ts +++ b/src/pty/types.ts @@ -1,5 +1,7 @@ /** Minimal abstraction over a running PTY process. Implemented by both the Bun and node-pty backends. */ export interface PtyProcess { + /** OS process ID of the shell spawned inside the PTY. */ + pid: number; /** Register a callback that receives raw UTF-8 output from the PTY. */ onData(cb: (data: string) => void): void; /** Register a callback invoked when the child process exits. */ diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index d78c05a..496e488 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -89,6 +89,15 @@ describe('server — routes', () => { expect(Array.isArray(body)).toBe(true); }); + test('GET /api/sessions includes pid field (null before PTY spawns)', async () => { + const res = await fetch(`${baseUrl}/api/sessions`); + const body = (await res.json()) as Array<{ id: string; pid: number | null }>; + expect(body.length).toBeGreaterThan(0); + for (const s of body) { + expect('pid' in s).toBe(true); + } + }); + test('POST /api/sessions creates session with given id', async () => { const res = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', @@ -176,6 +185,16 @@ describe('server — routes', () => { expect(res.status).toBe(404); }); + test('GET /p/:pid returns 404 for unknown pid', async () => { + const res = await fetch(`${baseUrl}/p/99999999`, { redirect: 'manual' }); + expect(res.status).toBe(404); + }); + + test('GET /p/:pid returns 404 for non-numeric pid', async () => { + const res = await fetch(`${baseUrl}/p/notanumber`, { redirect: 'manual' }); + expect(res.status).toBe(404); + }); + test('POST /api/server/stop returns 200 and stops server', async () => { const res = await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }); expect(res.status).toBe(200); diff --git a/src/server/routes.ts b/src/server/routes.ts index 4def51a..3da4a41 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -244,6 +244,25 @@ export async function handleRequest( return; } + const pidMatch = pathname.match(/^\/p\/(\d+)$/); + if (req.method === 'GET' && pidMatch) { + const pid = parseInt(pidMatch[1], 10); + if (!Number.isFinite(pid) || pid <= 0) { + res.writeHead(404); + res.end('Not Found'); + return; + } + const session = [...sessionRegistry.values()].find((s) => s.pty?.pid === pid); + if (!session) { + res.writeHead(404); + res.end('Not Found'); + return; + } + res.writeHead(302, { Location: `/s/${session.id}` }); + res.end(); + return; + } + if (pathname.startsWith('/dist/')) { const relativePath = pathname.slice(6); const ownFile = path.resolve(clientDistPath, relativePath); diff --git a/src/server/session.test.ts b/src/server/session.test.ts index 4405594..9fa9077 100644 --- a/src/server/session.test.ts +++ b/src/server/session.test.ts @@ -90,6 +90,17 @@ describe('sessionToJson', () => { expect(json.id).toBe('test'); expect(typeof json.createdAt).toBe('number'); }); + + test('pid is null when pty is not yet spawned', () => { + const session = createSession('test-pid-null'); + expect(sessionToJson(session).pid).toBeNull(); + }); + + test('pid reflects pty pid when pty is set', () => { + const session = createSession('test-pid-set'); + session.pty = { pid: 12345 } as never; + expect(sessionToJson(session).pid).toBe(12345); + }); }); describe('setLastUsedId', () => { diff --git a/src/server/session.ts b/src/server/session.ts index 466b8a2..6fabaff 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -71,8 +71,13 @@ export function createSession(id: string): Session { * Returns a plain JSON-safe representation of a session for API responses. * * @param s - The session to serialize. - * @returns A JSON-safe object with session ID, creation timestamp, and connection status. + * @returns A JSON-safe object with session ID, creation timestamp, connection status, and PTY PID (or `null` if no PTY has been spawned yet). */ export function sessionToJson(s: Session) { - return { id: s.id, createdAt: s.createdAt, connected: s.clients.size > 0 }; + return { + id: s.id, + createdAt: s.createdAt, + connected: s.clients.size > 0, + pid: s.pty?.pid ?? null, + }; } diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index fd60510..e6c1202 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -193,10 +193,10 @@ describe('websocket', () => { const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-resize?cols=80&rows=24`); await waitForMessages(messages, 1); + await waitForPrompt(messages); ws.send(JSON.stringify({ type: 'resize', cols: 120, rows: 40 })); - await waitForPrompt(messages); ws.send('echo resize-ok\n'); await waitForContent(messages, 'resize-ok'); await closeWs(ws); @@ -204,6 +204,30 @@ describe('websocket', () => { expect(messages.join('')).toContain('resize-ok'); }); + test('GET /p/:pid redirects to session URL after PTY spawns', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'ws-test-pid-route' }), + }); + + const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-pid-route?cols=80&rows=24`); + await waitForMessages(messages, 1); + await closeWs(ws); + + const sessions = (await fetch(`${baseUrl}/api/sessions`).then((r) => r.json())) as Array<{ + id: string; + pid: number | null; + }>; + const session = sessions.find((s) => s.id === 'ws-test-pid-route'); + expect(session).toBeDefined(); + expect(typeof session!.pid).toBe('number'); + + const res = await fetch(`${baseUrl}/p/${session!.pid}`, { redirect: 'manual' }); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('/s/ws-test-pid-route'); + }); + test('server shuts down when last session exits', async () => { await fetch(`${baseUrl}/api/sessions`, { method: 'POST',