# drishti
**htop for your whole fleet โ with nothing installed on the remote.** If you can `ssh` into a host and its Nix daemon trusts you, you can watch its live processes, CPU, memory, swap, disk, and network. drishti ships its own agent *over the SSH connection* on first connect โ no package to install, no inbound port to open, no daemon to configure on the far end.
Browser (SolidJS) โ local parent server (Bun) โ remote agent over `ssh` stdio, on the typed reactive transport [`@kolu/surface`](https://kolu.dev/blog/surface-framework/) + [Effect RPC over ssh](https://kolu.dev/blog/orpc-over-ssh/).
## Demo
โถ **[Watch drishti in action](https://x.com/sridca/status/2060333167463088637)** โ a short screencast demoing the multi-host fleet view and live htop drill-down.
Screenshots:
<img width="1081" height="774" alt="image" src="https://github.com/user-attachments/assets/77c83113-7b13-4387-99b4-a4e60777d638" />
<img width="1081" height="774" alt="image" src="https://github.com/user-attachments/assets/b5b11751-2bc8-4866-aa8c-16068124e2fe" />
## Why drishti
- **Zero install on the remote.** The agent closure is shipped over SSH (`nix copy --derivation` then realise) on first connect and reused after. The agent is built from its own minimal derivation, so editing the UI or parent server doesn't change its hash โ a drishti upgrade doesn't force a fresh closure copy on the next reconnect. The remote needs only passwordless `ssh` + a `nix-daemon` that trusts your user โ no agent binary to install, no inbound port, no config file to drop. The agent runs as a **durable daemon** under `~/.local/state/drishti/` (gate + socket + history ring); `ssh โฆ drishti-agent --stdio` fronts it so the session outlives the SSH link and idle-exits after 60 minutes with no connections.
- **Zero config locally.** No database, no setup. Metric history is retained by the agent daemon (on disk under `~/.local/state/drishti/history.ring.json`) so charts survive parent deploys and reconnects. `nix run` and you're watching.
- **One pane, many hosts, mixed arch.** A macOS laptop can drive Linux *and* macOS remotes from a single `nix run` โ drishti probes each host's Nix system and ships the matching build.
- **Cross-OS, same facts.** Linux and macOS are observed through the same versioned [`osfacts`](https://github.com/juspay/kolu/tree/master/osfacts) contract โ including cache-aware available memory. On macOS, Apple gates other users' task info from ordinary binaries, so drishti supplements only osfacts-blind per-process CPU/RSS with the privileged system `/bin/ps`.
## Quick start
```sh
nix run github:srid/drishti # localhost (default)
nix run github:srid/drishti -- user@host # one remote
nix run github:srid/drishti -- localhost a.lan b.lan # multiple hosts (tabbed UI)
```
Open <http://localhost:7720>. The UI opens on the **fleet** tab โ a single overview pane with one live summary card per host (connection state, CPU, memory, swap, disk, load average, uptime, and a 30m CPU/memory/swap/disk history sparkline); click a card (or a host's tab) to drill into that host's full htop. Every view has its own URL, so you can bookmark or share a link straight to a host: selecting a host updates the address to <http://localhost:7720/?host=user@host> (the fleet overview is the bare <http://localhost:7720/>), and opening such a link โ or reloading โ lands directly on that host. The browser tab is titled after the **host drishti itself runs on** โ `drishti@<hostname>` (e.g. `drishti@zest`) โ so a row of tabs (or installed apps) watching different fleets is self-labelling rather than five identical `drishti` tabs.
> [!IMPORTANT]
> **drishti is single-user software.** It is meant to run on your own machine and be reached from that machine โ it binds `127.0.0.1` (loopback) by default. Its RPC surface is **unauthenticated** (no accounts, sessions, or access control), so drishti is **not** designed to be exposed to the internet or shared between users. If you put it on a public address or a shared network you do so **at your own risk** โ prefer a trusted private overlay such as [`tailscale serve`](https://tailscale.com/kb/1242/tailscale-serve), and see [Deployment](#deployment-home-manager) for the `--bind` and origin-allowlist knobs.
## Features
- **Live time-series charts** (CPU% / memory% / swap% / disk%) over a rolling 1m / 5m / 15m / 30m window, switched by a segmented control. The **agent daemon** samples every host on each poll tick and keeps a durable ring on disk โ so the chart survives page reloads, tab switches, parent deploys, and reconnects, and is already populated the first time you open a host. The **fleet overview** carries the same trend at a glance: each host card shows a compact **30m** CPU/memory/swap/disk sparkline drawn from the same ring (pinned to the widest window, no per-card picker). A corrupt or unknown-version ring surfaces as a typed "unavailable" state rather than a silently empty chart. Disk is root-filesystem (`/`) fullness โ a slow-moving capacity gauge, not disk I/O. A tab left in the **background** pauses its live views after ~20s and shows a "Paused" placeholder โ a hidden tab can't display telemetry, so it stops decoding the 2s frames for it; because the *agent* keeps sampling regardless, the full history is intact the instant the tab is back in view.
- **Runtime host management** โ the `+ add host` button adds hosts, the `ร` on each tab removes one. Added/removed hosts persist to `$XDG_STATE_HOME/drishti/hosts.json` (override with `DRISHTI_HOSTS_FILE`), so `nix run github:srid/drishti` with no args restores the last session.
- **Per-host view memory** โ the chart window, process sort column, and process filter stick across reloads via `localStorage`, remembered per host. A **light/dark toggle** (top-right of the tab strip) overrides the OS theme and is remembered globally; until you touch it, the theme follows your system preference. The address-bar / installed-app tint (the PWA `theme-color`) tracks the *chosen* theme โ driven reactively from the in-app toggle rather than the OS media query โ so it stays in step even when the toggle overrides the system preference.
- **One host-observation boundary on both OSes** โ the agent invokes the Nix-baked [`osfacts` V2](https://github.com/juspay/kolu/tree/master/osfacts) binary through its version-gated TypeScript client. One atomic host-wide snapshot supplies process identity, CPU time, UID, RSS, start time, cwd, state/nice/threads, argv, claimed listeners, and host-wide unclaimed listeners where the OS permits; one `host` reading supplies load, memory, swap, uptime, cumulative per-core CPU plus model/clock metadata, network counters, and root-disk capacity. Usable facts survive a partial source error and the named `source ยท facet ยท code` status is shown alongside them. Darwin runs `/bin/ps` beside every process snapshot and uses its CPU/RSS only where osfacts reports those facets unreadable; readable osfacts values always win. Drishti never searches `PATH` and has no `lsof`, `/proc`, `sysctl`, `vm_stat`, `netstat`, or `statfs` fallback.
- **Click a process for details** โ selecting a row opens an inline panel with CPU and memory shares, full argv, cwd, state, nice value, nullable thread count, linked parent PID, start time, and attributed listeners. Listener addresses are decoded to ordinary IPv4/IPv6 text at the render edge. The compact table has no dedicated ports column; listener addresses remain available in details and filtering. Permission-blind sockets stay visible in a separate unclaimed-listener strip (with UID on Linux when available), collapsed to its count by default rather than becoming a falsely empty process port list. Click the row again, the `โ`, or press `Esc` to close.
- **Dense host drill-down** โ host identity and headline metrics share a compact header, CPU frequency sits inline with utilization only when osfacts reports it, idle NICs and unclaimed listeners start collapsed, and command plus cwd share one process row. CPU%, USER, memory, and clock-corrected UPTIME remain sortable. A partially unreadable fact gets one subtle hoverable marker at the cell it qualifies; a recovered command-fallback value keeps its number and gains a quiet `โฉ` naming the command on hover; a fully blind process collapses to one dimmed unreadable row instead of repeating an errno across the table.
- **Idle NICs collapse** behind a `+N idle` toggle by default, so the few interfaces moving traffic aren't buried under the dozens of always-zero virtual ones (utunN, anpiN, โฆ).
- **Read-mostly, with explicit process control** โ telemetry is read-only. The sole per-host mutation is `process.kill`: the process detail panel can send SIGTERM or SIGKILL, and reports permission/race failures instead of swallowing them.
- **Installable PWA, one per host** โ drishti ships a web app manifest and an emerald aperture icon, so you can install it as a standalone app (desktop, or a phone's home screen). The manifest's name and identity are the server's own `drishti@<hostname>`, so installing drishti from two different hosts gives you **two distinct, separately-labelled apps** in the OS app list โ not one ambiguous `drishti` that collides. The shell and its assets are served by [`@kolu/surface-app`](https://github.com/juspay/kolu/tree/master/packages/surface-app) under a strict freshness contract (see below); drishti ships **no caching service worker** of its own. A **Pin app** button in the tab strip surfaces the install prompt directly โ it appears only on a secure origin (`https://โฆ`, e.g. a `tailscale serve` FQDN) and hides once drishti is already installed, gated on the `canInstallPwa` / `isInstalled` signals from `@kolu/surface-app`'s `useSurfaceApp()` and rendered via [`@kolu/solid-pwa-install`](https://github.com/juspay/kolu/tree/master/packages/solid-pwa-install) (which owns the cross-browser install volatility).
- **Always the deployed build** โ drishti adopts `@kolu/surface-app`, which owns the *freshness contract* on the wire: the HTML shell is served `no-store`, the content-hashed `/assets/*` bundle and stylesheet are pinned `immutable` for a year, an asset miss 404s (never the HTML shell under a `.js` URL), and the server serves a self-destructing `/sw.js` that unregisters any legacy caching worker an earlier drishti build left behind. The client's build commit (carried on the `no-store` HTML shell as `window.__SURFACE_APP_COMMIT__`, read via `shellCommit()` โ never baked into an `immutable` bundle, which a stamp-only deploy would leave stale; kolu#1319) rides a `buildInfo` cell on the admin surface alongside the server's resolved commit; both commits โ plus a server-connection liveness dot โ are always visible in a slim status footer pinned to the bottom of the viewport, and when a tab is provably behind the deployed build the footer grows a `โ srv` one-tap reload affordance. A returning installed client thus always re-fetches the shell and converges to the deployed build โ the stale-client class of bug, gone structurally.
## Known limitations
On macOS, Apple gates other users' task info behind a private entitlement. Drishti's ordinary, unprivileged osfacts process cannot read those CPU/RSS facts, so the agent recovers them from Apple's privileged `/bin/ps`. If that command fails, the process frame still publishes and the failure appears as a `/bin/ps` source-error status beside any still-blind cells; a process that exits between the two censuses stays honestly unreadable for that poll. Linux needs no supplement.
On macOS 27+, Apple platform signing gates the host-wide unclaimed-listener table, so osfacts reports a named `ports_unclaimed` source failure there. Claimed listeners for the agent's own processes still work.
Requirements:
- The remote host must be `ssh`-reachable with **passwordless** auth and a working **`nix-daemon`** that **trusts your user** (`trusted-users` in `nix.conf`) โ drishti provisions the agent by shipping its `.drv` to the remote with `nix copy --derivation` and realising it there.
- Localhost works without any remote setup.
**Hosts behind a bastion (SSH hops).** drishti runs plain `ssh <host>`, so a host reachable only *through* a jump box needs no drishti config โ just an `~/.ssh/config` entry with `ProxyJump`:
```
Host db-internal
HostName 10.0.0.5
ProxyJump bastion.example.com
```
Then add `db-internal` like any other host. The agent spawn, the nix-system probe, and the `nix copy` of the agent closure all hop through the bastion automatically. The jump host must itself be non-interactive from where drishti runs (drishti forces `BatchMode=yes`), so a bastion that prompts for a password fails rather than hangs โ use a key.
Mixed-architecture host sets are supported: the monitor bakes ONE agent source (`SURFACE_AGENT_FLAKE_REF`), and `@kolu/surface-remote` probes each host's nix-system on add (asking the host's own Nix for `builtins.currentSystem`) and evaluates `packages.<host-system>.drishti-agent` from it โ so one `nix run` drives every architecture, and the agent identity (`DRISHTI_AGENT_BUILD_ID`) is the same value on all of them.
## Architecture
```
Browser (SolidJS UI, tab strip)
โ one WebSocket (admin + keyed host map)
โผ
Parent server (Bun, drishti)
โ ssh stdio front โ unix socket (Effect RPC, ndjson) รN
โผ
Host 1: drishti-agent daemon Host 2: drishti-agent daemon โฆ
โ ~/.local/state/drishti/ (gate, socket, history.ring.json)
โ osfacts V2; Darwin /bin/ps supplements blind process CPU/RSS
โผ
Kernel
```
The browser opens one WebSocket at `/rpc/ws?host=__admin__`. That transport multiplexes the admin procedures, surface-app build identity, and a keyed `@kolu/surface-map` entry for every monitored host. Host identity is the map key rather than a dedicated socket; each entry carries its own fine-grained connection state while sharing the one transport-liveness floor.
Per-host primitives:
| Primitive | Path | Purpose |
|---|---|---|
| **Cell** | `system` | Load averages, cache-aware used/total memory, swap, root-disk (`/`) capacity, uptime, OS, and hostname. All numeric observations come from one cached osfacts V2 `host --load --mem --cpu --net --disk` frame. |
| **Collection** | `processes` | Host-wide, keyed by PID โ short name plus capped full argv, CPU%, rendered UID, cwd, state/nice/nullable threads, PPID, RSS, start identity, listeners, facet-specific `unreadable[]`, and per-facet command `fallbacks[]` provenance. On Darwin, `/bin/ps` fills only osfacts-blind CPU/RSS values. A `U`-only pid remains represented. Snapshot-then-delta. |
| **Collection** | `unclaimedListeners` | Readable listening sockets whose owning pid could not be attributed โ `{ port, address, uid }`. This preserves every unclaimed listener osfacts can observe even when per-process descriptor inspection is blind. |
| **Collection** | `sourceErrors` | Named osfacts `E` rows (`operation`, `source`, `facet`, `code`) accompanying otherwise usable partial frames. |
| **Collection** | `cpuCores` | Per-core CPU usage, model, and nullable MHz. The whole grid rides the collection's batched deltas. |
| **Collection** | `networkInterfaces` | Per-NIC network I/O from osfacts V2, keyed by interface name โ `{ rxBytes, txBytes, rxRate, txRate }` (cumulative bytes since boot + bytes/sec throughput); loopback filtered out. The UI strip collapses idle interfaces behind a `+N idle` toggle. |
| **Stream** | `metricHistory` | Agent-daemon-owned durable CPU/memory/swap/disk history ring on disk (`~/.local/state/drishti/history.ring.json`): full retained snapshot on subscribe, then one delta per poll. Survives parent deploys and reconnects; corrupt/unknown-version rings surface as a typed `unavailable` standing state (never a silently empty chart). |
| **Procedure** | `process.kill` | Send an allowed signal to a PID on the host that owns it. The UI exposes TERM and KILL. |
The process, listener, source-error, CPU, and network collections are observation-only. `process.kill` is the one explicit per-host mutation.
Admin surface primitives:
| Primitive | Path | Purpose |
|---|---|---|
| **Host map** | `hosts.entries` | Configured hosts, keyed by host string, with entry status and per-host connection state. |
| **Procedure** | `hosts.add` | Spin up a new host session; persists to the hosts file. |
| **Procedure** | `hosts.remove` | Tear down a host session; persists removal. |
| **Procedure** | `hosts.reconnect` | Re-arm a configured host whose session reached a terminal failure. |
| **Procedure** | `hosts.recheck` | Ask every host session to probe again after browser focus/network recovery. |
## Development
```sh
just dev # parent server :7720, host=localhost
just dev user@somehost # any ssh target with passwordless access
just dev localhost a.lan b.lan # multiple hosts (per-host arch probe)
just typecheck # tsc --noEmit across the workspace
just fmt # nixpkgs-fmt everything *.nix
just nix-build # build the wrapped monitor binary
just regenerate-bun-nix # after any bun.lock change
```
`just dev` builds the agent source (`.#agent-flake-source`) and exports it as `SURFACE_AGENT_FLAKE_REF`, plus `DRISHTI_AGENT_BUILD_ID` (the agent identity the supervisor expects), then boots Bun in watch mode. Each host's agent is resolved **from that source** by `@kolu/surface-remote` itself โ it probes the host's nix-system and evaluates `packages.<host-system>.drishti-agent` โ so one dev session can mix architectures with nothing per-system held on the shell side. The probe runs inside the connection's spawn cycle, so a host that's unreachable when the probe fires (offline remote, stale ssh-agent) folds into the same retry path as any other connection failure rather than aborting startup. The dev server invokes `buildClient()` at startup so a single `bun --watch` covers both server-TS and client-bundle rebuilds. Browser refresh is manual โ there's no HMR.
The first connect to a fresh remote lands the agent closure there. CI publishes the agent **content-addressed** (`publish-closures` in `.github/workflows/nix-cache.yml`) and indexes it on this repo's `ca-index` branch; on a **Linux host running Nix 2.17โ2.34 the host pulls that exact path itself** (`nix copy --from <cache>`) โ nothing has to be trusted on the host, and the sender neither rewrites nor ships anything. Everywhere else (macOS, or a Nix line outside 2.17โ2.34) the signed closure is used instead and **the host must trust it** (a `trusted-users` ssh user, or the cache's public key in the host's `nix.conf`); a revision with no published entry โ a local build, an unpublished branch โ falls back to shipping the closure over `ssh` (`nix copy --derivation` then `nix-store --realise`), which needs the same trust. Subsequent connects reuse whatever landed. The realised closure is pinned behind a per-host GC root on the remote, so a `nix-collect-garbage` there can't delete the agent out from under a live session or force a rebuild on the next reconnect. The progress is streamed to the browser via the `connection` cell, and the overlay shows how long the current phase has been running ("Connectingโฆ 18s") so a slow connect reads as abnormal. A dropped link reconnects automatically (the tab pulses amber, "Reconnectingโฆ"), and what happens next depends on *why* it's down. If the host is simply **unreachable** โ offline, asleep, or you've roamed onto a different network โ drishti keeps retrying indefinitely at a capped backoff (the overlay reads "Host unreachable โ retryingโฆ"), so the link comes back on its own once the host is reachable again, no clicking. Only a **remote rejection** โ the host answered but its `nix-daemon` won't accept the closure because your user isn't in `trusted-users` โ is treated as terminal: after a few attempts the host enters a **failed** state, since retrying can't fix a misconfiguration. drishti then shows the underlying error, the captured connection log (the real `nix copy`/`ssh` output โ not a guess at the cause), and a **Reconnect** button that re-arms the session in place, so you don't have to restart the parent. A connect that comes up but never completes its first RPC โ transport alive, handshake wedged โ is timed out by a watchdog rather than hanging in "connecting" forever. A host that's unreachable from the very start โ offline when the monitor launches โ just keeps retrying while every reachable host is monitored normally, instead of one bad host crashing the monitor before its HTTP port is even bound.
Because drishti is usually run on a laptop, it also recovers from **sleep**: close the lid at home, reopen at a cafรฉ, and the parent detects the wake and immediately re-probes every host's link rather than waiting ~30s for each stale SSH connection to notice it's dead. The browser does the same when it regains connectivity (`online`) or you refocus the tab.
## Deployment (home-manager)
A home-manager module runs the monitor as a systemd user service on Linux and as a launchd LaunchAgent on macOS:
```nix
{
imports = [ drishti.homeManagerModules.default ];
services.drishti = {
enable = true;
package = drishti.packages.${system}.default;
port = 7720; # default
hosts = [ "user@host-a" ]; # optional; empty = manage hosts at runtime
};
}
```
The monitor binds **`127.0.0.1` (loopback) by default** โ its RPC surface is unauthenticated, so out of the box it is reachable only from the machine drishti runs on. To reach it from elsewhere, front it with a trusted reverse proxy (e.g. `tailscale serve`, which forwards to loopback), or โ behind a firewall โ bind all interfaces explicitly with `--bind 0.0.0.0` (`services.drishti.bind = "0.0.0.0"`, or the `DRISHTI_BIND` env var). When a proxy serves the UI from a different origin than the `Host` drishti receives, allowlist that origin via `services.drishti.allowedOrigins` (the `DRISHTI_ALLOWED_ORIGINS` env var) so the browser's WebSocket clears the same-origin check. `hosts` are passed as positional arguments; leave it empty to let drishti seed from its persisted hosts file (`$XDG_STATE_HOME/drishti/hosts.json`, overridable via `services.drishti.hostsFile`) and manage the set from the admin surface. The packaged wrapper already bakes `DRISHTI_DIST_DIR` / `DRISHTI_AGENT_DRVS_JSON` and puts `openssh` + `nix` on `PATH`, so the service self-contains its runtime needs.
See [`nix/home/example/`](nix/home/example/) for a full configuration โ a NixOS VM test exercises the systemd path on Linux, and a standalone home-manager activation build exercises the launchd path on Darwin.
On macOS, the LaunchAgent writes stdout to `~/Library/Logs/drishti.out.log` and stderr to `~/Library/Logs/drishti.err.log`, so crashes and startup failures leave logs alongside other user logs. Every stderr line is timestamped (ISO-8601) and tagged by subsystem โ `[server]`, `[hosts]`, `[admin]`, and one `[bridge:<host>]` per monitored host โ so a connection problem can be traced to a specific host on a timeline (on Linux/systemd, `journalctl --user -u drishti` adds its own timestamps).
## Project layout
```
drishti/
โโ flake.nix # one input: juspay/bun2nix (rawflake)
โโ default.nix # composer โ exposes drishti, drishti-agent, drishti-client
โโ shell.nix # mkShell; dependency setup is `just install`
โโ package.json # bun workspaces ["packages/*"]
โโ bunfig.toml # [install] linker = "hoisted"
โโ bun.nix # generated by bun2nix
โโ justfile
โโ npins/sources.json # nixpkgs + kolu pins
โโ nix/
โ โโ nixpkgs.nix
โ โโ overlay.nix # kolu-surface, kolu-surface-remote, โฆ
โ โโ env.nix # DRISHTI_KOLU_SURFACE{,_NIX_HOST}
โ โโ packages/
โ โ โโ kolu-package.nix # mkKoluPackage factory
โ โ โโ drishti/default.nix # monitor + client build (full app tree)
โ โ โโ drishti-agent/default.nix # scoped agent build โ minimal inputs (issue #38)
โ โโ home/
โ โโ module.nix # home-manager module (systemd / launchd)
โ โโ example/ # example config โ CI-built (VM + launchd checks)
โโ scripts/
โ โโ hydrate-kolu-packages.sh
โโ packages/ # bun workspace members ["packages/*"]
โโ common/src/surface.ts # per-host wire contract โ agent + monitor share it
โโ agent/src/{main.ts, proc.ts} # remote-side agent (its own scoped build)
โโ app/
โโ src/
โโ common/{metrics.ts, history.ts, admin-surface.ts} # monitor-internal shared (admin surface + metric math)
โโ server/ # parent server
โ โโ main.ts # multi-host WS dispatch
โ โโ router.ts # per-host router fragment
โ โโ admin-router.ts # host-set router fragment
โ โโ hostRegistry.ts # per-host session pool
โ โโ hostsStore.ts # $XDG_STATE_HOME/drishti/hosts.json
โ โโ build.ts # client bundler
โโ client/ # SolidJS UI
โโ App.tsx # MultiHostApp + TabStrip + HostView
โโ wire.ts # one admin socket + keyed host surface map
โโ {main.tsx, index.html, styles.css}
```
### How `@kolu/surface` is wired
`@kolu/surface`, `@kolu/surface-remote`, `@kolu/surface-app`, and `@kolu/solid-pwa-install` aren't on npm. They're vendored from the `juspay/kolu` repo via npins, exposed as Nix-store paths through `nix/overlay.nix`, and hydrated into `node_modules/@kolu/*` by `scripts/hydrate-kolu-packages.sh`. Run `just install` to install and hydrate dependencies before invoking tools directly. Entering `nix develop` supplies tools and environment only; it does not rewrite dependencies while another check may be reading them. The CI DAG shares one install prerequisite before its parallel checks. The hydrate script takes `(src, dest)` pairs so adding another `@kolu/*` package is a one-line addition to the overlay + env โ as `@kolu/solid-pwa-install` demonstrates.
To bump the kolu pin:
```sh
nix shell nixpkgs#npins -c npins update kolu
```
### Why Bun.build, not Vite
The client bundler is a hand-rolled `Bun.build` pipeline (`packages/app/src/server/build.ts`) with a small `solidJsxPlugin` (babel-preset-solid + babel-preset-typescript). Same bundle code path runs in dev (server invokes it at startup) and Nix (build derivation runs it during `buildPhase`). Tailwind v4 compiles via `@tailwindcss/cli` as part of the same pipeline.
### CI
CI runs via [odu](https://github.com/juspay/odu) โ "a CI runner you attach to" โ invoked straight from upstream (`nix run github:juspay/odu -- run`). odu ships its own generic lane runner (`nix copy`d to remote lanes), so this repo re-exports nothing. The canonical pipeline is the `[metadata("ci")]` DAG in `ci/mod.just`; it builds every flake output, type-checks, checks formatting + `bun.nix` freshness, asserts agent BUILD_ID stability, evaluates the agent source for every supported system, and boots the home-manager example. Lane hosts come from `~/.config/odu/hosts.json` (falling back to `~/.config/justci/hosts.json`); a live run is attachable (`nix run github:juspay/odu -- attach`) and agents drive it through odu's MCP server (`mcp__odu__*`).
One upstream issue currently shapes how CI runs:
- **crates.io blocks `curl/*` User-Agent.** Every `crate-*.tar.gz` fetched by `bun2nix`'s rust dep tree fails its `pkgs.fetchurl` with HTTP 403. `ci/mod.just`'s `_prefetch-crates` recipe (`scripts/ci-prefetch-crates.sh`) sidesteps this by fetching missing crates with a Mozilla UA and injecting them via `nix-store --add-fixed`. Idempotent and content-addressed, so the workaround disappears the first time upstream fetchurl learns to set a non-curl UA โ or once bun2nix's Rust deps fetch from `static.crates.io` directly. Tracking issue: TBD.
### Binary cache
Builds are pushed to Juspay's shared OSS Attic cache at [`cache.nixos.asia/oss`](https://cache.nixos.asia). `.github/workflows/nix-cache.yml` has two jobs: `build-and-push` builds the default package on linux + darwin for every push and PR and pushes each closure, and `publish-closures` rewrites the **agent** closure content-addressed, smokes it, pushes it, and publishes the index (`<rev>/<system>/drishti-agent.json` on the `ca-index` branch) that a host pulls through. The cache is wired in as a substituter through the flake's `nixConfig` โ and, for provisioning, through the agent source's own baked `binary-cache.json`, which `nix/agent-flake.nix`'s `nixConfig` derives โ so a local `nix build` pulls prebuilt closures instead of rebuilding: accept the flake config (`accept-flake-config = true`, or answer `y` when prompted) to opt in.
What a monitored host needs, by platform: **Linux on Nix 2.17โ2.34 needs nothing** โ no `trusted-users`, no cache key; the index-route pull is trust-free. **macOS (and any Nix outside 2.17โ2.34) needs trust** โ the signed closure is the only form those lines accept, so the ssh user must be in the host's `trusted-users` (or the cache's public key configured). The `ca-index` branch is born on the first push to `master`; before that, and for any revision it has no entry for, provisioning falls back to the ship-over-ssh route.
### License
AGPL-3.0-or-later (matches upstream `@kolu/surface`).