Skip to content

Integration: mosh, pi-vim, and moshi-hook - #2775

Merged
cameronraysmith merged 6 commits into
mainfrom
aggregate/mosh-pivim-moshi
Aug 19, 2026
Merged

Integration: mosh, pi-vim, and moshi-hook#2775
cameronraysmith merged 6 commits into
mainfrom
aggregate/mosh-pivim-moshi

Conversation

@cameronraysmith

Copy link
Copy Markdown
Owner

Aggregate gate for three independently reviewable changes, chained in this order:

  1. Declare mosh on every machine for connection-resilient terminal sessions #2774 declares mosh on every machine, with the UDP range scoped to the mesh interfaces rather than opened globally.
  2. feat(ai): enable pi-vim modal editing for pi only #2748 enables pi-vim modal editing for pi only.
  3. Package moshi-hook and run its daemon from home-manager on macOS and Linux #2773 packages moshi-hook and runs its daemon from home-manager on macOS and Linux.

The head of this pull request is the same commit as the top of that chain, so fast-forwarding main to it lands all three with no merge commit and closes each by reachability. Each change touches a disjoint set of files; review happens on the individual pull requests, not here.

cameronraysmith and others added 6 commits August 19, 2026 12:41
Terminal sessions over a mobile link die with the TCP connection. mosh
keeps the session across address changes and sleep, and bootstraps over
an ordinary SSH login, so it reuses the keys and certificate trust
already declared rather than adding an authentication path.

modules/system/mosh.nix contributes to both base namespaces, the way
caches.nix and zram-swap.nix already do, so every machine that imports
base picks it up without a per-host edit.

On NixOS, programs.mosh.enable installs the package and the utempter
setgid wrapper (upstream default, lets `who` see mosh sessions).
openFirewall is off because upstream opens 60000-61000 on every
interface; the rule here is instead scoped to zt+, matching the
existing interfaces."zt+" convention in cinnabar/caddy.nix and
magnetite. The range is 60001-60999, the PORT_RANGE_LOW/PORT_RANGE_HIGH
constants mosh-server actually binds between (src/network/network.h);
network.cc scans upward from the low end for the first free port, one
per live session.

On darwin the package is all that is needed: pf is enabled on stibnite
but loads only Apple's own anchors, none of which carry a block rule,
and the application firewall is off, so inbound UDP already passes.
nix-darwin's /etc/zshenv puts /run/current-system/sw/bin on the PATH of
a non-interactive zsh, which is how the SSH-spawned mosh-server is
found.

Validation: the change touches every machine's evaluated config, so the
selection is all ten of them - nix build of the six nixosConfigurations
toplevels and the four darwin-<host> checks, plus
clan-inventory-consistency, naming-conventions, eval-md-format, and nix
fmt --ci; all pass. Verified in the built closures rather than inferred:
cinnabar's firewall-start renders exactly one new rule, `ip46tables -A
nixos-fw -p udp --dport 60001:60999 -j nixos-fw-accept -i zt+`, and both
cinnabar's and stibnite's system-path carry bin/mosh-server. Also
checked that no host gained a global 60000-61000 opening; magnetite's
pre-existing allowedUDPPortRanges are unchanged. Not run: the k8s,
nixidy, and package checks, which no part of this diff reaches.

Activation is a normal deploy per host; no service restart beyond the
firewall unit NixOS reloads itself, and nothing listens until a client
starts a session.
Register lajarre/pi-vim 0.14.1 with pi through a new
aiAgentSettings.piOnlyPackages option, packaged from the npm tarball with
one downstream substituteInPlace.

The patch is load-bearing. clipboard-mirror.ts resolves the host agent's
module URL at module scope to embed it in a clipboard helper child
process, and pi satisfies that specifier through its virtual-module map
rather than node_modules, so import.meta.resolve throws and takes the
whole extension down. --replace-fail turns a moved call site into a build
failure per version bump rather than a silently unpatched output.

piOnlyPackages is a third divergence channel beside packagesForAtomic and
extensionsForAtomic, and unlike them it needs no force-exclude: atomic
declaring its own `packages` key shadows pi's array wholesale, so an entry
reaches pi alone. atomic stays excluded because its setEditorComponent is
a warn-once stub never remoted to the isolated interactive engine child --
under the npm-dist atomic build pi-vim loads at exit 0 with a dead editor,
which is worse than not loading it. That reason is recorded on the option
itself and in docs/notes/development/ai-agents/pi-vim-modal-editing.md.

Tests. package-pi-vim, pi-agent-environment-{structural,policy,smoke},
atomic-agent-environment-structural, home-manager-crs58,
naming-conventions, eval-md-format, treefmt. That is the option, its two
consumers, the two regulators asserting the divergence, the derivation,
and the only home configuration taking the ai aggregate -- crs58 is the
sole user whose meta.nix lists it.

The smoke regulator was falsified deliberately: with postPatch dropped it
fails at its returncode guard with the exact module-resolution error, so
it discriminates the patched build from the unpatched one rather than
merely passing.

Loading is not working, so behaviour is covered separately by
pi-vim-modal-editing-probe.py, run on both arms: with pi-vim, Esc then `x`
deletes a character and INSERT/NORMAL labels render; without it the buffer
takes a literal `x` and no labels appear. It stays out of flake checks
because every wait in it is a wall-clock sleep against TUI repaint, which
a parallel build sandbox would make flaky.

Left out: the darwin-* system closures and home-manager-cameron, an alias
resolving to the same crs58 aggregates.
moshi-hook is the companion daemon for the Moshi mobile app: it installs
hook configuration into supported coding agents, serves the local Unix
socket those hooks post events to, and holds a WebSocket to Moshi for
approval round-trips.

The vendor publishes no source repository. Both documented install paths,
the rjyo/moshi Homebrew tap and getmoshi.app/install.sh, resolve to the
same prebuilt tarballs under cdn.getmoshi.app, and the tap's formula is
generated by the vendor's release script from those assets. Pinning the
four tarballs directly therefore reproduces what Homebrew would install
while leaving the daemon inside the nix closure.

Hashes come from the vendor's per-release checksums.txt, which update.sh
reads rather than re-hashing downloads, so the pinned digests stay equal
to the published ones. Darwin assets carry a Developer ID signature with
the hardened runtime, so stripping is suppressed there; stripping rewrites
the Mach-O and macOS then refuses to exec it.

Verified with `nix build .#moshi-hook`, whose versionCheckHook execs the
installed binary.
One option surface, two service backends: `services.moshi-hook` describes
the daemon once and the platform decides whether it becomes a launchd user
agent or a `moshi-hook.service` systemd user unit, the name upstream's own
documentation tells operators to restart. Both run the same launcher, so
the pairing gate, state layout, and failure behaviour are identical and
only the supervisor differs.

