Skip to content

feat(terminal): detect listening ports of processes spawned in terminals and surface in UI #873

Description

@jamesbrink

Summary

When a user starts a dev server (Rails, Vite, Next, bun run dev, cargo run, etc.) in a Claudette terminal, Claudette should detect the listening port(s) the process opens and surface them in the UI with one-click access to the URL. Non-HTTP listeners (Postgres, Redis, gRPC, raw TCP, Unix sockets) should also be surfaced, just without the "Open in browser" affordance.

Today, the user opens a terminal, runs bin/dev, watches scrollback for the "Listening on http://127.0.0.1:3000" line, and copies the URL by hand. VS Code / Codespaces / GitHub Copilot's dev container terminals all auto-detect this and offer a Ports view; Claudette should match that ergonomic.

Prior art

  • VS Code / Codespaces "Ports" view: detects listeners spawned under each terminal's shell PID, lists <pid> <command> <port>, click to open / copy / forward. Confirmed via ChildProcessMonitor in vscode/src/vs/platform/terminal/node/childProcessMonitor.ts — walks the process tree from the shell PID, doesn't scan the whole host.
  • JetBrains Run/Debug "Service" tool window: lists listening ports for processes it launched.
  • iTerm2 / Warp: don't do this — opportunity to differentiate.

The "Copilot in a dev container" experience the user referenced is VS Code's automatic forwarding (remote.autoForwardPorts), which sits on top of the per-terminal port detection above.

Detection strategy (cross-platform)

We already have the right anchor: every PTY records its shell_pid (src-tauri/src/pty.rs:255) and spawns pty_tracker::spawn(pty_id, shell_pid, …) (src-tauri/src/pty.rs:260). A new port_tracker::spawn(pty_id, shell_pid, cancel, app) runs alongside it with the same lifetime.

The tracker polls every ~1.5s (configurable) and does two passes:

  1. Descend the process tree from shell_pid to a HashSet<i32> of descendant PIDs (the shell + everything it spawned, transitively).
  2. Enumerate listening sockets owned by those PIDs.

Diff against last poll → emit pty-port-detected / pty-port-released events. The frontend already has the listener-plumbing pattern (pty-command-detected in src/ui/src/App.tsx:558); we mirror it.

Per-platform implementation

