@intentic/machine
v1.301.0
Published
The one agent that lives on a user's own device: lets an intentic sandbox work on it (outbound WebSocket, MCP tools, scopes enforced here) and keeps folders and ports mirrored with it (Mutagen over tunnel SSH)
Readme
@intentic/machine
The one agent that lives on a user's own device: it lets a sandbox's agent work on the machine, and keeps the machine's folders and ports mirrored with the sandbox — one binary, one resident loop, one logon entry.
It replaces two agents (@intentic/host and @intentic/sync) that shared everything about being installed —
a resident loop, a pidfile, a login entry, an updater, a ~90 MB compiled binary — and nothing about what they
did. Merging them halves what a machine downloads and keeps resident, and gives "is this machine's agent
running" one answer instead of two halves of one.
Responsibilities
The device half (src/device/, the machine side of the host capability):
- Dial each linked sandbox — one outbound WebSocket, enrollment token in the first frame, oRPC after that; the
machine serves
hostContract, so no port ever opens here. Where it dials is the shared resolver's answer (below), re-asked on every reconnect, so a sandbox on this very machine stays connected with its tunnel down. - Expose the tool surface (
run_command, files, screenshot,describe; deliberately no delete — trash is recoverable) as MCP carried verbatim, so a machine can learn a tool without a daemon release. - Enforce the owner's scopes here, never in the sandbox, and append every call to an audit log that survives uninstall.
- Manage this machine's sandboxes for a browser button or a model, under the
sandboxesswitch: list them with their share of the machine (onedocker inspectper listing: memory and CPU caps, privileged, GPU, and who asked for each directive, the approved environment or the owner), start/stop/restart, run theicflows (prepare, update, rebuild, rollback, reshape, remove) narrating their output line by line, and tail their logs.reshapechanges a sandbox's share or privileges throughic sandbox reshapeon the same switch as the swaps, because another reshape undoes it; its ask is a closed form (two caps, two switches) spelled intoicflags here, so nothing a browser or a model sends reaches docker as text.describereports the docker engine's size beside the OS, which is the ceiling those caps are held to. - Keep each local sandbox's next update downloaded (
src/device/auto-prepare.ts): a background tick runsic sandbox prepare <slug> --autoevery few hours, so the web app's update card offers a half-minute restart instead of minutes of pulling. On by default; the switch isintentic-machine device updates --off(cached indevice.json). Nothing in any sandbox can start or steer the tick — a sandbox only learns the outcome through the staged markericwrites, the way it always has.
The sync half (src/sync/, the machine side of desktop sync):
Enroll an SSH key, then drive Mutagen: bidirectional file sync of a local folder ↔ the sandbox's /work, plus a one-way backup of the sandbox's own state.
Serve the SSH transport itself on loopback (the sandbox's sshd reached over its HTTPS surface), mirror every workspace port onto this machine's localhost, and bridge git so commits appear in local clones.
Own the port-mirroring switch (
sync mirror off|on, optionally--sandbox <id>): mirroring is the one thing here that writes to this device's localhost, so the flag lives on this side, survives a restart, and is read every tick. File sync, the state backup and the git bridge are untouched by it — the point is to stop the ports without unpairing the sandbox. The sandbox's Devices tab has a button for it, and that button runs this very command over thehostcapability, so the two can never disagree. The same is true of the other three verbs —sync pause,sync resumeandsync uninstall --sandbox <id>.Both scopes are buttons, because both scopes are commands. With
--sandboxthese act on one pairing, and that is the switch under that pairing's own folder and ports in the tab. Run BARE they act on every sandbox this machine pairs, which is what they mean in a terminal and what "turn this off on this laptop" should mean on screen, so the machine's own row carries a File syncing and a Port mirroring switch that omit the flag. The tab never sends a baresync uninstall: unpairing is the one verb here that a fresh one-liner is the only way back from, so the button always names the pairing it ends. A machine whose pairings disagree (one mirroring, one not — which the per-pairing switches exist to allow) is drawn as such rather than collapsed to one position, and offered both directions.And one port at a time (
sync mirror ignore|unignore --sandbox <id> --port <n>). The switch above is all-or-nothing, and the case it has no answer for is the common one: a single number this machine already uses for something else — a Postgres on 5440 that the sandbox's compose file also publishes — loses the local bind correctly, every tick, forever. That is not a contest that resolves, and turning every port off to be rid of one standing notice is the wrong trade. An ignored port is skipped before the free-check, torn down at once if it was already up, and still REPORTED — asignored, its own state — because the row it appears on is the only place the choice can be reversed. It lives here rather than in the sandbox for the same reason the switch above does: the conflict belongs to one machine's localhost, and the same sandbox goes on mirroring that port everywhere else.Clear its own build output when it blocks a deletion (src/sync/residue.ts). Two-way-safe refuses to delete a directory holding content it never carried, so a
node_modulesthis device built is enough to stop a directory the sandbox deleted from ever going away here. Nothing in that standoff is a disagreement — no edit, no second copy, nothing at stake — but it was reported as one ("created on this device"), and in a pnpm monorepo, where every package carries three ignored directories, an agent moving six packages produced six of them at once. The agent now reads Mutagen's ownuntrackedkind, and where one side holds NOTHING but ignored content and the other deleted the directory, it removes the residue here and lets the deletion land. It is bounded by the thing it cannot get wrong: it removes only what the live session's own ignore list already excludes, re-read from disk rather than trusted from the report, and a single file sync would have carried takes the whole directory out of the class.sync cleandoes it on demand (the Devices tab's Clear build output button runs exactly that);sync autoheal offstops the watcher doing it unprompted. A conflict with two real copies is untouched by all of it.Register Mutagen's daemon for login autostart — on Windows through the launcher stub, because Mutagen's own registration flashes a console window at every boot.
Shared (src/): the one resident loop (run), the merged status (and its --json envelope the desktop
app's tray reads), self-upgrade with automatic rollback, the autostart spec, and where a sandbox's daemon is
dialled (src/daemon-base.ts). A sandbox usually runs in a container on this very
machine, which publishes its daemon on 127.0.0.1:<port derived from the sandbox id>, so every dial either half
makes — the device socket, both enrollments, the sync transport, the ports poll, the machine report — tries
that address first and the public URL last. A candidate is adopted only if the daemon's unauthenticated
/health answers with the id we expected: a port is not a sandbox, and a token is what would be presented to
whoever holds it. For the sync half the shortcut saves a multi-gigabyte Mutagen sync a trip out to the
reachability edge and back to the same laptop. For the device half it is reachability itself: the socket used
to dial the public URL and nothing else, so a sandbox whose tunnel was down read "offline" on its own Devices
tab, with every button there gone, while this same process was polling it over loopback. The watcher re-probes
a pairing sitting on the public URL each minute, so a container started later is promoted with no restart; the
socket re-resolves on every reconnect.
The resident loop
intentic-machine run --foreground (what systemd, launchd and the Windows launcher stub run) serves both
halves in one process (src/resident.ts):
- The sync half re-reads its pairing list every tick; the device half's link list is fixed at startup, which
is why
setuprestarts the loop (reconcileResidency) instead of poking it. - Unpairing one sandbox of several does not restart it (
startResidentIfStopped): the sync half picks the drop up by itself, and this process holds the socket every sandbox reaches this machine over — bouncing it makes the sandbox that asked for the unpair watch its own device go offline. - The process exits — and takes the login entry with it — only when both halves have nothing to serve.
- On a signal it exits
128+signal, never 0: a supervisor must restart what it did not stop, and the incident that bought that rule is written out in src/sync/mirror.ts. - It stamps the build it is running into its pidfile (
pid boot build). Nothing else knows it: replacing the binary does not touch the process, so a machine can hold a current agent and keep serving a months-old one. - It stamps what each link's socket is doing into
links.tickevery five seconds, for the same reason: the only process that knows is this one and the only process that is asked isstatus, in another terminal. See Linked vs connected.
Installed vs running
Two facts, and every surface now carries both. agents.sync in the machine report is the file at
bin/intentic-machine (src/installed.ts); watcher.build is the loop running from it.
They drift whenever a binary lands without a restart — a card's one-liner re-run, a copy dropped in, an upgrade
in one environment while the loop runs in another — and the gap used to be invisible: whichever process built the
report stamped its own version into the one field, so the same machine answered its running build to the sandbox
its loop posts to and its installed build to one reading over a host capability.
intentic-machine statussays which is which, and the summary the tray reads leads withOLD BUILD RUNNING.- The Devices row says it beside the pid, with the two commands that close it.
intentic-machine upgraderestarts a loop that is behind the installed binary even when there is nothing to download, and after a swap it verifies that the loop which came up is the new build rather than that some process is alive — the check the old agent passed just as well as the new one.- A loop already on the installed build is never bounced, and a loop that is stopped is never started:
run --stopis a thing people do on purpose.
Linked vs connected
The same gap one level down, on the command whose entire job is to answer "is my machine connected". device.json
records the sandboxes this machine is meant to answer to and nothing whatever about whether it reaches any of
them, so status printed connected as <id> for every line in it — including, on the machine this was written
for, a sandbox whose host had been answering 502 for four hours while the loop retried it every 30 seconds and
said so in a log nobody had been pointed at.
- The loop's stamp is the answer:
statusprintsconnected,NOT connected (retrying)orNOT connectedper link, and the summary the tray reads counts the links that are actually up (1 of 2 sandboxes connected). - The stamp ages on purpose, since it describes sockets held in a process that may be gone. Four ticks past
the last write it is no answer at all — which is also what an agent too old to write one leaves behind — and
both cases print
linked as <id>with the reason in words. A link nothing can vouch for must not read like a healthy one; the permissions line beneath it had been hedged as "last pushed by the sandbox" for years for the same reason, and this line had not.
Key files
- src/commands.ts — the CLI surface:
device setup|uninstall|updates,sync setup|pause|resume|mirror|uninstall, sharedrun|status|version|upgrade|uninstall. - src/install.ts — what every
setupruns first: self-update (then re-exec), PATH repair, the Windows launcher stub. Everything the install scripts used to decide, decided once here. - src/upgrade.ts —
upgrade: what is published, then download → probe → stop → swap → start, with a rollback behind every step, and a restart when the file is current but the loop is not. - src/installed.ts — which build the file at
bin/intentic-machineis, as opposed to the one running: free while the two agree, one probe per swap after they stop. - src/daemon-base.ts — where a sandbox's daemon is dialled, for both halves: loopback first when
/healthproves it is ours, the public URL as the floor. - src/resident.ts — the one loop, its pidfile, its link stamp, and
reconcileResidency. - src/device/auto-prepare.ts — the background update-download tick; the judgement about what to download stays in
ic sandbox prepare --auto, on purpose. - src/status.ts — both halves as one answer;
--jsonis what the desktop app and tray read. - src/wsl.ts — whether this is a WSL distro, and which one. WSL hands a distro the Windows machine's own hostname, so without this the sandbox cannot tell a distro from the Windows install hosting it, or one distro from its neighbour; reported at connect (
describe) as well as in the status report, so it is known even with "Run commands" off. - src/device/tools/shell.ts —
run_command, and itsin:crossing:wsl:<distro>from Windows runswsl.exe --exec sh -lcwith the script as one argument,windowsfrom a distro runs PowerShell through interop; no quoting through the first shell either way. - src/device/policy.ts — what the sandbox is permitted to do here; the security surface.
- src/device/tools/sandboxes.ts — the fleet: the
docker ps/inspectreaders, the docker verbs, and theicflows (swap, reshape, remove, runners) with the pure argv builders beside them. - src/sync/mirror.ts — the sync tick: ports reconcile, git bridge, revocation handling.
- src/sync/mutagen.ts — driving the Mutagen binary, sessions and its daemon's autostart, and classifying a conflict as build output or as two real copies.
- src/sync/residue.ts — what may be deleted here without asking, and the four separate refusals that keep it to exactly that.
How it fits
Runs on the user's machine, not in the sandbox. Installed by device.{sh,ps1} / sync.{sh,ps1} (both cards
put the same binary in ~/.intentic/machine/bin), shipped as a bun-compiled binary per platform, self-updated
by upgrade.
Those four installers are bootstrap shims: they download an agent onto a machine that has none (pinned to
the tag releases/latest resolves to, resumable, probed by running version before it may become the agent)
and exec setup. Every other decision — installed-vs-published, PATH repair, the Windows launcher stub — runs
from src/install.ts at the top of every setup: it self-updates through the same
download→probe→swap→rollback machinery as upgrade, then re-execs the new agent with the same argv, so
re-running a card's command still upgrades a machine while the rule lives in exactly one compiled, tested
place.
The one decision a shim cannot delegate is whether the installed agent can take the handover at all: device
setup is itself part of this CLI's vocabulary, so an agent older than a route rename rejects it — and the
self-update that would have replaced it lives behind that same command. Renaming computer to device did
that to every machine paired before it. So each shim runs <route> setup --help on the installed binary
first, and replaces an agent that cannot answer. The shims' bootstrap blocks are held identical per dialect by
src/installers.test.ts, which also pins that probe, that the route is named once per
file, and that no other decision creeps back into shell.
The install-and-stay-alive plumbing is
@intentic/local-agent's; the windowless Windows logon start is
_devices/win-launcher's.
Conventions & gotchas
- Bidirectional sync has no undo. The integration tests here run against real directories and a real Mutagen for exactly that reason; a unit test that mocks the sync proves nothing about the case that loses work.
- The two halves keep separate state files (
device.json,sync.json) in the one home: the loop rewrites one half's state while a concurrentsetupwrites the other's, and separate files make cross-half torn writes impossible rather than unlikely. - Scopes are a cache. The sandbox pushes the real grant on every connect; what is stored only governs the seconds before the first push, and it starts at everything-off.
- The transport lives in the resident loop, so
sync setupstarts the loop before it probes ssh or hands anything to Mutagen: every step after that one needs the port to be open.
