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
61 changes: 61 additions & 0 deletions docs/adrs/024.client.focus-existing-tab.md
Original file line number Diff line number Diff line change
@@ -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 <id>` 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 <id>` 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/<id>` 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 <id>` 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).
174 changes: 174 additions & 0 deletions docs/specs/deep-link.md
Original file line number Diff line number Diff line change
@@ -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 <id>` 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/<pid>` — 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 <id>` 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=<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/<id>`.

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/<id>` 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 <url>` 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/<pid>` — server resolves the PTY PID to a session and responds with `302 Location: /s/<id>`; 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 <id>` 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/<pid>
```

1. Parse `<pid>` 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/<id>` — 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/<id>?cols=80&rows=24` |
| Jump by session ID | `open http://127.0.0.1:2346/s/<id>` |
| Jump by PTY PID | `open http://127.0.0.1:2346/p/<pid>` — server responds with `302 Location: /s/<id>` 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.
1 change: 1 addition & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
1 change: 1 addition & 0 deletions src/pty/bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function spawn(
});

return {
pid: proc.pid,
onData(cb) {
onDataCb = cb;
},
Expand Down
1 change: 1 addition & 0 deletions src/pty/node-pty.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/pty/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function spawn(
});

return {
pid: ptyProc.pid,
onData(cb) {
ptyProc.onData(cb);
},
Expand Down
2 changes: 2 additions & 0 deletions src/pty/types.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down
19 changes: 19 additions & 0 deletions src/server/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions src/server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions src/server/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
9 changes: 7 additions & 2 deletions src/server/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Loading
Loading