Integration: mosh, pi-vim, and moshi-hook - #2775
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Aggregate gate for three independently reviewable changes, chained in this order:
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.