Skip to content

Repository files navigation

Copilot Fleet

English · 简体中文

Copilot Fleet is a self-hosted control plane for supervising GitHub Copilot CLI agents on multiple machines. The Host combines a Fastify API, WebSocket hub, SQLite database, and React UI. Each Node makes one outbound connection and owns an isolated ACP client and Copilot process per live session.

What it looks like

Every agent in the fleet on one screen, grouped by the project it is working in. Cards stream their transcript live, so a wall of them is readable without opening anything.

The Copilot Fleet overview: five sessions across three workspaces and two nodes, each card streaming its own transcript.

Open one and you get the whole conversation, the node it runs on, a composer that takes slash commands and file attachments, and the agent's own Model and Mode pickers along the bottom.

A single session: prompts and responses in full, with the composer, model and mode pickers underneath.

Screenshots come from the deterministic --mock-agent demo described under Exact proof of concept, so they can be reproduced on any machine without a Copilot login. A real node streams real Copilot output in exactly the same surfaces.

Requirements

  • Node.js 22.5 or newer and npm 10 or newer
  • GitHub Copilot CLI 1.0.69 or newer installed and authenticated on each real Node
  • An absolute local path for every workspace placement

Mac/Linux Host

git clone <repository-url> copilot-fleet
cd copilot-fleet
npm install
cp .env.example .env

Start development mode:

npm run dev

This single command runs the Fastify API on http://127.0.0.1:8787, Vite on http://127.0.0.1:5173, and the local Node service. The Node reads its FLEET_* settings from .env.

A fresh Host has no password and no administrator. It prints a one-time claim code to its own console and admits to nothing else until somebody uses it:

Copilot Fleet is unclaimed. Claim it at http://127.0.0.1:8787 with this
one-time code:

    v-0MArasdtNAfxqlM5_pnA

It expires in 30 minutes and is printed only here.

See First run: claiming a Fleet for the two proofs a claim takes and how to register the Entra app it needs.

To keep a tunnel URL stable while you edit code, start the tunnel as its own process instead:

npm run dev:tunnel

The tunnel then survives tsx watch reloads, so the public URL stops rotating every time the Host restarts and remote nodes stay connected. The Host detects it and leaves its lifecycle alone; the Settings toggle is disabled while it runs. Stop everything with Ctrl+C as usual.

Open the UI → Settings:

  • General — session defaults, plus export/import to move this Host.
  • Security — administrators, invitations, password migration, the Host fingerprint, Node key migration, and this Host's security audit.
  • Tunnel — run Dev Tunnels, Cloudflare, Tailscale Funnel, ngrok, or bore; each installed provider has its own switch and status.
  • Nodes — rename/delete machines and mint a one-time connect command.
  • Workspaces — map projects to per-machine paths.

Settings → Workspaces & placements: three workspaces, each mapped to an absolute path on the machines that hold it.

A workspace is logical; a placement is the physical (workspace, node) → path pair. The same project can sit at a different absolute path on every machine, and a session is always started from a stored placement — never from a path typed into a request.

First run: claiming a Fleet

A Fleet Host can start processes and read every transcript on every machine enrolled in it. Who may do that is decided by Microsoft Entra ID plus this Host's own list of administrators — and nothing else. A tunnel decides who can reach the Host; it never decides who may operate it.

Two proofs are needed to claim a fresh Host, and one alone is worth nothing:

  1. The console claim code. 128 random bits, printed only to the Host's own stdout, valid for 30 minutes, and consumed by the first successful claim. Holding it proves you have access to the machine — which is the only fact a Host can establish about a network caller, because every supported tunnel relays into http://127.0.0.1:<port> and the source address, Host header and x-forwarded-proto all describe the relay rather than the browser.
  2. A Microsoft account. Signing in proves who you are. Fleet records the account's immutable (tenant id, object id) pair and issues its own opaque session; no Microsoft access, refresh, or ID token is ever persisted.

The first account to present both becomes the one and only administrator. Anyone else in the same tenant who signs in afterwards is refused with a named 403 and receives no session at all.

Microsoft sign-in needs no setup

Fleet uses the Microsoft corporate tenant and the same Visual Studio public client used by KYC for local development. Click Sign in with Microsoft and choose an account; Fleet persists only the account's tenant and object IDs, not the Microsoft token.

The public client accepts a hostless http://localhost:<port>/ callback. That is why login runs on localhost even when the Host is reached through a tunnel.

The environment variables below are advanced overrides for testing another approved registration; normal use leaves them unset:

FLEET_ENTRA_TENANT_ID=<directory (tenant) id>
FLEET_ENTRA_CLIENT_ID=<application (client) id>

Neither value is a secret, but an override must name a compatible approved public client.

The claim itself

  1. Start the Host and copy the claim code from its console.
  2. Open http://localhost:8787 — use localhost, not 127.0.0.1; the UI redirects if you get it wrong, because the registered reply URL is matched by name and the transaction cookie follows it.
  3. Enter the claim code, then Sign in with Microsoft.
  4. You are now this Fleet's administrator. Enrol machines from Settings → Nodes.

Signing in from somewhere else

Authorization code with PKCE and a loopback callback is the primary flow, so a remote browser has two options:

  • Forward the Host to your own machine and use http://localhost:<port>:

    devtunnel connect <tunnel-id>

    Any local forward works — SSH -L, a provider's client, whatever you already use. This is the recommended path and always available.

  • Device sign-in, if your tenant permits it. Microsoft recommends blocking the device code flow by default and Conditional Access commonly does, so Fleet keeps it off until it has watched one complete. An administrator turns it on from Settings → Security → Verify device sign-in: the check runs whatever the setting currently says, and only a completed flow writes it. A tenant that blocks the flow leaves it off and says so, rather than producing a login that hangs.

    A device code is the one credential an attacker can ask you to enter on their behalf. Only ever enter a code the Fleet page in front of you is showing. Fleet additionally requires a fresh authorization-code sign-in — not a device sign-in — before removing an administrator, disabling the password, or exporting a portable backup.

Fleet sessions are per-origin. A session issued on localhost authorises that forwarded UI and does not set a cookie for a public tunnel domain.

Adding and removing administrators

Fleet asks for no Graph permission to search your directory, so an invitation is how somebody is added:

  1. Settings → Security → Add administrator mints a single-use link that expires in 15 minutes.
  2. The recipient opens it and signs in with Microsoft.
  3. That records them as a candidate — it grants nothing. The exact account that turned up is shown, with its object and tenant id.
  4. An existing administrator approves or rejects that identity.

A leaked link is therefore not an escalation: the wrong person redeeming it appears in the pending list and is rejected.

Removing an administrator revokes every session they hold and closes their open browser connections in the same operation, mid-transcript if necessary. The last active administrator cannot be removed, and removal needs a Microsoft authorization-code sign-in from the last ten minutes.

Migrating off the shared password

Hosts that predate Microsoft identity keep working long enough to migrate. FLEET_OPERATOR_PASSWORD is an explicit, warned-about escape hatch — a fresh Host without one generates nothing.

  1. Sign in with the password the Host already has. Because nobody administers it yet, the console shows a migration checkpoint rather than the fleet.
  2. Claim with Microsoft. The account you sign in with becomes this Fleet's first administrator, the shared password is deleted automatically, its sessions are revoked, and the console appears.

Microsoft-only is the secure default after claim. An administrator who explicitly needs both methods can go to Settings → Security → Enable password sign-in and choose a new password of at least 16 characters.

The console claim code is not needed for any of that: proving the existing password proves the same thing it stands for, so the Host trades that session for the same short, browser-bound bootstrap grant and audits it as bootstrap_password_granted. A Host that never had a password, and a portable restore onto a rebuilt machine, still take the printed code.

Disabling deletes the stored verifier, records the choice so a stale FLEET_OPERATOR_PASSWORD left in a shell profile cannot re-enable it, and revokes every password session. If you lock yourself out, a local recovery command on the Host console issues a temporary password and writes an audit event; disable it again once you are back in.

Tunnels and who can reach the sign-in page

Provider Reachability Operator console
Direct localhost This machine only Always allowed
Dev Tunnels (creator-private) Microsoft login at the tunnel Default and recommended
Dev Tunnels (tenant / anonymous) Wider, by your devtunnel access policy Allowed, with a warning
Cloudflare / ngrok / Tailscale HTTPS Anyone with the URL Allowed after claim, with a warning
bore and any plain-HTTP relay Anyone with the URL, in clear text Refused

A fresh Host defaults to Dev Tunnels because its URL alone reaches nothing: the provider demands a Microsoft login before Fleet's own claim screen is even visible. A public HTTPS provider is fine after the Host is claimed — the sign-in page grants nothing on its own — but a stranger can at least see it.

bore is refused for the operator console by the Host itself, not merely greyed out in the panel: it relays plain TCP, so the session cookie and every transcript behind it would cross it readable. The refusal holds for any client, including one that never renders the UI.

Chats

Not every question is about a checkout. Chats is a workspace the Host creates for itself, pinned above your projects in the sidebar, for the sessions that only need an agent and a machine: a question, a bit of reading, research on something you have not written yet. Every node that reports a home directory gets a Chats placement there automatically, so there is nothing to set up — pick Chats in New session and it runs in that machine's home directory.

Because it is derived rather than filed, it is the one workspace you cannot edit: it has no rename, no delete, and no placements to add, move or remove. If you already had a project called Chats, it keeps everything but its label, which moves to Chats (2) so the reserved name is free.

An orchestrator can use it too — see Chats as a destination.

Connected nodes are told when the Host's public URL changes, so a rotated tunnel does not strand them — see Following the Host to a new URL.

For production (built Host + local Node together):

npm run build
npm start

Or just the Host: npm run start:host. Open http://127.0.0.1:8787 — Fastify serves the built UI.

Windows Node (PowerShell)

Install Node.js, then run copilot update and copilot login as the same OS user that will run the Node. Copilot CLI 1.0.69 is the minimum because older ACP builds can report authentication success while signed out, leaving a Host session waiting with no failure to display. From a checked-out Fleet directory (or paste the command from the Host's Nodes → Connect card):

Settings → Nodes: the connect command for a new machine, and the two enrolled nodes with their capacity, platform, commit and last-seen time.

npm install
npm run build:node
npm run start:node -- --url="https://fleet.example.com" `
  --host-id="<host-id>" `
  --host-fingerprint="<sha256>" `
  --enrollment-grant="<id>.<secret>"

The same lines work in bash — flags avoid the $env: / VAR=value split between shells. Generate them from Settings → Nodes → Generate a connect command: the grant is minted on request, is good for one machine and fifteen minutes, and is never stored by the Host in a form it could hand out again.

The node generates its own Ed25519 key pair before it contacts anything, and pins --host-fingerprint. A relay or an impostor that answers the URL cannot produce a signature for the matching key, so the node sends it no enrollment completion and accepts no command from it — which is what makes a relay merely a relay.

The node name defaults to the machine hostname, and can be changed from either end — the Host's Nodes tab or the node's own config page. Renaming keeps the machine's identity, so its placements and sessions come with it; the Host owns the name, so if both ends changed while the node was offline, the Host's name wins and is pushed back down. Pass --max-sessions 4 if you want a capacity other than 10.

Enrollment stores the node's private key and the Host's public key at $env:APPDATA\CopilotFleet\node.json; subsequent starts need no grant, and no reusable shared secret is ever issued. The service uses an outbound WSS connection, so no inbound Node port is required.

The older fleet-wide --token / ENROLLMENT_TOKEN exists only for machines that predate Node keys. A fresh Host has none: it neither requires one to start nor persists one, and it refuses token registration outright. It is a reusable credential that authorises any machine and that a node sends before it can tell the Host from a relay, so it is deprecated. Existing nodes do not upgrade themselves: a shared secret has already reached whatever relays that node's connection, so nothing sent back over it can prove which Host is answering. Migrate each machine by minting a fresh Connect command and running it there — the grant is one-time, the fingerprint comes from your screen rather than the wire, and enrolling under the machine's existing name reclaims the same node, keeping its id, placements and session history. Settings → Security shows how many are left and lets an administrator switch the shared secret off for good once none are.

Node command-line flags

Anything the node reads from the environment can be given as a flag instead, and a flag wins over both .env and the saved settings.json — which is what makes it usable to point one run at a different Host without editing files on that machine. Run npm run start:node -- --help for the current list.

Flag Replaces
--url, --host-url FLEET_HOST_URL
--name, --node-name FLEET_NODE_NAME
--enrollment-grant FLEET_ENROLLMENT_GRANT
--host-id FLEET_HOST_ID
--host-fingerprint FLEET_HOST_FINGERPRINT
--token, --enrollment-token FLEET_ENROLLMENT_TOKEN (deprecated)
--max-sessions FLEET_MAX_SESSIONS
--copilot-command FLEET_COPILOT_COMMAND
--permission-timeout-ms PERMISSION_TIMEOUT_MS
--context-tier FLEET_CONTEXT_TIER
--devtunnel FLEET_DEVTUNNEL_ID
--config-port FLEET_NODE_CONFIG_PORT
--mock-agent, --no-mock-agent FLEET_MOCK_AGENT

Both --flag value and --flag=value are accepted. The -- after the npm script name is npm's own separator; without it npm eats the flags. The same flags work on npm run node, npm run dev and npm start, where they are forwarded to the node process only:

npm start -- --url=https://fleet.example.com

Flags apply to that run; edits made later in the config page win until the process restarts.

Note that --url takes effect by restarting the node, which ends the sessions running on it — they settle as "Node reconnected without this session", and anything that reached the agent can be picked up again with Resume. To follow a rotated tunnel URL without losing live sessions, retarget from the node config page instead: it reconnects in place.

The nodeId stored in node.json is the machine's identity. --name proposes a new label for that identity; it does not create a second node or abandon the existing node's placements and sessions.

Rows that fold themselves away

A workspace or node row folds shut once nothing under it is running — every session on it stopped, finished, or offline while its machine is away — so the tree stays as short as the work in front of you rather than growing with every transcript kept for Resume.

It opens again the moment work turns up there: a session started on that machine, or one coming back to life as its node reconnects. Only those changes move a row, never the standing state, so a dormant branch opened by hand to read an old transcript stays open until something under it actually happens.

Ordering and filing by dragging

The sidebar tree can be rearranged by hand at every level: workspace rows, node rows, and the sessions under them. Drag a row above or below a sibling — the pointer's half of the target row decides which, and a line appears at that edge — and the order is stored, so it survives a reload and is the same in every browser watching the Host.

Dropping onto a row would only ever mean "take its place", which leaves no way to say "put it last": there is no row after the last one to aim at. The above/below distinction is what makes the end of a list reachable.

New workspaces, placements and sessions are added at the end rather than sorted in by name or date, so an arrangement made by hand is not undone by the next machine or run added. A fleet nobody has rearranged keeps the order it always had.

Dropping a node row onto a different workspace files that checkout under it instead of reordering, taking its sessions along: they carry their own workspace id so the sidebar can group history without a join, and leaving that behind would file every past run under the project the checkout no longer belongs to. That move is refused if the target workspace already has a placement on the same machine, since a workspace can only be in one place on a given node.

In Workspaces & placements, the same drags work on the cards: placement rows reorder within a card, node chips at the top can be dropped on a card to place that machine there, and a card that cannot take what is being dragged says why on the card rather than silently refusing.

Sessions only reorder among their own node's list. A session is a live agent process on one machine, holding that machine's files, so there is nowhere else for it to go.

Alerts

A finished turn plays a short rising tone; an agent blocked on a permission plays a lower one, twice. They are different on purpose: a fleet is watched out of the corner of an eye, and "it needs you" should be distinguishable from "it is done" without looking at the screen. The speaker button in the top bar mutes them, and the choice is remembered.

Both are synthesised in the browser rather than shipped as audio files, so they work on a Host that has never been online. Nothing sounds on the first view of the fleet — opening a page onto ten finished sessions is not the same as watching ten agents finish — and several sessions finishing together produce one tone rather than a pile of them. A permission that is still waiting is announced once, not on every refresh.

Permissions are also announced outside the page, with a tab-title count and a desktop notification that survives until it is clicked, because a request blocks its agent until the node's timeout expires.

Attaching files and images

The composer takes files: paste a screenshot straight into the box, or use the paperclip to pick some. Each one appears as a chip that can be removed until the message is sent, and a prompt can carry up to six of them at 10 MB each.

How a file reaches the agent depends on what it is. Images go over as ACP image blocks; everything else is embedded as text, so the agent reads the contents without needing the file to exist on its own disk — which matters because the machine running the agent is usually not the machine the file came from. A binary that is neither, like a zip, is named in the prompt rather than embedded: decoding it as text would spend the context window on replacement characters and can read as instructions.

Bytes travel with the prompt in one piece rather than through an upload endpoint. The agent is often behind a tunnel, and handing it a URL to fetch would mean giving the Node credentials and a route back to the Host for something already in the operator's hand. The size ceilings are what keep that from becoming a WebSocket frame large enough to stall the other sessions sharing the connection.

Only the name, type and size are recorded in the transcript. The event log is stored on the Host and replayed to every browser watching a session, so keeping the bytes there would turn a few pasted screenshots into a liability; the attachment chips under a sent message are the trace that remains.

Slash commands and session pickers

The composer offers Copilot's own slash commands: type / and a list appears, filtered as you type. Arrow keys move the selection, Enter or Tab picks one, and Escape closes the menu. A command that takes an argument (/review, /research) leaves the caret waiting after it; one that does not (/usage, /context) runs straight away. The list is whatever the agent reports for that session, including skills and plugins, so a machine with extra skills installed shows them without any change here.

Along the bottom of the composer sit the session's pickers — Model, Mode, Reasoning Effort — as the agent reports them. Each shows only its current value and opens a menu upwards; the setting's name lives in that menu rather than on the strip, so the composer stays one compact object instead of a band of labelled dropdowns. These are the settings a terminal Copilot opens a chooser for, which is why /model on its own answers "no model is currently selected" over a wire protocol: there is no terminal to open a chooser in. Changing one takes effect on the live session without spending a turn, and works while the agent is mid-run.

Copilot also reports an Allow All picker, and the strip leaves it out. Permission policy is decided once when the session is launched, with or without --allow-all, and is already shown as the session's YOLO badge. Offering it again as a dropdown can only disagree with that badge — and on a session already started with --allow-all, setting it back to "off" is answered with success and then ignored, so the control moves and snaps back. Note that YOLO does not imply Copilot's Autopilot Mode: a session launched with --allow-all still reports mode agent, so Mode stays on the strip as the only way to reach Plan or Autopilot.

Picking a value the agent rejects is reported as a notice and leaves the session alone; it does not end the run. Nodes advertise session-config, and the Host refuses the request rather than sending it to an older node that would not understand the frame.

Copilot owns the defaults. A session is started with nothing but a working directory, so the model, mode and effort a new session opens on are whatever Copilot itself resolves for that machine and account — the fleet never sends one. Changing a picker is scoped to that one session: a second session on the same node, and the next copilot run in a terminal, both still start on Copilot's own default. Resuming re-reads the live values through session/load rather than trusting what was stored, so a session that comes back shows what it is actually running on.

Choosing a model can change the other pickers, because not every model offers every setting — switching to a model without reasoning levels removes the Reasoning Effort control. The agent's whole option list is republished on every change for that reason, so the bar never keeps a control the current model has stopped offering.

Moving a Host or a Node to another machine

The fleet is two kinds of state, so there are two files — and one of them now comes in two versions, because moving a Host's data and moving its identity are different operations with different risks.

Host data (version 1) — Settings → General → Export fleet data. The JSON file holds workspaces, placements, nodes (identity hashes, not plaintext secrets), sessions, transcripts, defaults, any legacy enrollment token, and tunnel provider/enabled. It is data only: import on the new machine replaces the catalog, but deliberately preserves the security envelope of the Host it lands in — administrators, authentication mode and Entra configuration, the Host signing key, the CSRF and lead-token keys, and password mode all survive. A data restore can never return a secured Host to unclaimed or silently hand it a different identity, which is exactly why it cannot move a Host on its own.

Host identity (portable, version 2) — Settings → SecurityMove this Host. This is the file that moves a Host to a new machine intact. Its security section — administrators, Entra configuration, the Host private key, CSRF and lead-token keys, Node public keys, and whether mutual Node authentication is enforced — is encrypted with a passphrase you supply (scrypt + AES-256-GCM, minimum 14 characters, never persisted). Exporting it, and importing it into an already claimed Host, both require a Microsoft authorization-code sign-in from the last ten minutes; importing into a fresh Host instead takes that Host's console claim code plus the passphrase, and creates no session — an administrator signs in afterwards through the restored configuration.

A fleet that had already retired the shared Node secret restores that way too: enforcement travels in the sealed section, and the fleet-wide enrollment token it retired is not written back.

Restoring revokes every browser session and closes browser and Node sockets, and either applies whole or not at all. Stop the old Host before starting the moved one: two processes sharing one Host identity is a fingerprint two machines can sign for, and Nodes cannot tell them apart.

Existing nodes reconnect with the node.json they already have, as long as they can still reach the Host. A named hostname / FLEET_PUBLIC_URL / Tailscale Funnel address is copied into the archive; a rotating quick-tunnel URL (*.trycloudflare.com, free ngrok, bore) is not — those nodes would have to be retargeted by hand.

Node — the local config page (http://127.0.0.1:8788) → Export identity. That file is node.json plus settings.json for this machine. Import on the new box replaces this process's identity and reconnects. Placement paths stay whatever the Host already stored for that node id; update them if the checkout lives somewhere else. Copilot's own session files are not in the archive, so Resume only works if those files are on the machine that runs the agent.

Both files contain secrets. Do not commit them.

Recovering sessions after a restart

Reconnect on reboot, as a sequence: the Host marks every unsettled session offline, the Node's hello reports which sessions it still holds and which are mid-turn, and only the ones it no longer has settle as failed-but-resumable and are re-attached through ACP session/load.

A dropped transport says nothing about the agent behind it, so the Host asks rather than assumes. The Node reports its inventory and which of those sessions are mid-turn, which is what stops a returning session from landing on idle while its agent still has a prompt in flight.

Sessions survive both processes going down. The Host keeps them in its SQLite file and the node keeps its identity in node.json, so after both come back:

  1. The Host marks everything it had running offline ("Host restarted").
  2. The reconnecting node reports which sessions it still has. A restarted node has none, so the rest settle as "Node reconnected without this session".
  3. Resume re-attaches through Copilot's session/load, and the transcript continues where it stopped rather than starting over.

A session in that state is shown as resumable rather than failed, stays in the sidebar, and is skipped by Clear ended — that button only removes sessions with nothing left to re-attach to. Use Dismiss on a session to drop a resumable one deliberately.

By default the Host re-attaches those sessions itself as soon as the node is back, so a restart does not leave a row of buttons to click. It takes only the sessions settled by that reconnect, newest first, and stops at the node's capacity — so a restart never resurrects conversations abandoned days ago, and a resume that fails is left for a person instead of retried every heartbeat. Re-attaching sends no prompt: the agent lands on idle waiting for input, so nothing runs until you ask it to. Turn it off under Settings → General if you would rather press Resume yourself.

Three things have to hold for that to work: the Host's DATABASE_PATH file is intact, the node starts with the same node.json identity, and Copilot on that machine still has the agent session on disk. A session that died before its agent ever started has nothing to re-attach to — it settles as "it never reached the agent" and offers no Resume.

A node keeps its agents running while the Host is away and buffers the events they produce, so a Host restart mid-turn no longer costs that part of the transcript. If the outage outlasts the buffer the Host records the gap and keeps going; it never refuses the events that follow, because a session that cannot report its own state again is a session nobody can use.

Node config page

Each node serves a small settings page at http://127.0.0.1:8788 (override the port with FLEET_NODE_CONFIG_PORT). Use it to retarget the node when a tunnel hands out a new URL — the node reconnects in place, so no restart is needed and running sessions survive.

It also edits the node name, session capacity, Copilot executable path, and permission timeout. Values are stored in settings.json beside the credentials and take precedence over the environment variables, so an edit here is not undone by a stale .env on the next start. Command-line flags outrank both.

The listener binds to loopback only and is deliberately not exposed: anything that can repoint a node at a different Host can run commands on that machine. Reach a remote node's page over SSH port forwarding rather than binding wider.

Following the Host to a new URL

Settings → Tunnel: five providers — Cloudflare, Tailscale Funnel, Dev Tunnels, ngrok and bore — each with its own toggle and status, and a banner naming the address nodes are currently told to dial.

Each provider runs on its own, so more than one can be up at a time; the one marked for enrollment is the address handed to new nodes.

That address is the Host, not a separate handshake channel. The tunnel forwards to http://127.0.0.1:8787 (or PORT): /api, /ws/node, /ws/browser, and the built UI when one is there. In npm run dev the page you click is Vite on http://127.0.0.1:5173; the tunnel does not point at that. Opening the public URL still hits the Host, so /api/health answers and everything else still asks for a Microsoft sign-in.

When the Host's public address changes — a tunnel comes up, rotates, or is switched to another provider — it tells the nodes that are still connected. Each one records the new address, keeps the old one as a fallback, and does not drop the connection it already has: the running sessions on it are unaffected, and the new address is what the next reconnect dials.

This closes the gap where a rotated tunnel URL left every node dialing an address that had stopped existing, with no way back except editing settings.json on each machine.

What it does and does not cover:

  • A node reached over an address that outlives the change — a LAN address, a named tunnel — is told and follows along.
  • A node reached through the tunnel that just rotated cannot be told: that socket died with the tunnel. It keeps retrying its known addresses, so it recovers on its own if one of them still answers.
  • A private Dev Tunnel is advertised for enrollment but never pushed as a public Host URL. Its nodes use --devtunnel=<id>, keep a local devtunnel connect forward alive, and dial the loopback port that client reports.
  • Loopback is never announced. When no tunnel is up and no FLEET_PUBLIC_URL is set, the Host's idea of its own address is http://127.0.0.1:8787, which on another machine points at that machine. Nodes are left on the address they have instead.
  • A node running an older agent is skipped rather than sent a message it would reject, so a mixed fleet keeps working.

If an announced address turns out to be unreachable from a particular machine, that node dials it, fails, and rotates to the previous address on the next attempt — so an announcement can never strand a machine. Whichever address answers becomes the one it leads with. The node config page lists the fallbacks under the Host URL field.

Keeping nodes up to date

Updating a node, as a flowchart: a busy node is refused, the checkout is reset hard onto its tracking branch, an unchanged HEAD skips the restart, install and build both run before anything is torn down, and only a successful build reaches exit 75 and a supervisor restart. Every other exit leaves the machine on the code it already had.

The shape of that diagram is the whole feature: there is exactly one path that ends in a restart, and every guard that fails leaves the machine running what it was already running.

The Nodes tab compares each machine's commit with the Host's and marks it Up to date, Update available, or Manual update. Update on a row — or Update all above the table — tells those machines to git fetch --prune, git reset --hard onto the branch they track, npm install, npm run build:node, and restart into the new build. Progress appears in the row as it happens.

The commit is compared, not the package version: 0.1.0 never moves between deploys, so comparing it would report every machine as current no matter how far behind it was.

What it will not do:

  • Update a machine that is running sessions without being told to. A restart takes every agent on that node with it, so a busy node is refused — but the refusal names the sessions in the way, and Update then offers to stop them and go ahead. Each keeps its transcript and can be resumed afterwards. Update all never does this: it skips busy machines rather than deciding for you across the fleet.
  • Keep local work on a node. The checkout is reset hard onto the branch it tracks, so local commits and local edits to tracked files are discarded — the remote is what that machine is meant to be running, and --ff-only used to mean one stray commit froze a machine behind the fleet until someone logged into it. Untracked files are left alone, so the .env naming the Host survives. A node is a deployment; do the work somewhere else.
  • Move a machine off the branch it is on. The reset target is the branch's own upstream, not origin/main, and a branch with no upstream stops with that as the reason.
  • Restart into a build that does not compile. npm run build:node runs before anything is torn down; if it fails the node stays up on the code it already had and reports the error.
  • Update a node whose agent predates this feature. It has no update_node in its copy of the message union and would close the connection on receiving one, so it is marked Manual update and skipped. Update those machines by hand once — with the three commands under Windows Node — and every update after that can be done from the Host.

A node reports "" for its commit when its directory is not a git checkout — a tarball deploy, say. Those show as Unknown rather than being guessed at, and are left out of Update all.

How a node restarts itself

npm run node and npm run start:node both put a small supervisor in front of the node (apps/node/supervisor.mjs). The node never replaces itself: it exits with status 75 to ask for a restart, and the supervisor — which had nothing to do with the update and is therefore still alive — starts the new build in the same terminal. Nothing is detached and no window appears.

This exists because a process cannot reliably replace itself on Windows. The version that tried spawned a detached successor, which arrives with a console window of its own and has to win a race for the instance lock. Under tsx watch it lost that race every time: the pull changed the source, the watcher restarted its own child, and the successor found the lock taken and exited — which looked like a terminal flashing open and vanishing, with the node coming back only by the watcher's accident.

npm run dev:watch still runs the node under tsx watch for iterating on node code. Do not use it for a machine you rely on: a watcher does not restart a child that exits, so an update under one leaves the machine with nothing running.

The supervisor restarts on status 75 and nothing else — a node that crashes exits with the code it crashed with, so a broken build is visible instead of looping. It also gives up if the node asks to restart five times in twenty seconds.

Restarting under a process supervisor

The built-in supervisor does not survive a reboot and will not restart a node that crashes. A machine you rely on is better run under something that does — PM2, NSSM, a systemd unit.

Set FLEET_RESTART_MODE=exit and an update stops the process instead of launching a successor, leaving the restart to the supervisor. Point it at apps/node/dist/main.js directly, not at supervisor.mjs; two supervisors is one more than the job needs.

# PM2, on any platform
FLEET_RESTART_MODE=exit pm2 start apps/node/dist/main.js --name copilot-fleet-node -- --url=https://fleet.example.com
pm2 save
# Windows, as a service, with NSSM
nssm install copilot-fleet-node "C:\Program Files\nodejs\node.exe" "Q:\Repos\copilot-fleet\apps\node\dist\main.js"
nssm set copilot-fleet-node AppDirectory Q:\Repos\copilot-fleet
nssm set copilot-fleet-node AppEnvironmentExtra FLEET_RESTART_MODE=exit
nssm start copilot-fleet-node

An update exits 75 in this mode too. PM2 and NSSM restart on any exit, so that is already what you want; a unit file that restarts only on failure needs RestartForceExitStatus=75 or Restart=always.

Exact proof of concept

Run the Host in terminal 1:

cp .env.example .env
npm install
npm run host

Claim it: open http://localhost:8787, enter the code the Host printed, and sign in with Microsoft. Then mint a connect command from Settings → Nodes and run a deterministic no-login Node in terminal 2:

npm run node -- --url=http://localhost:8787 \
  --host-id="<host-id>" \
  --host-fingerprint="<sha256>" \
  --enrollment-grant="<id>.<secret>" \
  --name=mock-node \
  --max-sessions=2 \
  --mock-agent

Then open http://localhost:5173:

The Start a session dialog: a workspace placement, an optional session name, the initial prompt, and the YOLO toggle that decides whether the agent asks before running tools.

  1. Create a workspace under Workspaces.
  2. Add a placement for mock-node using an existing absolute directory.
  3. Start two sessions with New session. Give one a name in the dialog; the other is listed by its prompt until you rename it from the session header.
  4. Open either card to observe independent streamed events, send a follow-up, cancel a turn, or stop the process.

The automated equivalent is:

npm test

apps/node/src/router.test.ts starts two mock sessions concurrently and proves that each receives its own ordered event stream without Copilot authentication.

Architecture and message flow

Copilot Fleet architecture: a browser drives the Fleet Host, which owns SQLite state and sends commands over a Node-initiated WebSocket; each Node buffers events in an outbox, runs one Copilot ACP process per session, and is restarted by a supervisor after it updates itself.

The vertical split is the whole design: the Host owns desired state and history, the Node owns execution. Copilot credentials, child processes, and local paths never cross it, and the Node is the side that dials out.

  1. The Node generates its own key pair, pins the Host fingerprint, and enrols with a one-time grant; the Host stores only its public key.
  2. The Node authenticates its outbound WebSocket by signing the whole handshake, and both ends derive per-direction AEAD keys for it. Heartbeats report active session inventory.
  3. The browser creates a session from a stored placement. The Host never accepts a path in the session-create request.
  4. The Host dispatches a deduplicated command. The Node validates and resolves the placement directory, enforces capacity, and starts one isolated ACP connection.
  5. The official @agentclientprotocol/sdk performs initialize, session/new, prompt/update streaming, follow-up prompts, and session/cancel. Stop closes ACP and terminates the child.
  6. Node events carry a UUID plus a per-session monotonic sequence. SQLite ignores duplicates and records sequence gaps rather than rejecting everything after an outage; normalized sessions/events are broadcast to browsers and rebuild the transcript after refresh.
  7. ACP permission requests become persisted events. Browser allow-once/deny decisions round-trip to the waiting ACP request. Timeout or Node/Host disconnect denies pending requests. Cancel also denies pending requests before session/cancel.
  8. A transient Host WebSocket disconnect leaves local agent processes running. The Node buffers their events and re-announces active and busy sessions when it reconnects. The Host keeps them offline meanwhile and settles only sessions missing from the returning inventory as failed-but-resumable. An explicit Node shutdown still stops its local agents.

The states a session moves through

The session state machine: queued, starting, running and idle form the live loop; cancel drops a turn and returns to idle with the process intact; stop is terminal; a Host restart parks everything in offline, from which a session either comes back or settles as failed-but-resumable.

Two distinctions carry the model. Cancel ends the turn and keeps the process, so the session lands back on idle ready for a follow-up; stop ends the process and is terminal. And failed is not one thing: a session that reached the agent keeps its agent session id and is offered as resumable, while one that never got that far is simply over.

Runs: several sessions toward one objective

There are two ways to put several agents on one job.

Talk to an orchestrator. The sidebar's first row is Orchestrator, above the workspaces, because it is the fleet's own surface rather than any one repository's. Start one and you get a session you chat with, which does not write code itself — it starts other agents that do. Ask it for something and it picks a machine, dispatches a worker, and ends its turn. When that worker finishes, the Host wakes the orchestrator with a summary, and it decides what happens next. Ask it for a review and it dispatches one onto the same checkout the work happened in, so the reviewer sees the actual changes.

The orchestrator's architecture: a person asks a lead session, which reaches the Host through a bearer-scoped MCP tool surface; the tools write task state to SQLite, a pure scheduler reads a snapshot of it, and the orchestrator engine dispatches workers and wakes the lead with a summary once a worker's turn completes.

The orchestrator is not a special kind of process. It is an ordinary session on an ordinary node, and the only thing that makes it a lead is that the Host hands it a tool surface — an MCP server, with a bearer token scoped to that one session. Workers are given no tools at all: not denied them, never handed them, which is what stops orchestration nesting.

Two seams are worth naming, because they are what the awkward cases hang off. The scheduler is pure — a snapshot of runs, steps, sessions and nodes goes in, a list of actions comes out — so a Host restart mid-dispatch, a node that vanished, or two steps settling at once are all unit tests rather than situations you have to reproduce on a real fleet. The engine does nothing but carry those actions out, and it ticks on events plus a 15-second sweep, so a machine that loses power leaves a step overdue rather than stranded.

The wake — the coral path above — is the whole design. The orchestrator never sits and waits: it dispatches, ends its turn, and is woken when there is something to decide. The conversation is durable, so a worker that takes twenty minutes costs nothing while it runs, and a Host restart does not lose the thread.

You see all of this in three places. The sidebar lists your conversations; the Orchestrator board shows every conversation's tasks, because "what is the fleet doing" is a fleet-wide question; and a conversation carries its own tasks in a panel beside it, so what you just asked for is next to where you asked. Clicking a dispatched step opens that worker's transcript.

Chats as a destination

An orchestrator picks where each worker runs, and Chats is one of the choices: naming it as the workspace sends that worker to the node's home directory instead of a checkout. That is how a task that is a question — look something up, read around a problem, compare two approaches — gets dispatched at all, without inventing a project for it to be asked in first.

It is the one destination the Host refuses work for. A step that writes or reviews is never sent there, because a change made in a home directory would pin the whole task to it, and every later step — the review most of all — would then be sent somewhere the work has never been. The refusal says so, and names the alternative: send research to Chats, name a workspace for the repository.

Or write the plan yourself. A run is an objective plus a budget with a fixed list of steps, approved once. There is no UI for this; it is the engine's own fixture, and it is reachable over REST:

curl -X POST http://127.0.0.1:8787/api/runs \
  -H 'content-type: application/json' \
  -d '{"workspaceId":"<id>","name":"audit","objective":"audit, fix, then test"}'

curl -X POST http://127.0.0.1:8787/api/runs/<runId>/plan \
  -H 'content-type: application/json' \
  -d '{"steps":[
        {"stepKey":"audit","title":"Audit","prompt":"Find the flaky test","category":"explore"},
        {"stepKey":"fix","title":"Fix","prompt":"Fix it","category":"implement","dependsOn":["audit"]},
        {"stepKey":"test","title":"Test","prompt":"Run the suite","category":"test","dependsOn":["fix"]}
      ]}'

curl -X POST http://127.0.0.1:8787/api/runs/<runId>/approve

Either way the Host runs it: it picks a placement, waits for turn_complete and then idle before calling a step done, pins the whole run to the first checkout it wrote to, and stops the sessions it still holds when the run ends. A restart mid-run does not mis-settle anything, because offline is read as unknown rather than as failure.

Approving is deliberately the only gate. A human authorises the objective and its budget; individual dispatches are not re-approved, and the budget is what stops a run rather than a prompt each time.

Security notes

  • The web UI and the whole /api surface require a Fleet session belonging to a live administrator. A Fleet session is issued only after Microsoft Entra ID has authenticated the person and this Host's own administrator table has authorized them: a valid account from the right tenant that nobody added is refused with a named 403 and gets no session. Sessions are opaque 256-bit values stored as SHA-256 digests, HttpOnly, SameSite=Strict, Secure on a configured HTTPS endpoint, with a seven-day idle and 30-day absolute life. No Microsoft access, refresh, ID or device token is ever persisted. /api/health and /api/auth/status stay unauthenticated so a tunnel URL can be probed without becoming an administrator.
  • Claiming a fresh Host takes two independent proofs: a 128-bit one-time code printed only to the Host's console, and a Microsoft sign-in. Neither is sufficient alone, the claim is a single atomic transaction, and a second identity racing it gets 409 rather than a second administrator. Request IP, apparent loopback, x-forwarded-proto and caller-supplied Host values are not security inputs — every supported tunnel relays into loopback, so all of them describe the relay.
  • Every state-changing browser request carries an X-CSRF-Token derived from the session with an HMAC, so nothing per-session is stored to leak.
  • High-impact changes — removing an administrator, disabling the password, minting an enrollment grant, exporting a portable backup — additionally require an authorization-code sign-in from the last ten minutes. A device sign-in does not satisfy it, because an attacker can start a device flow and have an administrator finish it.
  • Removing an administrator revokes their sessions and closes their live browser sockets in the same operation; a 60-second sweep re-checks every open socket against the live session and administrator rows.
  • Legacy password sign-in is opt-in, off on a fresh Host, and retired automatically by the first Microsoft claim. Disabling it deletes the verifier and records the choice, so a stale FLEET_OPERATOR_PASSWORD cannot re-enable it.
  • The Host answers only to names it knows: loopback, FLEET_PUBLIC_URL, the live tunnel URL, and anything listed in FLEET_ALLOWED_HOSTS. Requests arriving under any other Host, or from another Origin, are refused — which is what keeps a page the operator happens to visit from reaching the fleet through a rebound DNS name. FLEET_ALLOWED_HOSTS=* disables the check.
  • A session or bootstrap grant is issued only over loopback or an endpoint this Host itself published as HTTPS. A plain-HTTP relay such as bore is refused for the operator console by the Host, not merely disabled in the UI.
  • /mcp is a separate machine principal, not an operator-cookie exception. It accepts only a signed lead token bound to a live lead session, run and node, rejects browser Origin headers, and audits every refusal without recording the bearer value.
  • A node's own credentials reach only the workspace and placement endpoints its config page relays through, and a node can only create or repoint placements on itself.
  • New enrollment sends no reusable credential to an unauthenticated Host. A one-time grant authorises exactly one Node public key for fifteen minutes; the node pins the Host fingerprint before it completes, both ends sign the whole handshake, and the connection derives per-direction AES-256-GCM keys with sequenced frames — so a relay can carry the traffic without reading, forging, or replaying it.
  • The legacy fleet-wide enrollment token exists only for machines that predate Node keys. A fresh Host never has one, does not persist one, and refuses token registration; Settings shows how many machines are left, and enforcement — which deletes the stored secrets and retires the token — is refused while any node still needs one. There is no automatic upgrade off a shared secret: that secret has already reached whatever relays the connection, so a machine migrates by running a fresh Connect command, which reclaims its own node row against a key.
  • Copilot authentication and tokens remain on the Node and are never included in Fleet messages.
  • Session requests reference preconfigured placement IDs. Nodes also require an existing absolute directory and resolve it before process creation.
  • Copilot is spawned directly with argument arrays, shell: false, and the selected placement as cwd.
  • Permissions are explicit and auditable in the UI (allow-once / deny only). YOLO is off by default, on the Host and on each new session. For unattended runs, set FLEET_YOLO=1 on the Node so Copilot starts with --allow-all (tools, paths, and URLs). Unanswered and disconnected requests still fail closed when YOLO is off.
  • Security-relevant decisions are recorded in a local audit kept to the newest 10,000 rows, readable from Settings → Security. Claim codes, authorization codes, device codes, Microsoft tokens, Fleet cookies, invitations, enrollment grants, lead tokens and private keys are never logged.
  • The node's local config page is bound to loopback and additionally refuses requests that do not name 127.0.0.1 (or localhost) on its own port, come from another origin, or write without content-type: application/json. It does not defend against another user signed in to the same machine.
  • An internet-exposed Host should still use HTTPS/WSS, and putting one behind an authenticated reverse proxy or access policy (for example Cloudflare Access) remains a good second layer.

Commands

npm run dev
npm run dev:tunnel
npm test
npm run typecheck
npm run build
npm run verify   # everything CI runs, in CI's order

npm run verify is the one to run before pushing: CI also checks formatting (prettier --check), which lint does not cover, and a red build there has more than once been nothing but unformatted source.

Startup is seed-free. SQLite creates its schema and empty data file on first launch.

About

Self-hosted control plane for running GitHub Copilot CLI agents across multiple machines

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages