Workspaces
A workspace is a full git clone of one repo, on one branch, in its own
directory, with at most one agent working in it. It is the unit Fleet hands to
an agent, and it is deliberately not a worktree or a shared checkout — an agent
can git pull, rebase, thrash the index, and break the build without touching
anyone else’s work.
Identity and layout
Section titled “Identity and layout”Workspaces live under the ship’s fleet directory:
<fleetDirectory>/├── atlas.json ← how an agent finds the ship├── api-gateway/ ← repo name│ ├── fix-auth/ ← workspace name (a clone)│ └── bump-deps/└── billing/ └── retry-logic/A workspace is identified by the (repoName, name) pair. Names are unique
within a repo; the pair <repo>/<name> is unique across a ship, and the
bridge extends that guarantee across the whole fleet.
The repo name is not derived from the clone URL. It is a name registered with the bridge, and it doubles as the directory the clone lands under. See Managing repos.
Both components are fleet identifiers: at most 128 UTF-8 bytes, not . or
.., no / or \, no Unicode control characters, well-formed Unicode. The
rules exist because these strings become directory names.
Containment
Section titled “Containment”Every path the ship builds from user input is resolved against the fleet
directory and checked to be a strict descendant of it. On top of that, the ship
refuses to follow symlinks when it opens an existing repo or workspace
directory, and re-checks the canonical (realpath-resolved) path is still
inside the fleet directory. A path that escapes is a 400, not a traversal.
States
Section titled “States”A workspace has exactly two states, and neither of them is stored anywhere:
| State | Means |
|---|---|
inactive |
the directory exists; no tmux session |
active |
a tmux session is up for this workspace |
active is answered by asking tmux whether the workspace’s session exists. The
session name is derived deterministically from (repoName, name) — see
Terminals. There is no state file to get out of sync
with reality; if you kill the tmux server, every workspace is inactive.
Likewise, the workspace list is a directory scan. A directory only counts as a
workspace if its name is a valid identifier, it is a real directory (not a
symlink) inside the fleet directory, and it contains a .git directory.
Anything else on disk is quietly skipped, including entries that are replaced
mid-scan.
Lifecycle
Section titled “Lifecycle” create ──▶ inactive ──activate──▶ active ──deactivate──▶ inactive │ │ │ └───────────────────────┴──────remove──────────┴──▶ goneCreate clones the repo into <fleetDirectory>/<repo>/<name> at the
requested branch, creating that branch off the default branch when the remote
has no branch or tag by that name. If the destination already exists the request
fails with 409; the ship never clones over an existing directory. A new
workspace starts inactive.
Activate starts a headless tmux session rooted at the workspace directory.
Activating an already-active workspace is a 400.
Deactivate kills that session and drops the agent status attached to it.
Switch branch runs a git switch, creating the branch if it doesn’t exist.
It works in either state.
Remove kills the session if one is up, then deletes the directory
recursively. The workspace.removed event still reports the branch, captured
before the delete, so consumers can identify what went away.
Each of those emits an event on /events — see Events.
Ephemeral workspaces
Section titled “Ephemeral workspaces”A workspace created from an issue can be marked ephemeral, which asks the
bridge to delete it once the work it was opened for is finished. Nothing about
the workspace on the ship is different; the bridge keeps a record of it in
ephemeral.json next to ships.json, and acts on that record.
The bridge re-reads every ephemeral record on a timer — five minutes by default,
set with sweepIntervalMs. A pass asks the repo’s provider for the pull
requests whose head is the branch that was linked to the issue at create time,
and cleans the workspace up when either:
- the branch has at least one pull request and none of them are open — merged and closed-without-merging both count; or
- the branch has no pull requests at all and the issue itself is closed.
The branch is pinned when the workspace is created. Switching the workspace to another branch afterwards does not re-point the watch, and does not cancel it.
Cleanup goes through the ship’s non-forcing delete, so it destroys nothing that
cannot be fetched again from the remote. If the workspace holds uncommitted
changes, commits no remote has, or a stash, the ship refuses and the record
turns blocked, carrying the ship’s own explanation. A blocked workspace stays
where it is, shows the reason wherever the workspace is listed, and is retried
on the next pass — push the work, or delete it by hand, and it goes away.
Nothing is written to the forge: the branch, the pull request, and the issue are left exactly as they are. Deleting the head branch after a merge is a repo setting on the forge itself, not something the bridge does for you.
A record is dropped — leaving the workspace as an ordinary one — when the repo is unregistered, when the ship is removed from the fleet, or when the workspace is deleted by hand. A workspace that has vanished is only forgotten once its ship is online to say so, so a rebooting ship never quietly disarms the watch.
What a workspace reports
Section titled “What a workspace reports”The list view (GET /workspaces) returns a summary per workspace: repoName,
name, branch, active, and agent. This same shape is what the event
stream carries.
The detail view (GET /workspaces/:repo/:name) is a discriminated union on
state. The inactive variant carries only the identity and branch. The
active variant adds the ship’s name, the live agent status, and the diff
summary.
The diff summary
Section titled “The diff summary”The diff summary on an active workspace is three numbers:
| Field | Source |
|---|---|
added |
lines added, summed from git diff --numstat HEAD |
removed |
lines removed, same source |
commits |
commits ahead of the upstream branch, 0 when there is no upstream |
--numstat HEAD compares the working tree and the index against the last
commit, so staged and unstaged changes are both counted. Binary files report -
for both counts and are skipped rather than counted as zero.
It is computed on demand, per request. The bridge therefore proxies
GET /workspaces/:repo/:name straight through to the owning ship instead of
answering from its cached view — that request is the only way to get a fresh
diff.
For the actual patch there is GET /workspaces/:repo/:name/diff, which returns
raw git diff text and takes the usual narrowing options (staged, stat,
nameOnly, range, paths, includeUntracked). Unlike the summary it runs on
the on-disk tree regardless of state, so you can read an inactive workspace’s
changes without starting a session.
Agents in a workspace
Section titled “Agents in a workspace”An agent attaches itself to an active workspace and reports what it’s doing. That status — state, description, model, provider, harness — is held in memory on the ship, keyed by the workspace, and cleared when the session is deactivated or removed. It is not persisted; a ship restart forgets it.
See Agents for the contract, and Managing workspaces for the commands.