FirstMate is a local plugin host. It runs a person's own tools on their own machine and gives each one a process, a page and an address, so that no tool has to build a runtime of its own.
FirstMate is an App for Windows: one program that holds the Host, the Tray and the window. Install it with the command line, from Windows or from inside WSL:
npx @luan-afonso/firstmate desktopdesktop downloads the App installer of its own version from the GitHub
Release, checks its SHA-256, installs it for you alone with no administrator,
and opens it (The App). The npm package is the command line and
nothing more: it holds no Host
(ADR-0024). To keep
the command around:
npm install -g @luan-afonso/firstmate
firstmate desktopTo work on FirstMate itself, see Work on FirstMate.
Coming from 1.x? See Moving from 1.x and the 2.0.0 release notes.
firstmate setupsetup asks a few questions in the terminal and does what you answer, with the
same code the plain commands use, so every step is also a command of its own.
It asks, in order:
- The Shelf of this machine's Place. Enter keeps the one in force. A path
it refuses is refused in the words
firstmate shelfuses, and the question comes again. - Places. On Windows and inside WSL, it shows the Places and asks for WSL
distributions to add as Places, one after another, until Enter. A
distribution's Place is named after it, as
debianforDebian. - The import. For each
wslPlace that holds a 1.x install and no Plugin yet, it asks whether to bring it across, asfirstmate importdoes. - The Official Plugins, each with one line about it.
It fetches the ones you choose by number:
1 3or1, 3, and Enter for none. Each goes into a Place it runs in: the only one, or the one you pick. One already in the Registry is marked installed and is not offered, one with no Place to run in is not fetched andsetupsays why, and a clone that fails names the Plugin and the others are still fetched. - Grants, for each installed Official Plugin that calls others, such as
scheduler. Every other Plugin is on the list; a Grant already given is shown and not offered.setupgives Grants and never takes one back. - Shortcuts, one after another: the keys, the Plugin by number, and the
path, where
/is the Plugin Page. Enter at the keys ends the step. - Start at logon, where the App is installed and does not start at logon
yet. It is off until you say yes. Where the App is not installed,
setupsays to runfirstmate desktop.
When a Host runs and this run changed something, setup asks it to reload, as
every plain command does, and asks you nothing.
Each answer is written when it is given, so Ctrl-C keeps the steps that
finished. setup never removes anything, so it is safe to run again. A script
can pipe the answers in, one per line; when the input ends before every
question is answered, setup stops with "setup ended before it had every
answer" and exits non-zero. A step that fails, such as one clone, lets the
others go on, and setup then exits non-zero too. At the end it says what it
changed, and nothing when nothing changed.
Evidence, not a promise. "Proved" means someone has run it.
| Platform | App | Host | Plugin Server | Command line |
|---|---|---|---|---|
| Windows | proved | proved | proved, from mcp.cmd or mcp.exe |
proved |
| WSL, as a Place | — | — | proved, through wsl.exe |
proved |
| Linux, native | not built | proved by CI | proved by CI | proved by CI |
| macOS | not built | should work, untried | should work, untried | should work, untried |
The App is Windows only (ADR-0020). A Plugin in a wsl Place keeps its
mcp file and runs inside its distribution (ADR-0021). On Windows itself a
Plugin Server starts from mcp.cmd or mcp.exe, because Windows reads no
shebang (ADR-0019). The Host runs on plain Node anywhere, which is how the tests
run it; only the App, the Tray and the window are Windows programs.
A Plugin is a directory and nothing more. Put a web/ folder in it and the Host
serves it as a Plugin Page. Put an executable named mcp in it and the Host
starts that Plugin Server. A directory with neither is still a Plugin; it just
has nothing to show.
The words this project uses are defined in CONTEXT.md. The
decisions that are expensive to reverse are in docs/adr/.
A directory, named in the Registry. There is no manifest and no schema.
| In the directory | What the Host does with it |
|---|---|
web/ |
Serves it at /p/<name>/, byte for byte. |
mcp (executable) |
Runs it, and speaks MCP to it over stdin and stdout. |
mcp.exe, mcp.cmd |
The same, on Windows, where mcp is not run. |
The shebang of mcp decides the language, so a Plugin Server can be written in
anything. Windows reads no shebang, so there the Host runs mcp.exe, or else
mcp.cmd (ADR-0019). Every Plugin Server receives FIRSTMATE_NODE, the Node
that runs the Host, so a Node Plugin needs no Node of its own. Its output goes
to the Host's own output and its log: firstmate logs prints it (ADR-0022).
A Plugin is in one of three states, and the Index Page shows which:
| State | What it means |
|---|---|
Running |
The Plugin Server is up and answered the MCP handshake. |
Stopped |
The Plugin Server exited, or could not be run at all. |
no Plugin Server |
The Plugin ships no mcp file of any form. This is allowed. |
The Host never starts a Stopped Plugin again, so a broken Plugin stays visible instead of spinning in a restart loop. A Stopped Plugin still serves its Plugin Page.
Writing one? docs/plugin-guide.md states the whole
contract: what the Host enforces, and what it only advises.
firstmate add <name> /absolute/path/to/the/directory
firstmate install /absolute/path/to/the/directory [name]
firstmate install https://example.com/someone/a-plugin.git [name]
firstmate install worklog [name]
firstmate remove <name>
firstmate list
firstmate grant <from> <to>
firstmate revoke <from> <to>
firstmate order [<name> <position>]add and install take --place <name> to put the Plugin in a Place other
than the default one. See Places.
install is add for a Plugin you do not have yet. A directory is copied and
a git URL is cloned; either way the files land in the Shelf and install
registers what it put there, under the last segment of the source, without its
.git suffix, or under the name you give. It runs nothing the Plugin ships —
the Plugin's own executable runs later, when the Host starts it. It refuses a
name already in the Registry and a directory already in the Shelf, and a fetch
that fails leaves the Registry untouched and the Shelf clean. Cloning needs the
git binary; nothing else does.
Adding neither copies nor symlinks the directory: the Registry holds the path,
so a Plugin stays in its own repository wherever it already lives. grant lets
<from> call <to>'s tools; a Grant is one way, so it does not let <to> call
<from>. revoke takes that Grant back. Both refuse a Plugin Name that is not
registered. The Host enforces a Grant on every Tool Bus call.
Every command that changes the Registry or the settings asks the running Host to reload, so you never restart FirstMate by hand. A reload starts the Plugins that were added, stops the ones that were removed, takes the new Grants, and leaves every other Plugin Server alone. It never starts a Stopped Plugin again. With no Host running, the command writes the files and says nothing more.
The FirstMate project writes a few Plugins of its own, and FirstMate knows
where they are, so install takes their Plugin Name in place of a source
(ADR-0015):
| Official Plugin | What it does |
|---|---|
worklog |
Keeps what you work on from one Meeting to the next. |
scheduler |
Calls one tool of another Plugin at a time you choose. |
nexus |
Manages the skills and global instructions of Claude and Codex. |
install reads a source as a git URL, then as an absolute directory, then as
the name of an Official Plugin. An Official Plugin is cloned from
https://github.com/luanAfons0/<name> and is then a Plugin like any other.
scheduler calls other Plugins, so it needs a Grant for each one it calls.
A Tool Bus call may carry a timeoutMs saying how long it is allowed to take.
Without one it gets thirty seconds, and FIRSTMATE_MAX_CALL_MS is the ceiling:
a call that asks for more is quietly given the ceiling rather than refused. A
Plugin Page's own call is not a Tool Bus call and keeps its thirty seconds.
A Place is where Plugins are installed and their Plugin Servers run
(ADR-0021). The default Place is this
machine: windows on Windows, local anywhere else. It is always there, and
add, install and shelf act in it unless --place names another.
firstmate place # every Place, its kind and its Shelf
firstmate place add <name> <kind> # add a Place
firstmate place remove <name> # take an empty Place awayA local Place runs its Plugin Servers on this machine. A wsl Place is one
WSL distribution, and its Plugins keep their mcp file as it is:
firstmate place add debian wsl Debian [windows-path]
firstmate add worklog /home/me/.worklog --place debianThe Host starts a wsl Plugin as wsl.exe -d <distribution> --cd <directory> -- ./mcp, and reads its files, its Plugin Page among them, through
\\wsl.localhost\<distribution> or the Windows path you gave. Its paths are the
distribution's own. A Plugin whose distribution is missing or will not start is
Stopped with one sentence, and nothing retries it. Its default Shelf is
~/.firstmate/shelf inside the distribution, and install makes the fetched
mcp executable there again. FIRSTMATE_NODE is not handed across, because it
names a Windows program.
Each Place has its own Shelf: the default Place's is the Shelf below, and any other's is
shelves/<name> in the home directory until you move it with
shelf <directory> --place <name>. A Plugin Name is used once across every
Place, so /p/<name>/ is the same address wherever the Plugin runs. A Place
that holds a Plugin is not removed, and removing one touches nothing on disk.
firstmate place add debian wsl Debian
firstmate import debianimport reads the 1.x Registry and settings from ~/.firstmate in that
distribution and brings across every Plugin, its Grants, the Shortcuts, the
Plugin Order and the Shelf. Each Plugin stays where its directory already is,
in the wsl Place. A Plugin Name already in use is refused in one sentence, and
the rest still come. When the 1.x Host runs there as a systemd service, import
asks before it turns the service off, because the 1.x Host holds the port the
App listens on.
Each Plugin in registry.json names its Place. A row with no place, as 1.x
wrote it, is in the default Place.
firstmate status
firstmate status --json
firstmate restart <name>status says whether the Host runs, and the state of each Plugin in the Plugin
Order: Running, Stopped, or no Plugin Server. It asks the running Host itself,
over loopback, with the port and the token from runtime.json, as the App
does. With no Host it says no Host runs. and ends with exit code 6. A
runtime.json that a crashed Host left behind names a port nothing answers on,
and that reads as no Host too.
restart stops one Plugin's Plugin Server, Stopped or not, and starts it
again, after you fix it. The Host never does this by itself: a broken Plugin
stays Stopped until you say so. It says restarted <name>. It is Running., or
that the Plugin is Stopped and why, which ends with exit code 1. An unknown
Plugin Name is refused with exit code 4, and with no Host it says no Host runs. and ends with exit code 6.
status, list, order and shelf say what they read as one JSON value with
--json, and print nothing else, so a script can read them. A command that
changes something prints no JSON, and refuses --json as typed wrong.
firstmate logs # what the Host and its Plugin Servers said
firstmate logs -f # and keep printing new linesThe Host keeps its output, and every Plugin Server's stderr with it, in
firstmate.log in its home directory
(ADR-0022). The file never grows past
two megabytes: the older half is kept as firstmate.log.old, and logs prints
it first. The token never reaches either file.
firstmate --help
firstmate <command> --help
firstmate help <topic>
firstmate --versionfirstmate --help, or firstmate with no words, prints one screen: every
command in its group, with a few words each. With no words it ends with exit
code 2, because nothing was asked.
--help or -h after any command says how that command is typed and what
its words mean, and does nothing else. firstmate help <command> says the
same. A command typed wrong says what is wrong, then shows that same help.
A word that is no command is refused in one line. When it is within two edits
of a command's name, the line guesses that name: firstmate: no such command: lsit. Did you mean list? It runs nothing, and ends with 2.
firstmate --version, or -v, prints firstmate <version>, the version of
the package, and ends with 0. The short help names it on its last line but
one.
firstmate help lists the help topics, and firstmate help <topic> says one:
| Topic | What it says |
|---|---|
exit-codes |
What each exit code means, as in the table below. |
environment |
The variables the command line reads, and their defaults. |
places |
What a Place is, the default Place, and how to add a wsl Place. |
Every command ends with one of these exit codes, so a script can tell one refusal from another without reading the sentence:
| Code | What it means |
|---|---|
0 |
It did what it was asked. |
1 |
It failed: a damaged file, a fetch that went wrong, or a fault. |
2 |
It was typed wrong: no such command, or the wrong words for it. |
3 |
It refused a value that is not what it must be: a Plugin Name, a path, keys, a position. |
4 |
It refused a Plugin or a Shortcut that is not there. |
5 |
It refused a name, keys or a directory that is taken already. |
6 |
It needs a running Host, and none answers. |
FirstMate 2.0 is an App: one Windows program that holds the Host and shows the
Index Page in its window (ADR-0020). Each release carries its installer,
FirstMate-Setup-<version>.exe, and that file's SHA-256 on the
GitHub Release of its tag,
with the same version as the command line on npm (ADR-0023). Install it from a
terminal on Windows, or inside WSL:
npx @luan-afonso/firstmate desktopfirstmate desktop downloads the installer of its own version and its
checksum, and refuses an installer whose SHA-256 does not match: it says so in
one sentence and runs nothing. Then it installs the App silently, for you
alone, with no administrator, and opens it. When that version is installed
already, or a newer one the App updated itself to, it only opens it. It
downloads when you run it, never when the package is installed. From WSL it
saves the installer in the Windows temp folder and runs it, and the App,
through WSL's interop with Windows. Anywhere else it says the App runs on
Windows. It reads which version is installed where the installer records it
for Windows: the App's uninstall key in HKCU. FIRSTMATE_RELEASES_URL moves
where it downloads from.
To build it yourself from a clone, on Windows, see Work on FirstMate. Both the installer and the App it holds are unsigned, so Windows may warn before they run.
To try a change to the App before it is released, you need no clone on
Windows: the Windows check of every pull request keeps the installer it built,
as the artifact FirstMate-Setup, for 14 days. Download it and run it, from
the run's page or from a terminal:
gh run download <run-id> --name FirstMate-SetupIt installs over the App you have, keeps your settings, and is replaced by the next release when the App updates itself.
The App's Host is the one the App runs in its own process: the same home, %APPDATA%\FirstMate unless
FIRSTMATE_HOME moves it, the same port and the same runtime file, so the
command line reaches it as it reaches any other. Only one App runs for one
home; a second start shows the first window. When another program holds the
port, the App says so and does not start. Closing the window hides it: the
App and every Plugin keep running, and the Tray brings the window back. Quit,
in the Tray's menu, ends the App and stops every Plugin Server. Each Plugin
Server runs on the App's own executable as Node, so a Node Plugin needs no Node
of its own.
The window has the strip at the top: FirstMate, then what is open, which opens the switcher with every Plugin in the Plugin Order; "open in browser", which gives what the window shows to your browser; and the gear, which opens the Settings View. Each Plugin Page you open has a view of its own and stays loaded until the App quits, so switching to another Plugin and back loses nothing. A link that leaves FirstMate opens in your browser.
The Settings View shows the Places, each with its Shelf, start at logon, and
the Shortcuts. There you add a Place (a wsl one by its distribution), remove
a Place that holds no Plugin, and move a Place's Shelf: type a path, or choose
a folder for a Place on this machine. Each change runs the same check as
firstmate place and firstmate shelf, and is refused in the same sentence.
The App then has the Host read its settings again. The Host never serves the
Settings View, so no Plugin Page can reach it. Shortcuts, Plugins, Grants and
the Plugin Order change from a terminal.
The Tray is FirstMate's icon in the notification area. It wears the stopped mark while any Plugin is Stopped. A click on it brings the window up, and its menu opens the Index Page or any Plugin, turns Start at logon on and off, and quits. Start at logon is off until you turn it on; the App it starts begins in the Tray, with the window put away.
A Notice shows as a Windows pop-up under FirstMate's name and mark, and a click on it opens the sender's address in the window. Windows takes that name from the installer's Start menu entry, so the unpacked App's Notices are filed under Electron.
The App holds every Shortcut you bind, and picks up a bind or unbind at
once. Pressing one opens its address in the Popup: a small window with no
frame, on top of the others. It hides when it loses focus, on Esc, on its own
Shortcut again, and when its page goes to any address other than its own.
Keys another program already holds are named in a Notice, never dropped in
silence.
The App updates itself. Five minutes after it starts, and every six hours after, it looks at the GitHub Releases for a newer version and downloads it in the background. When it is ready, one Notice says so, and it installs when you quit FirstMate, never in the middle of work. A stable App follows stable Releases; a beta follows the newest Release, beta or stable, so a beta tester lands on the stable version and stays there (ADR-0023). An App you build and run unpacked from a clone never updates.
A Plugin Page may write to the clipboard, so its "copy" buttons work, but never
read it. It is refused every other permission, camera, location and
notifications among them, except the microphone and a capture of the screen or
a window with its sound. The first time a Plugin asks for one, the App asks you, naming the
Plugin, and keeps your answer under permissions in settings.json. To be
asked again, take that Plugin out of permissions. For a capture, the App
then asks which screen or window to give.
| Variable | Default | What it moves |
|---|---|---|
FIRSTMATE_HOME |
~/.firstmate, or %APPDATA%\FirstMate on Windows |
Where the Registry, the runtime file and the settings live. |
FIRSTMATE_PORT |
4747 |
The port. Zero asks the system for a free one. |
FIRSTMATE_HANDSHAKE_MS |
10000 |
How long a Plugin Server has to answer the handshake. |
FIRSTMATE_MAX_CALL_MS |
600000 |
The longest a Tool Bus call may ask the Host to wait. |
FIRSTMATE_NOTICE_MS |
60000 |
How long the Host holds a Notice for /notices.json. |
FIRSTMATE_SHELF |
$FIRSTMATE_HOME/shelf |
The Shelf: where a fetched Plugin lands. With the default home, ~/.firstmate/shelf, or %APPDATA%\FirstMate\shelf on Windows. |
FIRSTMATE_RELEASES_URL |
https://github.com/luanAfons0/FirstMate/releases/download |
Where firstmate desktop downloads the App from: <url>/v<version>/FirstMate-Setup-<version>.exe and its .sha256. |
A _MS variable is a whole number of milliseconds, from 1 to 2147483647 (about
24 days, the longest wait a Node timer holds). The Host refuses to start on any
other value, and says which.
The Shelf is the directory a fetched Plugin lands in. Each Place has its own,
and this section is about the default Place's; --place <name> says or moves
another's. Say where it is, or move it:
firstmate shelf # where a fetched Plugin lands
firstmate shelf /absolute/directory # move it, and remember itThe directory has to be there already, and it may not be, hold, or lead to the Host's home directory, because a fetched Plugin must never be written over the Registry and the runtime file. What is remembered is the real path, so what you read back is what FirstMate uses.
The choice is kept in settings.json and survives a restart. FIRSTMATE_SHELF
beats it, for a test, a script, or a second FirstMate. The Index Page shows the
Shelf in force and names the command that moves it, and that is all it does:
the Shelf is moved from a terminal or the App's Settings View and from nowhere
else, because no address on the Host changes it
(ADR-0012). Nothing scans the
Shelf — a directory sitting there is not a Plugin until add or install
registers it.
A Shortcut is a key combination for all of Windows, bound to one address of one Plugin. The App holds it, and pressing it opens that address in a Popup: a small window with no frame, on top of every other window.
firstmate bind Ctrl+Alt+N worklog new.html # open /p/worklog/new.html
firstmate bind Ctrl+Alt+W worklog # open the Plugin Page
firstmate unbind Ctrl+Alt+N
firstmate list # the Shortcuts follow the PluginsThe keys are one or more of Ctrl, Alt, Shift and Win, and one key: a
letter, a digit, F1 to F24, or one of Space, Enter, Tab, Backspace,
Insert, Delete, Home, End, PageUp, PageDown, Up, Down, Left
and Right. Letter case and modifier order do not matter: alt+ctrl+n is
Ctrl+Alt+N, and that is how it is kept and shown. Keys with no modifier are
refused, so a plain letter is never taken away from every program.
bind refuses a Plugin that is not in the Registry, a path that is absolute or
leaves the Plugin's address, and keys already bound, naming the Plugin that
holds them. unbind of keys that are not bound fails. remove takes the
Plugin's Shortcuts with it and says which. The Shortcuts are kept in
settings.json beside the Shelf, and they are bound from a terminal and from
nowhere else: a Plugin Page cannot set one, because any Plugin could then take
a key in all of Windows
(ADR-0013).
Every list of Plugins shows them in the Plugin Order: the Index Page,
/plugins.json, the window's switcher and the Tray menu. You choose it.
firstmate order # every Plugin, with its position
firstmate order worklog 1 # move worklog to the topA position counts from 1 at the top, and the other Plugins keep their order. A
Plugin you never moved follows the ones you did, in the order it was added, so
a Plugin you add goes to the bottom. order refuses a Plugin that is not in the
Registry and a position outside the list. The order is kept in settings.json
under order, and the Host reads it on every request, so a new order shows
within a few seconds with no restart. remove takes the Plugin out of it, and
list keeps Registry order. The order changes from a terminal alone; no
address on the Host changes it
(ADR-0016).
registry.json— the Registry: one row per Plugin, holding its Plugin Name, the absolute path of its directory, and its Grants.runtime.json— the port the Host listened on and the token it minted. It is rewritten at every start and removed when the Host stops.settings.json— the settings FirstMate remembers: the Shelf, the Places, the Shortcuts, the Plugin Order, and the App's permission answers. It is absent until you choose one of them.
| Address | What it serves |
|---|---|
/ |
The Index Page: every Plugin, its state, the Shelf. |
/plugins.json |
The same list, for the App. Nothing is written. |
/shortcuts.json |
Every Shortcut and the address it opens, for the App. Nothing is written. |
/notices.json?after=<n> |
The Notices after n, for any reader. The App hears them with no poll. Nothing is written. |
/p/<name>/ |
That Plugin's web/ directory, byte for byte. |
POST /p/<name>/rpc |
That Plugin's tools. The body is an MCP request. |
POST /reload |
Read the Registry and the settings again. A terminal only. |
POST /restart/<name> |
Start that Plugin's Plugin Server again. A terminal only. |
A Plugin Page is a whole page: the Host links to it and the browser goes there, rather than framing it under chrome of its own (ADR-0008).
POST /reload and POST /restart/<name> are the Host's own addresses, for the
command line. Every Plugin Page shares the Host's origin and holds its cookie,
so the Host answers them only
when the request carries no Origin and no Sec-Fetch-Site: what a browser
always sends and a terminal never does
(ADR-0018).
A Plugin Page calls its own tools with one ordinary request. There is no bridge API to learn:
const answer = await fetch('rpc', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
});The Host forwards that body to the Plugin's Plugin Server and returns the answer. A Plugin Page may reach only its own Plugin. From a terminal:
curl -X POST -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' "http://127.0.0.1:4747/p/<name>/rpc?token=<token>"A Plugin Server calls another Plugin's tools over the pipe it already speaks MCP on, and the Host carries the call only under a Grant (ADR-0009). One JSON-RPC request on its own stdout:
{"jsonrpc":"2.0","id":1,"method":"firstmate/tools/call",
"params":{"plugin":"other","name":"its-tool","arguments":{}}}firstmate/tools/list takes {"plugin":"other"} and lists that Plugin's
tools. Both hand back the other Plugin's own answer, unchanged. No Plugin is
ever given the token or the port, because the Host owns the pipe and already
knows who is calling. A Plugin Page holds no end of that pipe and still reaches
its own Plugin and no other.
The Host mints a token when it starts and refuses every request that does not carry it. The address it prints at startup carries the token once:
FirstMate: http://127.0.0.1:4747/?token=<token>
Open that address and the Host answers with a host-only SameSite=Strict
cookie and sends the browser to the same address without the parameter, so the
relative paths inside a Plugin Page are never disturbed. Every later request is
admitted on the cookie alone.
From a terminal, pass the token on every call:
curl "http://127.0.0.1:4747/p/<name>/?token=<token>"The Host also refuses a request whose Host header it does not answer to, one
carrying an Origin that is not its own, one carrying the literal Origin of
null, and one a foreign site started. Each refusal is a 403 that says which
check it failed.
The App keeps the Host running for as long as it runs, and it can start at
logon: turn that on from the Tray, or say yes when setup asks. Closing the
window keeps the App and every Plugin running; Quit ends them.
~/.nexus is FirstMate's first Plugin. The Host serves its web/ directory as
a Plugin Page with every relative path unchanged, and starts its mcp file as
a Plugin Server. Nexus deleted its own listener, its Run File and its tray once
this replaced them, and keeps no copy of any of the three.
The migration is finished. What moved, and what it proved, is written down in
docs/nexus-migration.md. What it taught the next
Plugin is in docs/plugin-guide.md.
Everything here runs in a clone. In a clone, node apps/cli/src/cli.ts takes
the place of firstmate in every command above.
git clone https://github.com/luanAfons0/FirstMate.git
cd FirstMate
pnpm install
node packages/host/src/main.tsThe Host runs on plain Node 24 with no build step. It is the Host the App runs
in its own process, and how every test runs it. It listens on
http://127.0.0.1:4747/ and on no other address, and the same environment
variables move it.
node --test
pnpm check
pnpm build
pnpm --filter @firstmate/desktop packagenode --test boots a real Host against a temporary home directory and drives it
over HTTP, and no test imports a module of the Host.
pnpm check checks the types, lints with Biome, checks the layout with
Prettier, looks for dead code with knip, and runs every test. CI runs the same
command. pnpm typecheck checks the types alone.
pnpm build makes the bundle of apps/cli that goes to npm, by hand. The last
command, on Windows, writes apps/desktop/dist/FirstMate-Setup-<version>.exe, a
one-click installer for you alone that needs no administrator, and the same App,
uninstalled, in apps/desktop/dist/win-unpacked/.
The repository is a pnpm workspace: packages/core and packages/host are
private, and apps/cli is the package on npm. Node 24 runs TypeScript without a
build step, so a clone never holds built output and nothing here compiles.
Packing bundles apps/cli, with core and host inside it, on the way to npm
and nowhere else (ADR-0017). pnpm build makes the same bundle by hand.
apps/desktop is the App, and it is the one thing a clone builds: Electron
cannot run a clone's TypeScript. On Windows, node --test packages it and
starts the packaged program, so the suite takes about a minute longer there.

