@dylanpiercey/work
v0.2.52
Published
Web GUI for agentic dev workspaces
Readme
work
A self-hosted web GUI for agentic dev workspaces — on desktop and as a mobile PWA. Spin up multi-repo worktree workspaces, run coding agents (Claude Code, Codex, Copilot, Cursor, Grok) or plain shells in real terminals, review diffs and send feedback to agents, and watch PR status — from any device.
- Workspaces — named directories of git worktrees (one per GitHub repo, cloned from a shared bare-repo cache), all on a generated branch. Create, add/remove repos, archive/restore, delete.
- Sessions — real CLIs in tmux, mirrored to the browser over WebSocket (xterm.js). Sessions survive server restarts and phone locks; agent hooks surface working / waiting / idle state at a glance.
- Review — a fast diff view across every repo in the workspace, with line comments you can send straight into an agent session.
- Mobile-first PWA — installable, with a touch-tuned terminal (sticky keyboard toggle, d-pad for arrows/tab/ctrl-c, momentum scrolling).
Install (bare machine)
One command on a fresh Linux or macOS box — installs the system tools (git, tmux, a compiler for node-pty), mise + node, the published package, and the background service (systemd/launchd):
curl -fsSL https://cdn.jsdelivr.net/npm/@dylanpiercey/work/install.sh | bashAlready have node (and git/tmux)? npm i -g @dylanpiercey/work && work setup
does the same minus the system tools. Update later with work update (or
re-run the installer).
Develop
Requires Linux or macOS with git, tmux, gh (authenticated), and Node 24+.
The bootstrap script installs everything via mise,
offers the agent CLIs, and runs pnpm install:
./scripts/bootstrap.shRun
pnpm dev # http://localhost:7171
# While hacking on work itself, point the background service at a checkout
# (and back at the npm install when done):
cp scripts/work-source.sh ~/.local/bin/work-source && chmod +x ~/.local/bin/work-source
work-source local # this tree (or: work-source local /path/to/work)
work-source rebuild # rebuild + restart without rewriting the unit
work-source prod # global @dylanpiercey/work again
work-source # statusFrom the command line (and agents)
Everything the app's pages do has a work command too, so an agent — or a
script — can use and set up work from a terminal: work help lists them.
Inside a workspace, work branches, work session, work topic,
work review, work notify and work open; agents start and message each
other with work session new|send|wait — any vendor's, each in a tab of its
own the user watches, its last message as a turn ends coming back to the
one that asked as a prompt — and run groups of them with
work session fanout, which the session list shows as a
lead's team; to change things,
work workspace create, work repo add, work config get|set (checked
against what each setting takes; work config schema lists them),
work agent|skill|lsp|host and work gh login; work status sums the
machine up, and work procs prints each workspace port's link as the
user's browser would open it. Every command takes --json (JSON alone on
stdout) and never prompts under it or --non-interactive.
Access
work has no login of its own — it runs agents and terminals as you, so it
answers this machine only by default (it listens on 127.0.0.1). To
reach it from other devices, put it behind something that terminates TLS
and authenticates — a reverse proxy with a login, a Cloudflare Tunnel with
Access, tailscale serve — and tell work about it:
WORK_ALLOWED_HOSTS=work.example.com— the name(s) the proxy serves (their workspace-port subdomains are included). work refuses any other name, which is what stops a web page elsewhere from pointing its own name at your machine (DNS rebinding).- If the proxy runs on another machine, work has to listen on the
network for it:
WORK_LISTEN=0.0.0.0andWORK_TRUSTED_PROXIES=<proxy ip>(IPs or CIDRs, comma-separated). work then answers that proxy and this machine, and refuses everyone else on the network, who would otherwise walk around the proxy's login.WORK_TRUSTED_PROXIES=*answers any machine — only on a network you trust as it is.
Set them when installing (WORK_ALLOWED_HOSTS=… WORK_TRUSTED_PROXIES=…
WORK_LISTEN=0.0.0.0 work setup); work setup keeps them in the service
from then on. A refused request gets a page saying which setting it needs.
Everything (pages, API, and the /term websockets) serves from the single
app port, so route the whole hostname to it.
Tailscale is the zero-config way. On a machine logged in to Tailscale
(1.52 or newer), work setup offers to run tailscale serve --bg <port>,
which puts work at https://<machine>.<tailnet>.ts.net/ for your devices on
the tailnet alone (never Funnel, which would publish it to the internet),
and adds that name to WORK_ALLOWED_HOSTS: open it on a phone signed in to
the same tailnet. work setup --tailscale does it without asking and
--no-tailscale skips the question; --yes alone leaves Tailscale's config
as it is. A serve config already pointing at work only has its name
allowed, and work doctor says which of these holds.
Direct connections. Behind a proxy, a tunnel or a relay, terminals
move to a direct WebRTC connection to this machine when it is measurably
faster than the path through the proxy (Settings → Terminal → Direct
connection: Auto by default, Always, or Off, one setting for every device). Pages,
the API and sign-in still go through your proxy; the direct path is set up
over the app's authenticated events socket, carries terminal traffic
alone, encrypted (DTLS), to UDP port WORK_RTC_PORT (by default the app's
port number), and ends when that login does. That port is held by a helper
process of work's own, only while direct connections are in use: it
starts at the first one and exits ten minutes after the last. Proxy-side inspection or
logging does not see terminal traffic that goes direct: turn the feature
off (per device in Settings, or WORK_RTC=off for the machine) where it
must. On the LAN the direct path needs nothing more; from another network
(a phone on cellular) the UDP port has to be reachable through your router.
Settings → Terminal can allow work to open it on the router when needed
(UPnP, off by default): only after a device failed to connect without it,
leased, and given back once unused. A host firewall can keep a device
out too, though usually it does not (ICE gets through a stateful one from
most networks), so work says nothing of it until a device has failed to
connect directly while nothing has come in through the port. Then work
doctor says it drops the port, or may (where only root can read its
rules, it judges from the defaults and gives the command that shows
them), with the command that admits it, and work setup offers to add
that rule, named work so it is removed as one (--firewall adds it
without asking, --no-firewall skips the question).
macOS's firewall admits programs rather than ports, so there it is the
node the service runs that work doctor checks and work setup lets in. WORK_RTC_STUN names the STUN servers that find this machine's
public address (Cloudflare's by default; empty for none).
A faster git status. Listing a large repo's untracked files is most
of what git status costs, and work asks it of every checkout and topic
branch's worktree. So the service turns on git's untracked cache
(core.untrackedCache) in the repos work keeps (<work root>/.cache, whose
worktrees are every workspace's checkouts), after which git reads again
only the directories that changed; your agents' own git status gets
faster too. It relies on directory modification times, so it is only
turned on where the work root is a local filesystem with fine timestamps
(not NFS, SMB, FUSE mounts, HFS+ or FAT) and git's own check passes there.
A repo set core.untrackedCache=false is left alone, and
git config --global core.untrackedCache false keeps it off everywhere;
work doctor says which it is.
Opening workspace ports remotely. The port chips in the sidebar link to
http://<host>:<port>, which only works where the machine is reachable
directly — under a tailnet name too, since tailscale serve has no
wildcard names: there a chip reaches a dev server that listens beyond
loopback (--host), and one on loopback says so. Through a tunnel, route
a wildcard hostname to the app port as well and work proxies it — HTTP and
websockets, so HMR works — to localhost:<port>. WORK_PORT_HOSTS picks
the hostname shape the chips link to; sub is always accepted, dash only
when picked, and only for a name of three labels or more:
sub(default):https://3000.work.example.com. Needs a wildcard*.work.example.comroute and a certificate that covers it (your own reverse proxy with a DNS-01 wildcard; Cloudflare only with Advanced Certificate Manager, since Universal SSL covers one label).dash:https://3000-work.example.com. One label under the zone, so a Cloudflare Tunnel works on the free plan. DNS wildcards match a whole label, so the tunnel's public hostname is*.example.compointing at the app port, with an Access application on the same wildcard so the ports sit behind the same login as the app. Work ignores hostnames that do not start with a port, so other names under the zone are unaffected.
Only loopback ports on the work machine are reachable, behind the same
auth as the app. API writes and the terminal websockets refuse a foreign
Origin, so a page a workspace serves cannot drive work with the shared
login cookie.
Backup, restore, join
A work machine holds almost nothing that lives only there: branches are
on origin, tools come from mise's registry, skills from their source repos.
So a backup is a small manifest, not a filesystem copy — Settings → Backup
→ Export (or work export [file]) writes one with the workspaces (ids,
names, branches, repos), settings, GitHub hosts, skill sources and mise
pins — a few KB, with every default left out (owner/name@base only when
the base is not the remote's default, tool@version only when pinned). A
workspace is only as recoverable as what its branches have on origin, so
the export pre-flight lists anything the file cannot carry — unpushed
commits, uncommitted or untracked
files, ignored files that are not build junk — and waits for a decision.
Restore (Settings → Backup, or work restore <file> --yes) installs
what the file names that the machine lacks — a tool outside work's catalog
or a new GitHub host only when ticked (or named with --allow), since it
installs and runs whatever the file says — and re-creates the workspaces
as rows; each one's repos then clone in the background, one workspace at
a time, on the branch the manifest recorded (from origin, or fresh from
the base when it was never pushed). Archived workspaces clone when they
are un-archived.
Share / Join: the clipboard button on Manage (or work share) copies a workspace's
invite — its backup-manifest entry (name, branch, repos) as JSON. Someone
else pastes it into New workspace (the join button; or work join <file>, or pipes it
in) and gets the same repos, checked out on their own lane
(<their-branch>-<them>) of the workspace branch, with PRs and the review
targeting that branch.
Configuration
| Variable | Default | Purpose |
| ------------------------- | --------------------- | ---------------------------------------------------- |
| WORK_ROOT | ~/work | Workspaces root (+ .cache bare repos) |
| WORK_DATA_DIR | ~/.local/share/work | sqlite db and server state |
| WORK_APP_PORT | 7171 | HTTP server (app, assets, and /term websockets) |
| WORK_ALLOWED_HOSTS | — | Hostnames a proxy serves it under (see Access) |
| WORK_LISTEN | 127.0.0.1 | Listen address (0.0.0.0 for every interface) |
| WORK_TRUSTED_PROXIES | — | Proxy IPs/CIDRs allowed in from the network (*) |
| WORK_PORT_HOSTS | sub | Port-chip hostname shape: sub / dash (see above) |
| WORK_USER / WORK_HOST | current user/host | Branch-name prefix parts |
| WORK_BRANCH_PREFIX | <user>-<host> | Whole branch-name prefix, overriding the parts |
| WORK_RTC | on | off turns direct connections off (see Access) |
| WORK_RTC_PORT | the app port | UDP port of direct connections |
| WORK_RTC_STUN | Cloudflare's STUN | Their STUN servers, comma-separated (empty: none) |
The usage panel (and work usage) reads plan/quota windows straight from each
agent CLI's own credentials on disk — Claude, Codex, Cursor, Grok, and Copilot
(via gh's token). Providers you are not signed into simply say so. A script
that waits for room in a window reads work usage --json, whose shape (and
what each field means when a reading is stale) work help usage spells out.
Running more than one work (a laptop and a desk box, say)? Settings →
Appearance gives each a name and an accent colour: the tab title, home-screen
icon, notifications, and the app's chrome all take them, so two windows or
two installed apps stop looking identical.
PR chips refresh by polling GitHub (faster for a few minutes after a
local push via repo-pushed). No inbound GitHub webhooks are required.
Layout on disk
~/work/<slug>-<id>/— a workspace: one worktree per repo + generatedAGENTS.md(andCLAUDE.mdsymlink) encoding the conventions~/work/.cache/github.com/<owner>/<repo>.git— shared bare-repo cache~/.local/share/work/work.db— sqlite database (authoritative state)
Terminals live in a dedicated tmux server (tmux -L work), kept alive by a
transient systemd unit so app restarts never kill your sessions.
