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:
- Descend the process tree from
shell_pid to a HashSet<i32> of descendant PIDs (the shell + everything it spawned, transitively).
- 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:
HTTP GET / Host: localhost:<port> with a 500ms timeout — if response status parseable → http (or https if TLS handshake succeeds first).
- Otherwise consult a known-port table: 5432 →
postgres, 6379 → redis, 27017 → mongodb, 3306 → mysql, 9229 → node-inspect, 5900-5999 → vnc, etc.
- 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
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).
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
<pid> <command> <port>, click to open / copy / forward. Confirmed viaChildProcessMonitorinvscode/src/vs/platform/terminal/node/childProcessMonitor.ts— walks the process tree from the shell PID, doesn't scan the whole host.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 spawnspty_tracker::spawn(pty_id, shell_pid, …)(src-tauri/src/pty.rs:260). A newport_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:
shell_pidto aHashSet<i32>of descendant PIDs (the shell + everything it spawned, transitively).Diff against last poll → emit
pty-port-detected/pty-port-releasedevents. The frontend already has the listener-plumbing pattern (pty-command-detectedinsrc/ui/src/App.tsx:558); we mirror it.Per-platform implementation
ps -eo pid,ppid(orproc_listpids+proc_pidinfo)lsof -iTCP -sTCP:LISTEN -iUDP -n -P -F pcnPLparsed withpidfilter, or-p pid1,pid2,…lsof -Fis line-oriented machine output — much easier to parse than the default columnar form.-n -Pskips DNS + service-name resolution./proc/*/status(PPidfield)/proc/<pid>/net/tcp+tcp6+udp+udp6, cross-referenced with/proc/<pid>/fd/*socket inodesss -tulnpHfor environments where/procis restricted (containers).Get-CimInstance Win32_Process(orCreateToolhelp32Snapshot)Get-NetTCPConnection -State Listen+Get-NetUDPEndpointGetExtendedTcpTable/GetExtendedUdpTable) via thewindowscrate. This is the same approachnetstat -anouses internally.All three return:
{ pid, protocol: tcp|udp|unix, addr, port }. Unix sockets (/proc/<pid>/net/unixon Linux,lsof -Uon 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:HTTP GET / Host: localhost:<port>with a 500ms timeout — if response status parseable →http(orhttpsif TLS handshake succeeds first).postgres, 6379 →redis, 27017 →mongodb, 3306 →mysql, 9229 →node-inspect, 5900-5999 →vnc, etc.tcp.Probes are debounced per
(pid, port)and cached for the lifetime of the listener — we do not re-probe every poll.Filtering / privacy
127.0.0.1,::1) or wildcard (0.0.0.0,::). Listeners bound to a specific LAN/public IP are still shown but flagged.sudo-launched daemons).UI/UX
Four surfaces, each pulling from a single
workspaceTerminalPorts: Record<workspaceId, Record<pty_id, Port[]>>slice that mirrors the existingworkspaceTerminalCommandsslice (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:Multiple ports → multiple chips (
🌐 :3000 📦 :5432). Non-HTTP listeners get a different glyph + tooltip ("Postgres listening on 5432 — copy aspostgresql://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-HTTPAuto-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 listShow non-loopback listeners— default on but visually flaggedEvents and data flow
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. Mirrorspty_tracker.rsshape:spawn(pty_id, shell_pid, cancel, app)+ platform-gatedenumerate_listeners(pids: &HashSet<i32>).src-tauri/src/pty.rs— callport_tracker::spawnnext to the existingpty_tracker::spawnat line 260; thread the samecancel.src-tauri/src/commands/terminal.rs— new commandterminal_get_detected_ports(pty_id)for cold-start sync (frontend reconnect after refresh).Cargo.toml(src-tauri) —windowscrate features forWin32::NetworkManagement::IpHelper.Frontend
src/ui/src/stores/slices/terminalSlice.ts— addworkspaceTerminalPortsmap +setWorkspaceTerminalPorts/addDetectedPort/removeDetectedPort.src/ui/src/App.tsx— wire two newlisten()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.tsxis 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 insite/astro.config.mjssidebar).site/src/content/docs/features/settings.mdx— new rows in the Terminal settings table.Tests
-Flines,/proc/net/tcplines, Win32 row structs). No live network — fixture strings checked in.pty-port-detectedevent.Acceptance criteria
python -m http.server 8765in a Claudette terminal → within 3s, sidebar shows:8765chip, terminal overlay listslocalhost:8765 (http), click opens browser.Ctrl-C) → chip disappears within one poll cycle.postgres -D ...) → chip rendered withpostgresscheme glyph; right-click → "Copy connection string" yieldspostgresql://localhost:5432; no auto-toast or browser open.nc -lU /tmp/foo.sock(Unix socket) → listed in terminal overlay as a non-network listener (no port, just path).5432suppresses Postgres chip in that workspace but not in others.port_trackertasks; verify viatracingspans).terminal_get_detected_ports.bun run devterminal launched from PowerShell — no PowerShell spawned per poll (uses Win32 IP Helper API)./proc/<pid>/net/tcpaccess (restricted container) → falls back toss -tulnpand still produces results.pty-command-detected/ sidebar running-command pill — port chips render alongside, not in place of, the command pill.Open design questions
http://localhost:3000on 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.0.0.0.0:3000vs127.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.References
src-tauri/src/pty_tracker.rs(the architectural twin of this proposal).src-tauri/src/pty.rs:255.pty-command-detectedconsumer:src/ui/src/App.tsx:558(UI plumbing to mirror).src/ui/src/stores/slices/terminalSlice.ts:61.vscode/src/vs/platform/terminal/node/childProcessMonitor.ts.lsof -Fmachine-readable format:man lsof, section "OUTPUT FOR OTHER PROGRAMS"./proc/<pid>/net/tcpformat:man 5 proc.GetExtendedTcpTable/GetExtendedUdpTable(no admin needed for own-process listeners).