Platform Process tree Listening sockets Notes
macOS ps -eo pid,ppid (or proc_listpids + proc_pidinfo) lsof -iTCP -sTCP:LISTEN -iUDP -n -P -F pcnPL parsed with pid filter, or -p pid1,pid2,… lsof -F is line-oriented machine output — much easier to parse than the default columnar form. -n -P skips DNS + service-name resolution.
Linux /proc/*/status (PPid field) /proc/<pid>/net/tcp + tcp6 + udp + udp6, cross-referenced with /proc/<pid>/fd/* socket inodes Pure file reads, no subprocess. Fall back to ss -tulnpH for environments where /proc is restricted (containers).
Windows Get-CimInstance Win32_Process (or CreateToolhelp32Snapshot) Get-NetTCPConnection -State Listen + Get-NetUDPEndpoint PowerShell is slow to spawn; prefer the IP Helper API (GetExtendedTcpTable / GetExtendedUdpTable) via the windows crate. This is the same approach netstat -ano uses internally.

All three return: { pid, protocol: tcp|udp|unix, addr, port }. Unix sockets (/proc/<pid>/net/unix on Linux, lsof -U on macOS) round out the "non-URL" case the user called out — gRPC servers, MCP sidecars, Docker socket re-exports, etc.

Scheme guessing (best-effort, not on the hot path)

When a new listener is detected on 127.0.0.1 / ::1 / 0.0.0.0, the frontend (not the Rust tracker) fires one probe:

  1. HTTP GET / Host: localhost:<port> with a 500ms timeout — if response status parseable → http (or https if TLS handshake succeeds first).
  2. Otherwise consult a known-port table: 5432 → postgres, 6379 → redis, 27017 → mongodb, 3306 → mysql, 9229 → node-inspect, 5900-5999 → vnc, etc.
  3. Otherwise just label as tcp.

Probes are debounced per (pid, port) and cached for the lifetime of the listener — we do not re-probe every poll.

Filtering / privacy

  • Default: only surface listeners bound to loopback (127.0.0.1, ::1) or wildcard (0.0.0.0, ::). Listeners bound to a specific LAN/public IP are still shown but flagged.
  • Ignore ports < 1024 by default (system services that the shell can't have spawned anyway, but a defense-in-depth filter against false positives from sudo-launched daemons).
  • Per-workspace ignore list (e.g., user runs Postgres permanently — they don't want it surfaced).
  • Setting to disable detection entirely (off would only be a privacy/perf escape hatch).

UI/UX

Four surfaces, each pulling from a single workspaceTerminalPorts: Record<workspaceId, Record<pty_id, Port[]>> slice that mirrors the existing workspaceTerminalCommands slice (src/ui/src/stores/slices/terminalSlice.ts:61):

1. Sidebar workspace row (primary surface)

Today the sidebar shows a "running command" pill under each workspace when a foreground command is active (pty-command-detected). When a port is detected for that PTY, the pill grows a clickable chip:

my-app  ⏵ bin/dev   🌐 :3000
                    └─ click: open http://localhost:3000 in default browser
                    └─ right-click: copy URL / copy host:port / pin to workspace

Multiple ports → multiple chips (🌐 :3000 📦 :5432). Non-HTTP listeners get a different glyph + tooltip ("Postgres listening on 5432 — copy as postgresql://localhost:5432").

2. Terminal pane overlay

A small footer bar in the active terminal pane lists ports owned by that specific PTY (not the whole workspace). Disambiguates which terminal opened which port when the user has two dev servers running. Hides itself when there are no ports.

3. Toast on first detection

First time a new (pid, port) is detected: a non-modal toast appears for ~6s — "Opened http://localhost:3000". Click → open. Dismiss → hidden but still in the sidebar. Per-port "don't auto-toast for this command again" pin.

4. Command palette entry

Cmd-P → "ports" → fuzzy list across all open workspaces. Lets remote-control / mobile sessions (where the sidebar may not be visible) reach the URL.

Settings (under Settings → Terminal, new "Detected ports" subsection)

  • Detect listening ports in terminals — master toggle (default on)
  • Show toast on new port — default on for HTTP, off for non-HTTP
  • Auto-open browser on new HTTP port — default off (opt-in, mirrors VS Code's conservative default)
  • Polling interval — 1s / 2s / 5s (default 2s)
  • Ignored ports — comma-separated free-text list
  • Show non-loopback listeners — default on but visually flagged

Events and data flow

pty.rs                          App.tsx                      Sidebar.tsx
─────────                       ─────                         ──────────
spawn shell ──► pty_tracker     listen("pty-port-detected")   render <PortChip />
            └─► port_tracker ──►   workspaceTerminalPorts ──►
                                listen("pty-port-released")

New events:

  • pty-port-detected — payload { pty_id, pid, command, protocol, addr, port, scheme_guess }
  • pty-port-released — payload { pty_id, pid, port }
  • pty-port-scheme-resolved — payload { pty_id, port, scheme } (frontend-emitted after probe, store-internal only)

Files touched (likely)

Rust

  • src-tauri/src/port_tracker.rs — NEW. Mirrors pty_tracker.rs shape: spawn(pty_id, shell_pid, cancel, app) + platform-gated enumerate_listeners(pids: &HashSet<i32>).
  • src-tauri/src/pty.rs — call port_tracker::spawn next to the existing pty_tracker::spawn at line 260; thread the same cancel.
  • src-tauri/src/commands/terminal.rs — new command terminal_get_detected_ports(pty_id) for cold-start sync (frontend reconnect after refresh).
  • Cargo.toml (src-tauri) — windows crate features for Win32::NetworkManagement::IpHelper.

Frontend

  • src/ui/src/stores/slices/terminalSlice.ts — add workspaceTerminalPorts map + setWorkspaceTerminalPorts / addDetectedPort / removeDetectedPort.
  • src/ui/src/App.tsx — wire two new listen() calls next to the existing pty-command listeners (~L557).
  • src/ui/src/components/sidebar/Sidebar.tsx — render port chips under the running-command pill.
  • src/ui/src/components/terminal/TerminalPanel.tsx — new <TerminalPortsOverlay /> child (separate file — TerminalPanel.tsx is already on the god-file list; keep the new behavior in its own component and slot it in).
  • src/ui/src/components/terminal/TerminalPortsOverlay.tsx — NEW.
  • src/ui/src/components/settings/sections/AppearanceSettings.tsx (or a new TerminalSettings section) — toggles.
  • src/ui/src/locales/en/settings.json + sibling locales — copy.

Docs

  • site/src/content/docs/features/port-detection.mdx — NEW (register in site/astro.config.mjs sidebar).
  • site/src/content/docs/features/settings.mdx — new rows in the Terminal settings table.

Tests

  • Rust unit tests for the parser of each platform's listener-enumeration output (lsof -F lines, /proc/net/tcp lines, Win32 row structs). No live network — fixture strings checked in.
  • Vitest test for the toast + sidebar chip rendering off a fake pty-port-detected event.

Acceptance criteria

  • Start python -m http.server 8765 in a Claudette terminal → within 3s, sidebar shows :8765 chip, terminal overlay lists localhost:8765 (http), click opens browser.
  • Stop the server (Ctrl-C) → chip disappears within one poll cycle.
  • Start two dev servers in two different terminals in the same workspace → both chips visible, terminal overlays each show only the port from that pane.
  • Start Postgres in a terminal (postgres -D ...) → chip rendered with postgres scheme glyph; right-click → "Copy connection string" yields postgresql://localhost:5432; no auto-toast or browser open.
  • Run nc -lU /tmp/foo.sock (Unix socket) → listed in terminal overlay as a non-network listener (no port, just path).
  • Auto-open setting off (default) → no browser launches on HTTP detection; toast still appears (when its setting is on).
  • Per-workspace ignore list — adding 5432 suppresses Postgres chip in that workspace but not in others.
  • Polling stops when the terminal closes (no orphaned port_tracker tasks; verify via tracing spans).
  • Cold start: closing/reopening the Claudette window restores ports from the snapshot returned by terminal_get_detected_ports.
  • Windows: works against a bun run dev terminal launched from PowerShell — no PowerShell spawned per poll (uses Win32 IP Helper API).
  • Linux without /proc/<pid>/net/tcp access (restricted container) → falls back to ss -tulnp and still produces results.
  • No regression on the existing pty-command-detected / sidebar running-command pill — port chips render alongside, not in place of, the command pill.

Open design questions

  • Forwarding for remote / mobile sessions. Today Claudette's mobile and WSS clients can drive the desktop; if they detect a port on the desktop, opening http://localhost:3000 on the phone won't work. Options: (a) just show the URL and let the user copy it, (b) tunnel via the existing WSS transport, (c) defer. Recommend (a) for MVP — tunneling is its own multi-PR project.
  • Should the chip show the actual binding (0.0.0.0:3000 vs 127.0.0.1:3000)? Useful for debugging "why can't my phone reach this" but visual noise for the 99% case. Suggest: show bare port; show full binding in tooltip.
  • Pi SDK / Codex Native agent backends spawn their own subprocesses. Should port detection extend to ports opened by agent-spawned processes (not just user-typed shell commands)? Probably yes — same anchor (the agent's PID is tracked). Leave for a follow-up issue once this lands for terminals.

References

  • Existing PTY foreground-process tracker: src-tauri/src/pty_tracker.rs (the architectural twin of this proposal).
  • Shell PID capture: src-tauri/src/pty.rs:255.
  • Existing pty-command-detected consumer: src/ui/src/App.tsx:558 (UI plumbing to mirror).
  • Existing per-workspace running-command slice: src/ui/src/stores/slices/terminalSlice.ts:61.
  • VS Code child-process monitor (descend-from-shell pattern): vscode/src/vs/platform/terminal/node/childProcessMonitor.ts.
  • macOS lsof -F machine-readable format: man lsof, section "OUTPUT FOR OTHER PROGRAMS".
  • Linux /proc/<pid>/net/tcp format: man 5 proc.
  • Windows IP Helper: GetExtendedTcpTable / GetExtendedUdpTable (no admin needed for own-process listeners).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions