Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3f84fb4
Report each dev server's localhost and network URLs, and how to expos…
enso-works Oct 23, 2026
4304ec2
Run the Python tests in CI before building
enso-works Oct 23, 2026
54af45f
Show a QR code and network URL for dev servers in the menu bar app
enso-works Oct 23, 2026
7dcc7b9
Add QR code screenshots from demo data
enso-works Oct 23, 2026
8b59bf7
Document server URLs and QR codes
enso-works Oct 23, 2026
9262067
Release 0.5.0
enso-works Oct 23, 2026
25cc5dc
Give Storybook, Rsbuild and webpack a --host 0.0.0.0 hint, since thei…
enso-works Oct 23, 2026
2023351
Don't list Storybook among servers that listen on localhost by default
enso-works Oct 23, 2026
597a6c8
Link the article on opening localhost from a phone
enso-works Oct 23, 2026
a939539
Scan git worktrees for changes, merged branches and age, and remove t…
enso-works Oct 23, 2026
741df3c
Send worktrees from the bridge once a minute, with demo data that nev…
enso-works Oct 23, 2026
8442234
Add a Worktrees page, a project menu entry and a Needs you item for m…
enso-works Oct 23, 2026
0d30a65
Add the Worktrees screenshot and refresh the popover screenshots
enso-works Oct 23, 2026
411666a
Document worktrees
enso-works Oct 23, 2026
a64cf6c
Release 0.6.0
enso-works Oct 23, 2026
b729581
Leave the folder off the main checkout's row, the repository header s…
enso-works Oct 23, 2026
cba979a
Link the article on removing git worktrees
enso-works Oct 23, 2026
b1c7332
Retry stapler validate too, it hits the same flaky CloudKit lookup as…
enso-works Oct 23, 2026
00a37b1
Merge branch 'server-urls' into worktrees
enso-works Oct 23, 2026
c11b5d4
Retry hdiutil create, which fails now and then on CI runners
enso-works Oct 23, 2026
06e205b
Merge branch 'server-urls' into worktrees
enso-works Oct 23, 2026
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
7 changes: 7 additions & 0 deletions .github/workflows/macos-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ on:
paths:
- "macos/**"
- "devdash/**"
- "tests/**"
- "CHANGELOG.md"
- ".github/workflows/macos-app.yml"
pull_request:
paths:
- "macos/**"
- "devdash/**"
- "tests/**"
- "CHANGELOG.md"
- ".github/workflows/macos-app.yml"
workflow_dispatch:
Expand All @@ -36,6 +38,11 @@ jobs:
with:
fetch-depth: 0

- name: Python tests
run: |
python3 -m pip install --quiet psutil pytest
python3 -m pytest -q tests

- name: Check version and release notes
id: check
run: |
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Changelog

## 0.6.0

### Added
- **Worktrees**: a page with every linked git worktree of the repositories devdash knows about (projects with something running and the Claude Code projects of every account), with the branch, who created it, when it was last touched, its size, and whether it's running, has changes, is merged, or is stale. Open it from the footer or from **Worktrees** in a project's menu.
- Remove a worktree from its row: `git worktree remove`, `git worktree prune`, and `git branch -d` for merged branches. Worktrees with changes, with a server or Claude session running in them, and the main checkout can't be removed.
- Branches count as merged when they are merged into the default branch or their remote branch is gone, so squash-merged pull requests are caught too.
- Needs you lists merged worktrees nothing has touched for a day.
- Projects that are linked worktrees get a stacked icon on their branch chip in Running.
- `devdash --serve` sends a `worktrees` list when it changes (scanned once a minute) and has `refresh_worktrees`, `worktree_size` and `remove_worktree` commands.

## 0.5.0

### Added
- **Server URLs and QR codes**: the QR button on a dev server, or Show QR Code in its menu, opens a page with the server's network URL as a QR code, so a phone on the same Wi-Fi opens it in one scan. It also lists the localhost and network URLs with copy and open buttons, and a port picker for servers with several ports.
- Servers that listen on localhost only say "Only this Mac can open it" and show how to fix that for their runtime, such as `--host` for Vite or `--host 0.0.0.0` for uvicorn.
- Server menus have Copy Network URL and Show QR Code. Option-click a port chip to copy its URL.
- `devdash --serve` reports each server's `urls` (port, localhost URL, network URL and whether other devices can reach it), an `expose_hint`, and the Mac's `lan_ip`.

### Changed
- Port chips, Open and Copy URL use https for servers that speak TLS, such as Vite with `--https`.

## 0.4.0

### Added
Expand Down
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ See and manage your dev servers, Docker containers and Claude Code sessions from
## Features

- **Dev servers**: every process listening on a port, in any language (Node, Bun, Deno, Python, Ruby, Go, Rust and more), with its memory, CPU and the project it belongs to. Local databases such as Homebrew Postgres show up as services.
- **Open on your phone**: each dev server's network URL and a QR code, so a phone on the same Wi-Fi opens it in one scan. Servers only this Mac can reach say so, with the flag that fixes it. See [Server URLs and QR codes](#server-urls-and-qr-codes).
- **Git worktrees**: every linked worktree of the repositories you work in, such as the ones Claude Code creates, with uncommitted changes, merged branches and what runs in them. Remove the merged ones in one click. See [Worktrees](#worktrees).
- **Docker**: containers grouped by Compose stack, with status, health and ports.
- **Cleanup**: flags idle, orphaned and zombie processes and long-running containers, and lets you remove them in one click.
- **Disk tree**: a treemap of your home folder that shows what takes up space and suggests what you can safely reclaim.
Expand Down Expand Up @@ -77,8 +79,10 @@ What you can do:

- **Needs you**: one list at the top for anything that wants your attention: cleanup suggestions, low disk space, disk space worth a look, and plan limits over 80%. Dismiss an item and it stays hidden until it changes. The list disappears when there is nothing to show.
- **Running**: dev servers, Docker containers and Claude sessions, grouped by project (the git repository they run in). Each project header shows the branch and has a menu to open the project in your editor, Finder or Terminal, start a Claude session there, or stop everything in it. Services, servers outside any project and Node processes without a port are grouped at the bottom. Choose **Type** next to the filter to group by dev servers, Compose stacks, Claude sessions, services and background processes instead.
- Click a port chip to open `localhost:<port>`. Hover a row to open it in your editor or kill it (click twice to confirm). Right-click any row for all its actions.
- Click a port chip to open `localhost:<port>` (https when the server speaks it), or Option-click to copy the URL. Hover a row to open it in your editor or kill it (click twice to confirm). Right-click any row for all its actions.
- Click a server for process details: command, children, network connections, open files and environment. Click a container to stream its logs in a separate window, with filtering, follow mode and copy.
- **Worktrees**: the footer button lists every linked git worktree, and Needs you shows merged ones nothing has touched for a day. See [Worktrees](#worktrees).
- **Server URLs**: right-click a server for Copy URL, Copy Network URL and Show QR Code, or hover it and click the QR button. See [Server URLs and QR codes](#server-urls-and-qr-codes).
- **System**: CPU, memory, disk, swap and network, the Disk tree summary, and the top processes sorted by memory or CPU. The header shows CPU and memory on every tab; click it to open this tab.
- **Claude**:
- plan limits and API-equivalent cost;
Expand All @@ -91,6 +95,34 @@ What you can do:
- **Notifications**: a macOS notification when a dev server starts or exits, a container stops, or a watched port comes up.
- **Settings**: launch at login, editor (VS Code or Cursor), whether the menu bar shows the count and usage percentage, notifications, and a custom `devdash` path.

### Server URLs and QR codes

| Open on your phone | Localhost only |
|---|---|
| ![QR code for a dev server](screenshots/app/qrcode.png) | ![A server only this Mac can reach](screenshots/app/qrcode-localhost.png) |

To try a dev server on your phone, hover it in Running and click the QR button, then scan the code with the phone's camera. The phone has to be on the same Wi-Fi as the Mac. The page also lists the server's localhost and network URLs with copy and open buttons, and a port picker when the server listens on more than one.

A phone can only reach a server that listens on all interfaces (`0.0.0.0` or `::`). Vite, Astro, uvicorn, Rails and many other dev servers listen on `localhost` by default, so the page says "Only this Mac can open it" and shows the flag for that server, such as `--host` for Vite or `--host 0.0.0.0` for uvicorn. If the code still doesn't open, check that the macOS firewall allows incoming connections for the server's runtime (System Settings > Network > Firewall).

The network address is the Mac's private IPv4 address on Wi-Fi or Ethernet. VPN tunnels are skipped. More in [How to access localhost from your phone](https://bavrk.com/articles/access-localhost-from-phone).

### Worktrees

![Worktrees](screenshots/app/worktrees.png)

Coding agents create a git worktree per task and rarely clean up after themselves. The Worktrees page (in the footer, or **Worktrees** in a project's menu) lists every linked worktree of the repositories devdash knows about: projects that have something running, and your Claude Code projects in every account. Repositories without linked worktrees are left out.

Each worktree shows its branch, which tool created it (Claude Code worktrees get the Claude mark), when it was last touched, its size, and a status:

- **Running**: a dev server or Claude session is running in it.
- **Changes**: it has uncommitted or untracked files.
- **Merged**: its branch is merged into the default branch (`origin/main` when there is a remote), or its remote branch is gone, which is what a squash merge on GitHub leaves behind.
- **Stale**: untouched for a week.
- **Active**: everything else.

Remove a worktree from its row or right-click menu. devdash runs `git worktree remove`, then `git worktree prune`, and for merged branches `git branch -d`, which keeps a branch git doesn't consider merged (squash merges) and says so. It never forces anything: worktrees with changes, with something running in them, locked ones and the main checkout can't be removed from devdash. Merged worktrees nothing has touched for a day also appear under Needs you. More in [git worktree remove: clean up worktrees without --force](https://bavrk.com/articles/git-worktree-remove).

### Disk tree

![Disk tree](screenshots/app/disk-tree.png)
Expand Down
2 changes: 1 addition & 1 deletion devdash/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.4.0"
__version__ = "0.6.0"
81 changes: 81 additions & 0 deletions devdash/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@
kill_process,
stop_docker_container,
)
from devdash.network import lan_ip
from devdash.servers import get_servers, project_infos, resolve_project
from devdash import worktrees

CLAUDE_REFRESH_EVERY = 5 # ticks
USAGE_REFRESH_SECONDS = 30.0
WORKTREES_REFRESH_SECONDS = 60.0


def _asdict(obj):
Expand Down Expand Up @@ -80,11 +83,18 @@ def __init__(self, config: Config) -> None:
self._all_procs = []
self._stats = None
self._claude_roots: set[str] = set()
self._claude_cwds: list[str] = []
self._usage_enabled = self._has_claude
self._trackers: dict[str, UsageTracker] = {}
self._usage_payload: dict | None = None
self._usage_version = 0
self._usage_sent = 0
self._busy_cwds: list[str] = [] # where servers and Claude sessions run, for remove_worktree
self._snapshot_roots: set[str] = set()
self._worktrees_wake = threading.Event()
self._worktrees_payload: list | None = None
self._worktrees_version = 0
self._worktrees_sent = 0

# -- output --------------------------------------------------------------

Expand Down Expand Up @@ -112,6 +122,7 @@ def run(self) -> None:
threading.Thread(target=self._read_commands, daemon=True).start()
if self._usage_enabled:
threading.Thread(target=self._usage_loop, daemon=True).start()
threading.Thread(target=self._worktrees_loop, daemon=True).start()
time.sleep(0.5)
while not self._stop.is_set():
try:
Expand Down Expand Up @@ -164,13 +175,17 @@ def _snapshot(self) -> dict:
docker_dicts = [_with_project(_asdict(c), c.compose_working_dir) for c in docker]
claude = self._claude_payload() if self._has_claude and self._tick % CLAUDE_REFRESH_EVERY == 0 else None
roots = {s.project_root for s in servers} | {d["project_root"] for d in docker_dicts} | self._claude_roots
with self._state_lock:
self._snapshot_roots = roots - {""}
self._busy_cwds = [s.cwd_full for s in servers] + list(self._claude_cwds)

snapshot = {
"type": "snapshot",
"timestamp": time.time(),
"system": _asdict(stats),
"node": node,
"servers": [_asdict(s) for s in servers],
"lan_ip": lan_ip(),
"projects": project_infos(roots - {""}),
"docker": docker_dicts,
"processes": [_asdict(p) for p in all_procs],
Expand All @@ -183,6 +198,9 @@ def _snapshot(self) -> dict:
if self._usage_version != self._usage_sent:
snapshot["usage"] = self._usage_payload
self._usage_sent = self._usage_version
if self._worktrees_version != self._worktrees_sent:
snapshot["worktrees"] = self._worktrees_payload
self._worktrees_sent = self._worktrees_version
self._tick += 1
return snapshot

Expand All @@ -201,6 +219,7 @@ def _claude_payload(self) -> dict:
d["expected_profile"] = profiles.route(d["cwd_full"])
instances.append(_with_project(d, d["cwd_full"]))
self._claude_roots = {d["project_root"] for d in instances}
self._claude_cwds = [d["cwd_full"] for d in instances]
return {
"profile": selected.name,
"profiles": [
Expand All @@ -220,6 +239,53 @@ def _claude_payload(self) -> dict:
"stats": _claude_stats_payload(get_claude_stats(selected.dir)),
}

# -- worktrees -----------------------------------------------------------

def _worktree_candidates(self) -> set[str]:
"""Folders to look for repositories in: projects on screen, and the Claude projects of every profile."""
with self._state_lock:
paths = set(self._snapshot_roots)
for profile in self._profiles.profiles:
if profile.dir.is_dir():
try:
paths |= {p.path for p in get_claude_projects(profile.dir)}
except Exception:
pass
return paths

def _refresh_worktrees(self) -> None:
payload = [_asdict(r) for r in worktrees.scan(self._worktree_candidates())]
with self._state_lock:
if payload != self._worktrees_payload:
self._worktrees_payload = payload
self._worktrees_version += 1

def _worktrees_loop(self) -> None:
time.sleep(2) # let the first snapshot fill in the project roots
while not self._stop.is_set():
try:
self._refresh_worktrees()
except Exception as e:
self.emit({"type": "error", "message": f"worktree scan failed: {e}"})
self._worktrees_wake.wait(WORKTREES_REFRESH_SECONDS)
self._worktrees_wake.clear()

def _cmd_refresh_worktrees(self, cmd: dict):
self._refresh_worktrees()
self._wake.set()
return None

def _cmd_worktree_size(self, cmd: dict):
return worktrees.size_bytes(str(cmd["path"]))

def _cmd_remove_worktree(self, cmd: dict):
with self._state_lock:
busy = list(self._busy_cwds)
result = worktrees.remove(str(cmd["path"]), bool(cmd.get("delete_branch")), busy)
self._refresh_worktrees()
self._wake.set()
return result

# -- usage ---------------------------------------------------------------

def _usage_loop(self) -> None:
Expand Down Expand Up @@ -450,6 +516,7 @@ def _snapshot(self) -> dict:
"system": demo.system(),
"node": demo.node(),
"servers": demo.servers(),
"lan_ip": demo.LAN_IP,
"projects": demo.projects(),
# Demo paths do not exist, so the Compose working dir stands in for the resolved project.
"docker": [
Expand All @@ -462,6 +529,7 @@ def _snapshot(self) -> dict:
if self._tick % CLAUDE_REFRESH_EVERY == 0:
snapshot["claude"] = self._cmd_refresh_claude({})
snapshot["usage"] = demo.usage()
snapshot["worktrees"] = demo.worktrees()
self._tick += 1
return snapshot

Expand Down Expand Up @@ -507,6 +575,19 @@ def _cmd_project_detail(self, cmd: dict):
from devdash import demo
return demo.project_detail(str(cmd["path"]))

def _worktrees_loop(self) -> None:
pass # never scan the real repositories in demo mode

def _cmd_refresh_worktrees(self, cmd: dict):
return None

def _cmd_worktree_size(self, cmd: dict):
from devdash import demo
return demo.worktree_size(str(cmd["path"]))

def _cmd_remove_worktree(self, cmd: dict):
return {"path": cmd["path"], "branch": "", "branch_deleted": bool(cmd.get("delete_branch")), "note": ""}

def _cmd_project_sessions(self, cmd: dict):
from devdash import demo
return demo.project_detail(str(cmd["path"]))["recent_sessions"]
Expand Down
Loading
Loading