home-manager is the layer because the daemon is per-user: it owns a socket
that this user's agent processes post hook events to and a WebSocket
carrying this user's pairing. Nothing about it belongs to a system-wide
service manager.

Upstream defaults the macOS secret store to Keychain, which is unreachable
from an SSH session against a locked login keychain and therefore unusable
for a daemon meant to be driven from a phone. Setting MOSHI_STATE_DIR
switches the store to <state>/secrets.json on macOS as well as Linux, so
one launcher serves both platforms. That environment is bound to the
package rather than to the service because the service is not the only
caller: the installed hook entries are executed by agent processes,
`moshi-hook status` is run by hand, and the iOS app probes over SSH.

The pairing token is read only when the daemon reports itself unpaired,
since `pair` rotates the host secret, and is passed through
MOSHI_PAIRING_TOKEN so it never reaches the process listing.

Agent-hook reconciliation orders the two writers rather than merging them.
Our generators reinstall ~/.claude/settings.json and ~/.codex/config.toml
wholesale on every activation, so an activation entry sequenced after them
re-runs `moshi-hook install`. Emitting moshi's entries from our own
generators was rejected: they embed moshi's binary path and a per-agent
event schema upstream versions independently, and `install` also writes
TypeScript extension modules and a hermes plugin directory that no
settings generator models.

Checked against moshi-hook 0.2.86 rather than assumed: `install` appended
its hooks beside nix-declared PreToolUse and SessionStart entries without
touching them, a second run left the file byte-identical, and `uninstall`
restored the file to its pre-install content.
The token registers a host with Moshi, so the daemon is handed the
decrypted path rather than the value, at mode 0400.

Seeded blank: sops-nix validates every declared key when the generation is
built, so the key has to exist before the token does. A blank value is the
not-yet-paired state, and the daemon starts and serves its local socket
regardless. Replace it with the token from the app's Settings -> Hooks
screen via `just edit-secret secrets/home-manager/users/crs58/secrets.yaml`,
then switch; the launcher pairs on the next start.
@mergify

mergify Bot commented Aug 19, 2026

Copy link
Copy Markdown

⚠️ The sha of the head commit of this PR conflicts with #2773. Mergify cannot evaluate rules on this PR. Once #2773 is merged or closed, Mergify will resume processing this PR. ⚠️

@cameronraysmith
cameronraysmith merged commit 98c49d4 into main Aug 19, 2026
8 checks passed
@cameronraysmith
cameronraysmith deleted the aggregate/mosh-pivim-moshi branch August 19, 2026 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant