Skip to content

Package moshi-hook and run its daemon from home-manager on macOS and Linux - #2773

Open
cameronraysmith wants to merge 4 commits into
mainfrom
fm/vx-moshi-hook
Open

Package moshi-hook and run its daemon from home-manager on macOS and Linux#2773
cameronraysmith wants to merge 4 commits into
mainfrom
fm/vx-moshi-hook

Conversation

@cameronraysmith

Copy link
Copy Markdown
Owner

Replaces the ad-hoc Homebrew install of the Moshi companion daemon with a
declarative module that works the same on every host where agent sessions run.

What moshi-hook is

It installs hook configuration into supported coding agents, serves the local
Unix socket those hooks post events to, exposes a localhost gateway the iOS app
forwards to for the diff and browser-preview views, and holds a WebSocket to
Moshi for approval round-trips and status.

Packaging

pkgs/by-name/moshi-hook pins the vendor's four release tarballs. There is no
source build to do: the vendor publishes no source repository, and both
documented install paths — the rjyo/moshi Homebrew tap and
getmoshi.app/install.sh — resolve to exactly these assets under
cdn.getmoshi.app. The tap's formula is generated by the vendor's own release
script from the same URLs and digests, so pinning them directly reproduces what
Homebrew would have installed while keeping the daemon inside the nix closure
and off the Homebrew upgrade path. update.sh reads the vendor's per-release
checksums.txt rather than re-hashing downloads, so the pinned digests stay
equal to the published ones.

Two platform details are load-bearing. The Linux assets are statically linked
Go, so nothing needs patchelf. The darwin assets carry a Developer ID signature
with the hardened runtime; stripping rewrites the Mach-O and macOS then refuses
to exec the binary, so stripping is suppressed there.

One option surface, two backends

services.moshi-hook is a home-manager module in the ai aggregate, because
the daemon is per-user rather than per-machine: it owns a socket that this
user's agent processes post to and a WebSocket carrying this user's pairing.
Options cover only what the daemon needs — enable, package,
pairingTokenFile, stateDir, socketPath. macOS realizes it as a launchd
user agent, Linux as a moshi-hook.service user unit named to match what the
vendor's own documentation tells you to restart. Both run the same launcher
script, so the pairing gate, state layout, and failure behaviour are identical
and only the supervisor differs.

The macOS secret store defaults to Keychain, which is unreachable from an SSH
session against a locked login keychain — unusable for a daemon whose whole
point is being driven from a phone. Setting MOSHI_STATE_DIR switches the store
to <state>/secrets.json on macOS as well as Linux; verified against
moshi-hook 0.2.86, where status --json then reports "secretStore":"file".
That single override is what lets one launcher serve both platforms.

The environment is bound to the package via a wrapper rather than to the
service, because the service is not the only caller: the hook entries are
executed by the agent processes, moshi-hook status is run by hand, and the iOS
app probes the host over SSH. A caller that missed the override would look for a
different socket and, on macOS, a different secret store.

Pairing token

The token arrives as a sops-nix secret at mode 0400 and is handed to the daemon
as a path. The launcher reads it only when the daemon reports itself unpaired —
pair rotates the host secret, so calling it every start would invalidate the
credential the app already holds — and passes it through MOSHI_PAIRING_TOKEN
so it never appears in the host's process listing. Nothing writes it into a nix
store path and Keychain is never involved.

sops-nix validates every declared key when the generation is built, so the key
has to exist before the token does. It is seeded blank; a blank value is the
not-yet-paired state, and the daemon starts and serves its local socket
regardless.

Agent-config reconciliation

moshi-hook install writes hook entries into agent config files this repository
also generates — ~/.claude/settings.json and ~/.codex/config.toml are
reinstalled wholesale on every activation, so anything moshi added is gone by
the time the daemon next starts.

The choice here is to let moshi-hook remain the sole writer of its own entries
and to order the two writers rather than merge them: nix installs its declared
configuration first, then an activation entry sequenced after every generator
runs moshi-hook install, which re-adds moshi's entries on top. Every
activation converges to the same state.

The alternative — teaching our generators to emit moshi's entries — was
rejected because it pins two things we do not own. The entries embed
moshi-hook's own binary path and a per-agent event schema the vendor versions
independently, so every upstream bump would silently desynchronize them. And
install writes more than settings keys: TypeScript extension modules for
pi, omp, and opencode, and a plugin directory plus a config entry for hermes.
Reproducing that in nix would be reimplementing the vendor's installer.

Ordering composes because install is additive and idempotent, which was
checked rather than assumed against moshi-hook 0.2.86: it appended its hooks
beside nix-declared PreToolUse and SessionStart entries without touching
them, a second run left the file byte-identical, and uninstall removed exactly
its own entries and restored the file to its pre-install content. The
reconciliation step reports failure without aborting activation, so a malformed
config file belonging to an agent this repository does not manage cannot fail an
otherwise valid generation switch.

Verification

Built green:

  • .#packages.aarch64-darwin.moshi-hook and .#packages.x86_64-linux.moshi-hook
    versionCheckHook execs the installed binary on each, which is also what
    proves the darwin signature survived packaging.
  • .#checks.aarch64-darwin.home-manager-crs58, .#checks.aarch64-darwin.home-manager-cameron,
    and .#checks.x86_64-linux.home-manager-cameron.
  • .#checks.aarch64-darwin.darwin-blackphos and .#checks.x86_64-linux.nixos-cinnabar
    — one host configuration per platform.

That selection is the narrowest thing that would fail if this change were
wrong. The module is consumed by exactly one home-manager aggregate, and the
activationPackage checks are enumerated per user automatically, so no new check
file is needed; the two host builds add the platform-specific service
realization on top of the same home closure.

Inspected in the built outputs rather than inferred:

  • The launchd plist and the systemd unit each render only on their own
    platform, and are absent from the other.
  • The activation script orders moshiHookReconcile after linkGeneration,
    claudeCodeMutableSettings, codexMutableSettings, ompMergeConfig, and
    piCodingAgentMutableSettings, and before setupLaunchAgents.
  • The wrapper sets MOSHI_STATE_DIR and MOSHI_SOCKET_PATH as overridable
    defaults, and the moshi alias resolves to the wrapper rather than past it.
  • The secret renders at mode 0400.

Ran the generated launcher directly: it reports the blank token, starts the
daemon, and the socket appears at 0600 with the gateway listening. Also
confirmed against 0.2.86 that a full install skips absent agents and exits
zero, so the activation step is quiet in the normal case.

Not run: the full nix flake check, which is dominated by work unrelated to
this change, and .#packages.aarch64-linux.moshi-hook, because no builder for
that platform was reachable and no machine here targets it. The platform is
still declared, since the vendor publishes the asset.

Pairing itself is not covered here — it needs the token from the app.

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

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

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