···11-{ self, pkgs, ... }:
22-{
33- # herdr: agent multiplexer (https://herdr.dev). Keeps coding-agent terminals
44- # running in a background server that survives a dropped ssh session, marks
55- # every pane working/blocked/idle and joins saved ssh machines into one
66- # window, so a herd of agents across hosts can be driven from one client.
77- # Taken from llm-agents (tracks upstream releases; nixpkgs lags).
88- environment.systemPackages = [ self.inputs.llm-agents.packages.${pkgs.system}.herdr ];
99-1010- # Multi-machine: `herdr machine add <host>.r --label <host>` on the client
1111- # host saves an ssh profile in ~/.local/state/herdr/client/endpoints.json and
1212- # starts the remote server. No bootstrap download is needed because
1313- # systemPackages puts herdr in the non-interactive ssh PATH
1414- # (/run/current-system/sw/bin), and every machine here shares the llm-agents
1515- # pin, so client and server versions match as long as they are deployed
1616- # together. A machine lagging behind shows "Attention" until it is updated.
1717-1818- # The whip app on massulus (herdr's mobile client) drives the agents over
1919- # ssh as lass, so it gets in wherever herdr runs.
2020- users.users.mainUser.openssh.authorizedKeys.keys = [ self.keys.ssh.whip_massulus.public ];
2121-}
···4545 # This is what the WakeHermesClaw app on massulus points at when it is not on
4646 # retiolum: the assistant button posts to /v1/chat/completions here.
4747 #
4848- # SECURITY: behind this vhost sits an agent with terminal tools and, through
4949- # the herdr wrapper, code execution as lass. The gateway's only check on this
4848+ # SECURITY: behind this vhost sits an agent with terminal tools. The
4949+ # gateway's only check on this
5050 # lane is the API_SERVER_KEY bearer token — there is no per-user allowlist like
5151 # MATRIX_ALLOWED_USERS on the Matrix lane, and the app cannot do mTLS (it
5252 # builds a bare OkHttpClient with no KeyManager), so a client certificate is
-191
configs/omnigent.nix
···11-let
22- domain = "omni.lassul.us";
33- port = 8771;
44- stateDir = "/var/lib/omnigent";
55-in
66-{
77- config,
88- lib,
99- pkgs,
1010- self,
1111- ...
1212-}:
1313-let
1414- # Server-side agent catalog entry so a browser session can pick omp without
1515- # any per-user config: the generic ACP harness drives `omp acp` (omp speaks
1616- # the Agent Client Protocol natively). The command is unqualified on purpose
1717- # — it is resolved on the *host* that runs the session, not here, so that host
1818- # needs omp on PATH (`covibe host` registers one that way).
1919- #
2020- # This has to be a single-file spec, NOT a bundle directory with config.yaml:
2121- # the bundle parser stringifies nested values under `executor.config`, so
2222- # `acp_agent` arrives as "{'name': …}" and every turn dies with
2323- # "turn setup failed: executor acp_agent must be a mapping with name and
2424- # command" — while the session itself sits there looking idle. The single-file
2525- # parser keeps it a mapping. `--agent` accepts either shape (spec
2626- # materialize_bundle branches on file-vs-directory).
2727- ompAgent = pkgs.writeText "omnigent-agent-omp.yaml" ''
2828- name: omp
2929- description: Oh My Pi (omp) over ACP — runs on whichever machine hosts the session.
3030- prompt: You are omp (Oh My Pi), a concise coding assistant.
3131-3232- executor:
3333- harness: acp
3434- acp_agent:
3535- name: Oh My Pi
3636- command: omp acp
3737- # See tools/covibe/flake-module.nix: omp fails session/new outright
3838- # when omnigent's MCP relay cannot start, and the relay's
3939- # `python -I -m omnigent…` child cannot import omnigent under a
4040- # nixpkgs-wrapped interpreter.
4141- omnigent_mcp: false
4242-4343- os_env:
4444- type: caller_process
4545- cwd: "."
4646- sandbox:
4747- type: none
4848-4949- # Without a declared terminal the dashboard refuses to open one ("Terminals
5050- # can only be created for agents whose spec declares them"), so a session
5151- # started from the browser would have no way to poke at the machine it runs
5252- # on. One entry is enough; the panel can open several instances of it.
5353- terminals:
5454- shell:
5555- command: bash
5656- os_env: inherit
5757- '';
5858-5959- # One identity per line. Admins bypass every per-session ACL, which is what
6060- # makes sharing manageable at all: a session's creator gets LEVEL_OWNER, and
6161- # only owners/managers can grant others read (watch + diffs) or edit (steer).
6262- admins = pkgs.writeText "omnigent-admins" ''
6363- lass@lassul.us
6464- '';
6565-in
6666-{
6767- # omnigent: self-hosted agent meta-harness. The server owns sessions,
6868- # transcripts, per-session ACLs (read / edit / manage / owner), the per-file
6969- # diff view and the browser UI; the agents themselves run on whichever
7070- # machine registers as a *host*, so no coding agent runs on neoprism unless
7171- # someone points a host at it. `s covibe` in a project directory is the
7272- # client: it runs omp locally and registers the session here.
7373- #
7474- # Browser auth is the local pocket-id IdP. pocket-id has no declarative
7575- # client API, so register the client by hand at https://id.lassul.us:
7676- # name: omnigent
7777- # callback URL: https://omni.lassul.us/auth/callback
7878- # logout URL: https://omni.lassul.us/
7979- # then paste its id + secret into the clan prompt below.
8080-8181- # Generated cookie-signing secret plus the prompted pocket-id client
8282- # id/secret, materialised into one EnvironmentFile.
8383- clan.core.vars.generators.omnigent = {
8484- files."env" = { };
8585- prompts.oidc_client = {
8686- description = ''
8787- Register an OIDC client in pocket-id (https://id.lassul.us):
8888- name: omnigent
8989- callback URL: https://omni.lassul.us/auth/callback
9090- Generate a client secret, then paste both lines:
9191- OMNIGENT_OIDC_CLIENT_ID=...
9292- OMNIGENT_OIDC_CLIENT_SECRET=...
9393- '';
9494- type = "multiline";
9595- persist = true;
9696- };
9797- runtimeInputs = [ pkgs.openssl ];
9898- script = ''
9999- {
100100- printf 'OMNIGENT_OIDC_COOKIE_SECRET=%s\n' "$(openssl rand -hex 32)"
101101- cat "$prompts"/oidc_client
102102- } > "$out/env"
103103- '';
104104- };
105105-106106- systemd.services.omnigent = {
107107- description = "omnigent agent meta-harness (${domain})";
108108- wantedBy = [ "multi-user.target" ];
109109- after = [
110110- "network-online.target"
111111- "nginx.service"
112112- ];
113113- wants = [ "network-online.target" ];
114114- environment = {
115115- HOME = stateDir;
116116- OMNIGENT_AUTH_ENABLED = "1";
117117- OMNIGENT_AUTH_PROVIDER = "oidc";
118118- OMNIGENT_OIDC_ISSUER = "https://id.lassul.us";
119119- OMNIGENT_OIDC_REDIRECT_URI = "https://${domain}/auth/callback";
120120- OMNIGENT_OIDC_LOGOUT_REDIRECT_URI = "https://${domain}/";
121121- OMNIGENT_OIDC_ALLOWED_DOMAINS = "lassul.us";
122122- # pocket-id emits `email` *and* `email_verified` in the id_token when the
123123- # `email` scope is requested (which omnigent does), but the value of
124124- # `email_verified` is the users.email_verified column, whose default comes
125125- # from the app-config key `emailsVerified` — shipped as false. omnigent
126126- # otherwise hard-rejects the login with "Could not determine user email
127127- # from IdP". The gate exists for IdPs where a user can self-assert an
128128- # arbitrary address; here users are provisioned by hand in pocket-id
129129- # (signups are disabled) and the domain allowlist above still applies, so
130130- # the marker carries no information for us.
131131- OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION = "1";
132132- # Default is 8 hours, which expires the `s covibe` session JWT roughly
133133- # once a working day and forces a browser round-trip through pocket-id.
134134- # A week is the pragmatic setting for a single-operator host behind an
135135- # invite-less IdP; the tokens live 0600 in ~/.omnigent/auth_tokens.json,
136136- # so shorten this if a laptop ever leaves the house untrusted.
137137- OMNIGENT_OIDC_SESSION_TTL_HOURS = "168";
138138- OMNIGENT_ADMIN_LIST_PATH = "${admins}";
139139- # The wheel checks PyPI for updates on every start otherwise.
140140- OMNIGENT_NO_UPDATE_CHECK = "1";
141141- # Off by default upstream; be explicit, it phones home otherwise.
142142- OMNIGENT_TELEMETRY_ENABLED = "0";
143143- };
144144- serviceConfig = {
145145- ExecStart = lib.concatStringsSep " " [
146146- (lib.getExe self.packages.${pkgs.system}.omnigent)
147147- "server"
148148- "--host 127.0.0.1"
149149- "--port ${toString port}"
150150- "--no-open"
151151- "--database-uri sqlite:///${stateDir}/chat.db"
152152- "--artifact-location ${stateDir}/artifacts"
153153- "--agent ${ompAgent}"
154154- ];
155155- EnvironmentFile = [ config.clan.core.vars.generators.omnigent.files."env".path ];
156156- Restart = "always";
157157- RestartSec = 5;
158158- DynamicUser = true;
159159- StateDirectory = "omnigent";
160160- WorkingDirectory = stateDir;
161161- ProtectSystem = "strict";
162162- ProtectHome = true;
163163- PrivateTmp = true;
164164- NoNewPrivileges = true;
165165- ProtectKernelTunables = true;
166166- ProtectControlGroups = true;
167167- RestrictAddressFamilies = [
168168- "AF_INET"
169169- "AF_INET6"
170170- "AF_UNIX"
171171- ];
172172- };
173173- };
174174-175175- services.nginx.virtualHosts.${domain} = {
176176- enableACME = true;
177177- forceSSL = true;
178178- locations."/" = {
179179- proxyPass = "http://127.0.0.1:${toString port}";
180180- # The session event stream is SSE (omnigent sets X-Accel-Buffering: no,
181181- # which nginx honours) and the terminal/host/runner tunnels are
182182- # WebSockets that idle between turns, so the default 60s read timeout
183183- # would sever live sessions.
184184- proxyWebsockets = true;
185185- extraConfig = ''
186186- proxy_read_timeout 1d;
187187- proxy_send_timeout 1d;
188188- '';
189189- };
190190- };
191191-}
···3535 # format: Invalid argument"): the Lua hook throws, its AsyncEventHook
3636 # transition never completes, and the event dispatcher stalls for good --
3737 # after which no node ever activates again, the ALSA sinks sit in SUSPENDED,
3838- # and every player (including herdr's notification pings) blocks until
3838+ # and every player blocks until
3939 # wireplumber is restarted by hand. Fail the transition instead.
4040 services.pipewire.wireplumber.package = pkgs.wireplumber.overrideAttrs (old: {
4141 postPatch = (old.postPatch or "") + ''
···5757kanban IN CNAME neoprism
5858n2n IN CNAME neoprism
5959id IN CNAME neoprism
6060-covibe IN CNAME neoprism
6161-omni IN CNAME neoprism
6260where.is IN CNAME neoprism
6361parley IN CNAME neoprism
6462review IN CNAME neoprism
···11{
22 self,
33 config,
44- lib,
54 pkgs,
65 ...
76}:
···4342 '';
4443 };
45444646- # herdr keeps its API socket under the session owner's
4747- # $HOME/.config/herdr/herdr.sock (verified on this host: the running server
4848- # has HOME=/home/lass and listens on /home/lass/.config/herdr/herdr.sock),
4949- # and the herd runs as lass — so the hermes user cannot see any pane without
5050- # crossing the user boundary. sudo's env_reset leaves HOME pointing at the
5151- # *calling* user, which made the CLI look for a socket under
5252- # /var/lib/hermes/.config and report "no herdr server is running", so set it
5353- # here. Keeping the env setup inside a store script also means sudoers
5454- # matches one fixed path instead of an argv template.
5555- herdr-as-lass = pkgs.writeShellApplication {
5656- name = "herdr-as-lass";
5757- text = ''
5858- export HOME=${config.users.users.mainUser.home}
5959- exec ${self.inputs.llm-agents.packages.${pkgs.system}.herdr}/bin/herdr "$@"
6060- '';
6161- };
6262-6363- # What the agent actually calls: `herdr agent list`, `herdr agent read <id>`,
6464- # `herdr agent prompt <id> '...'`. Named plainly because the gateway's PATH
6565- # is hermes + bash + coreutils + git + extraPackages — /run/current-system/sw
6666- # is absent, so there is no real `herdr` to shadow.
6767- herdr = pkgs.writeShellApplication {
6868- name = "herdr";
6969- text = ''
7070- exec /run/wrappers/bin/sudo -u ${config.users.users.mainUser.name} \
7171- ${herdr-as-lass}/bin/herdr-as-lass "$@"
7272- '';
7373- };
7445in
7546{
7647 imports = [
···11485 # to ffprobe for the duration + waveform metadata those bubbles carry.
11586 pkgs.ffmpeg
11687 hermes-tts
117117- # Drive the herd from a voice note: "read me what the agent on herdr is
118118- # stuck on" -> herdr agent list / read / prompt, as lass.
119119- herdr
12088 # CalDAV (calendar skill below) is plain HTTP; curl is not otherwise on
12189 # the gateway PATH.
12290 pkgs.curl
···202170 # "" = auto-detect per clip, instead of upstream's "en" hint — commands
203171 # here switch between English and German mid-conversation.
204172 language = "";
205205- # Vocabulary bias (faster-whisper initial_prompt). Measured on this host:
206206- # "the agent on herdr is blocked" transcribes as "the agent on Herter"
207207- # without it and verbatim with it. Every name here is one the agent has
208208- # to route on — a misheard host or tool name is a failed command, not a
209209- # typo.
210210- prompt = "Jargon: herdr, omp, nix, clan, retiolum, coaxmetal, neoprism, ignavia, lassulus.";
173173+ # Vocabulary bias (faster-whisper initial_prompt). Every name here is one
174174+ # the agent has to route on — a misheard host or tool name is a failed
175175+ # command, not a typo.
176176+ prompt = "Jargon: omp, nix, clan, retiolum, coaxmetal, neoprism, ignavia, lassulus.";
211177 local = {
212178 # small is the first size whose transcripts survive our jargon; a 4s
213179 # clip takes ~3s warm on this CPU (int8), ~6s from cold weights.
···252218 # EMAIL_PASSWORD, EMAIL_IMAP_HOST and EMAIL_SMTP_HOST are set
253219 # (gateway/config_env.py); only an explicit `enabled: false` stops that.
254220 settings.platforms.email.enabled = false;
255255-256256- # Teach the agent the herd. Without this the `herdr` wrapper is just an
257257- # unadvertised binary on PATH: nothing in the model's context would make it
258258- # reach for the herd when a voice note says "what is the agent stuck on".
259259- # Skills are loaded from $HERMES_HOME/skills/<name>/SKILL.md.
260260- hermesHomeFiles."skills/herdr/SKILL.md" = ''
261261- ---
262262- name: herdr
263263- description: Inspect and steer the coding agents running in lass's herdr session on this machine.
264264- version: 1.0.0
265265- license: MIT
266266- platforms: [linux]
267267- metadata:
268268- hermes:
269269- tags: [herdr, agents, terminal, coding, inspect]
270270- ---
271271-272272- # herdr
273273-274274- `herdr` on this host is a wrapper that talks to lass's running herdr
275275- server (the agent multiplexer holding the coding-agent panes). Every
276276- invocation prints JSON.
277277-278278- Use it whenever the user asks about "the agents", "the herd", "the
279279- session on herdr", what something is "stuck on", or wants a prompt or
280280- answer delivered to a running agent.
281281-282282- | Command | Use |
283283- | --- | --- |
284284- | `herdr agent list` | Every agent with its state: working, blocked, idle, done. Start here. |
285285- | `herdr agent get <agent>` | One agent's detail (workspace, tab, pane, cwd). |
286286- | `herdr agent read <agent>` | Recent terminal output. This is how you answer "what is it doing/asking". |
287287- | `herdr agent prompt <agent> '<text>'` | Submit a prompt to that agent, e.g. to unblock an approval. |
288288- | `herdr agent wait <agent> --state idle` | Block until it reaches a state. |
289289- | `herdr workspace list` / `herdr tab list` / `herdr pane list` | Structure, when agent-level views are not enough. |
290290- | `herdr --skill` | herdr's own, fuller usage reference — read it before improvising flags. |
291291-292292- Answering by voice: the reply is spoken, so summarize what `agent read`
293293- shows in a sentence or two instead of reciting scrollback, and say the
294294- agent's name and state first. Read a diff or a stack trace aloud only if
295295- asked.
296296-297297- Blocked agents are the interesting case: report what the agent is waiting
298298- for, and only run `herdr agent prompt` when the user actually decides.
299299- Never close panes or kill agents on your own.
300300- '';
301221302222 # The one shared calendar (configs/radicale.nix): lass's own events, the
303223 # book.lassul.us requests and bookings, and anything Hermes adds. Hermes
···473393 ];
474394 };
475395476476- # The herd/hermes user boundary. herdr keeps its API socket in the session
477477- # owner's runtime dir, so reaching lass's panes means running as lass.
478478- #
479479- # Be clear about what this grants: `herdr pane run` / `agent prompt` execute
480480- # commands inside lass's panes, so this is lass-level code execution for
481481- # anything that can drive the agent — it does not narrow the existing threat
482482- # model (the gateway already has terminal tools as hermes), but it widens the
483483- # blast radius from the hermes user to lass. The gate stays
484484- # MATRIX_ALLOWED_USERS above. Restricting the command to one store path only
485485- # buys auditability, not containment.
486486- security.sudo.extraRules = [
487487- {
488488- users = [ config.services.hermes-agent.user ];
489489- runAs = config.users.users.mainUser.name;
490490- commands = [
491491- {
492492- command = "${herdr-as-lass}/bin/herdr-as-lass";
493493- options = [
494494- "NOPASSWD"
495495- "SETENV"
496496- ];
497497- }
498498- ];
499499- }
500500- ];
501501-502502- # sudo is setuid, and the upstream unit sets NoNewPrivileges=yes, which makes
503503- # every setuid exec fail — so the wrapper above returned "sudo: The "no new
504504- # privileges" flag is set, which prevents sudo from running as root" and the
505505- # agent reported the herd as unreachable. Turning it off for this unit is the
506506- # price of the bridge; it only re-enables what the sudoers rule already
507507- # allows (one store path, runas lass), since sudo itself is the only setuid
508508- # binary the agent is pointed at.
509509- # The module hardens the unit with NoNewPrivileges = true, hence mkForce.
510510- systemd.services.hermes-agent.serviceConfig.NoNewPrivileges = lib.mkForce false;
511511-512396 # ssh as hermes signs with the phone key (keys/ssh/phonetpm_massulus.pub,
513397 # authorized on all hosts): every login asks for a fingerprint on the phone.
514398 # The daemon only starts once paired:
···621505 # Bearer token for the API server. Generated, not prompted, and kept in a
622506 # separate file as well so it can be read back out for the phone:
623507 # clan vars get coaxmetal hermes-api/api_key
624624- # (same shape as the covibe api_key generator).
625508 clan.core.vars.generators.hermes-api = {
626509 files."api_key" = { };
627510 runtimeInputs = [
···11-{
22- lib,
33- fetchPypi,
44- python313Packages,
55-}:
66-let
77- py = python313Packages;
88-99- # 0.18.1 added a runtime dependency on `websockets` (realtime transport) that
1010- # nixpkgs' derivation does not declare yet, so its wheel fails the
1111- # runtime-deps check. Add it until nixpkgs catches up.
1212- openai-agents = py.openai-agents.overridePythonAttrs (o: {
1313- dependencies = o.dependencies ++ [ py.websockets ];
1414- });
1515-1616- # omnigent-client declares `omnigent` as a dependency, and omnigent declares
1717- # omnigent-client: the two are released in lockstep from the same repo. Break
1818- # the cycle by dropping the back-reference here; the final application below
1919- # provides `omnigent` itself.
2020- omnigent-client = py.buildPythonPackage rec {
2121- pname = "omnigent_client";
2222- version = "0.17.0";
2323- format = "wheel";
2424-2525- src = fetchPypi {
2626- inherit pname version format;
2727- dist = "py3";
2828- python = "py3";
2929- hash = "sha256-840EK8+ZfBOPP0lp5h974rkKtIs4eLC8uLaK23y8FhY=";
3030- };
3131-3232- pythonRemoveDeps = [ "omnigent" ];
3333-3434- dependencies = [
3535- py.httpx
3636- py.pydantic
3737- ];
3838-3939- # No pythonImportsCheck: every module imports `omnigent`, which only exists
4040- # in the application closure below.
4141- doCheck = false;
4242-4343- meta = {
4444- description = "Python client library for the Omnigent server API";
4545- homepage = "https://omnigent.ai";
4646- license = lib.licenses.asl20;
4747- };
4848- };
4949-5050- omnigent-ui-sdk = py.buildPythonPackage rec {
5151- pname = "omnigent_ui_sdk";
5252- version = "0.17.0";
5353- format = "wheel";
5454-5555- src = fetchPypi {
5656- inherit pname version format;
5757- dist = "py3";
5858- python = "py3";
5959- hash = "sha256-2I9JWM64pqFNawr6exvQRJ1uOvZ+6J6NZ37JBy2OLFA=";
6060- };
6161-6262- dependencies = [
6363- omnigent-client
6464- py.prompt-toolkit
6565- py.pyyaml
6666- py.rich
6767- ];
6868-6969- doCheck = false;
7070-7171- meta = {
7272- description = "Terminal UI SDK for Omnigent";
7373- homepage = "https://omnigent.ai";
7474- license = lib.licenses.asl20;
7575- };
7676- };
7777-in
7878-py.buildPythonApplication rec {
7979- pname = "omnigent";
8080- version = "0.17.0";
8181- format = "wheel";
8282-8383- # The published wheel ships the prebuilt web SPA under
8484- # omnigent/server/static/web-ui; the sdist does not.
8585- src = fetchPypi {
8686- inherit pname version format;
8787- dist = "py3";
8888- python = "py3";
8989- hash = "sha256-/ygrFeQOlXXilVU5H1zqdYBqR3YVZUo/HlfIfsxR/IQ=";
9090- };
9191-9292- # Upper bounds that nixpkgs has already moved past. All of these are
9393- # conservative caps in omnigent's pyproject, not known incompatibilities.
9494- pythonRelaxDeps = [
9595- "argon2-cffi" # nixpkgs 25.1.0 vs <24
9696- "cachetools" # nixpkgs 7.1.4 vs <7
9797- "openai" # nixpkgs 2.53.0 vs <2.45
9898- "packaging" # nixpkgs 26.2 vs <26
9999- "protobuf" # nixpkgs 7.35.1 vs <7
100100- "rich" # nixpkgs 15.0.0 vs <15
101101- "websockets" # nixpkgs 16.1 vs <15
102102- ];
103103-104104- dependencies = [
105105- omnigent-client
106106- omnigent-ui-sdk
107107- ]
108108- ++ (with py; [
109109- alembic
110110- anyio
111111- argon2-cffi
112112- cachetools
113113- cel-python
114114- certifi
115115- claude-agent-sdk
116116- click
117117- fastapi
118118- filelock
119119- ftfy
120120- httpx
121121- json5
122122- keyring
123123- mcp
124124- openai
125125- openai-agents
126126- opentelemetry-api
127127- opentelemetry-exporter-otlp-proto-grpc
128128- opentelemetry-exporter-otlp-proto-http
129129- opentelemetry-instrumentation-fastapi
130130- opentelemetry-instrumentation-httpx
131131- opentelemetry-instrumentation-sqlalchemy
132132- packaging
133133- pexpect
134134- pillow
135135- prompt-toolkit
136136- protobuf
137137- psutil
138138- pydantic
139139- pyjwt
140140- pyte
141141- python-dateutil
142142- pyyaml
143143- rich
144144- sqlalchemy
145145- starlette
146146- tiktoken
147147- tomlkit
148148- uvicorn
149149- websockets
150150- zstandard
151151- ])
152152- ++ py.pyjwt.optional-dependencies.crypto
153153- ++ py.uvicorn.optional-dependencies.standard;
154154-155155- # The CLI respawns itself as `sys.executable -m omnigent.host._daemon_entry`
156156- # (cli.py:2945) for the host daemon, and the daemon spawns runners the same
157157- # way. That is the *bare* interpreter, without the site-dirs that
158158- # wrapPythonPrograms bakes into the console script, so those children die
159159- # with ModuleNotFoundError: No module named 'omnigent' and every session hangs
160160- # on "the connect daemon did not come online within 30s". Hand them the
161161- # transitive dependency closure through PYTHONPATH, which omnigent's own
162162- # daemon/runner env allowlist preserves (host/connect.py) while the agent
163163- # spawn allowlist drops it (inner/agent_env.py), so it never reaches omp.
164164- postFixup = ''
165165- buildPythonPath "$out $pythonPath"
166166- wrapProgram $out/bin/omnigent --prefix PYTHONPATH : "$program_PYTHONPATH"
167167- '';
168168-169169- pythonImportsCheck = [
170170- "omnigent"
171171- "omnigent_client"
172172- "omnigent_ui_sdk"
173173- ];
174174-175175- passthru.updateScript = ./update.sh;
176176-177177- meta = {
178178- description = "Open-source meta-harness giving a common orchestration layer over AI coding agents";
179179- homepage = "https://omnigent.ai";
180180- license = lib.licenses.asl20;
181181- mainProgram = "omnigent";
182182- platforms = lib.platforms.unix;
183183- };
184184-}
-58
pkgs/omnigent/update.sh
···11-#!/usr/bin/env bash
22-set -euo pipefail
33-44-# omnigent, omnigent-client and omnigent-ui-sdk are released in lockstep and
55-# all three are fetched as py3 wheels in package.nix; nix-update only bumps
66-# the top-level src, so bump all of them here.
77-attr=omnigent
88-99-# Run by update-pkgs from the flake root ($0 is a read-only store copy).
1010-pkg=pkgs/omnigent/package.nix
1111-system=$(nix eval --raw --impure --expr 'builtins.currentSystem')
1212-1313-new_version=$(curl -fsSL https://pypi.org/pypi/omnigent/json | jq -r .info.version)
1414-old_version=$(sed -n 's|^ version = "\([^"]*\)";|\1|p' "$pkg")
1515-if [ "$old_version" = "$new_version" ]; then
1616- echo "$attr: already up to date ($new_version)"
1717- exit 0
1818-fi
1919-2020-# Hash of the py3-none-any wheel as published on PyPI, in SRI form.
2121-wheel_hash() {
2222- local project=$1 hex
2323- hex=$(curl -fsSL "https://pypi.org/pypi/$project/$new_version/json" |
2424- jq -r '.urls[] | select(.packagetype == "bdist_wheel" and (.filename | endswith("-py3-none-any.whl"))) | .digests.sha256')
2525- if [ -z "$hex" ]; then
2626- echo "$attr: no py3-none-any wheel for $project $new_version" >&2
2727- exit 1
2828- fi
2929- nix hash convert --hash-algo sha256 --to sri "$hex"
3030-}
3131-3232-# Replace the `hash = ...` that follows `pname = "<pname>"` (blanking first so
3333-# nothing ever builds against a stale cached FOD).
3434-set_hash() {
3535- local pname=$1 hash=$2
3636- sed -i "/pname = \"$pname\";/,/hash = /s|^\(\s*\)hash = \"[^\"]*\"|\1hash = \"$hash\"|" "$pkg"
3737-}
3838-3939-client_hash=$(wheel_hash omnigent-client)
4040-ui_sdk_hash=$(wheel_hash omnigent-ui-sdk)
4141-app_hash=$(wheel_hash omnigent)
4242-4343-sed -i \
4444- -e "s|^\(\s*\)version = \"$old_version\";|\1version = \"$new_version\";|" \
4545- -e "s|^\(\s*\)hash = \".*\"|\1hash = \"\"|" \
4646- "$pkg"
4747-4848-set_hash omnigent_client "$client_hash"
4949-set_hash omnigent_ui_sdk "$ui_sdk_hash"
5050-set_hash omnigent "$app_hash"
5151-5252-if grep -q 'hash = ""' "$pkg"; then
5353- echo "$attr: failed to fill in every hash" >&2
5454- exit 1
5555-fi
5656-5757-echo "$attr: updated $old_version → $new_version"
5858-nix build --no-link ".#packages.$system.$attr"
-119
tools/covibe/flake-module.nix
···11-{ inputs, ... }:
22-{
33- perSystem =
44- {
55- pkgs,
66- self',
77- system,
88- ...
99- }:
1010- let
1111- server = "https://omni.lassul.us";
1212- omp = inputs.llm-agents.packages.${system}.omp;
1313-1414- # omnigent's `pi-native` harness runs the agent's own TUI in a tmux pane,
1515- # attaches your TTY to it, and mirrors the session (transcript, tool
1616- # calls, terminal, files) to the server — the shape covibe always had,
1717- # now with omnigent's per-session ACLs behind it. It resolves the CLI by
1818- # the literal name `pi` (onboarding/harness_install.py: the readiness gate
1919- # is `resolve_cli_binary("pi")`, which ignores OMNIGENT_PI_PATH and the
2020- # config override), so omp has to reach it under that name. Version-gated
2121- # flags are satisfied by omp reporting 17.x >= pi's declared 0.79.0 floor.
2222- piShim = pkgs.writeShellApplication {
2323- name = "pi";
2424- text = ''
2525- OMP_BIN=${omp}/bin/omp
2626- ''
2727- + builtins.readFile ./pi-shim.sh;
2828- };
2929- in
3030- {
3131- # Launch omp as a session on the omni.lassul.us dashboard: omp's own TUI
3232- # runs here, in a tmux pane owned by the local runner, while the server
3333- # holds the transcript, the diff view and the per-session ACLs — so a
3434- # session can be shared read-only or read-write with another pocket-id
3535- # account, and a teammate's message from the browser executes in this
3636- # TUI.
3737- #
3838- # Auth is the pocket-id login, but *only a browser* has to see pocket-id —
3939- # the machine running the agent only needs to reach the server. omnigent's
4040- # cli-ticket flow (POST /auth/cli-login, then poll /auth/cli-poll for five
4141- # minutes) prints a URL that any device with your passkey can complete, so
4242- # this works over ssh on a box with no display: run `covibe login`, open
4343- # the printed link on a laptop or phone, done. The session JWT is then
4444- # cached in ~/.omnigent/auth_tokens.json for the server's configured TTL.
4545- # Other arguments go through to omp, e.g. `covibe --continue`;
4646- # `covibe --resume` (no value) opens omnigent's session picker.
4747- #
4848- # Note the TUI path runs on a spec omnigent materialises itself
4949- # (`pi-native-ui`), so the only terminal it declares is omp's own pane;
5050- # extra shells from the dashboard are a property of the *browser-started*
5151- # omp agent, whose spec lives in configs/omnigent.nix.
5252- packages.covibe =
5353- (pkgs.writeShellApplication {
5454- name = "covibe";
5555- runtimeInputs = [
5656- self'.packages.omnigent
5757- piShim
5858- # The host daemon we spawn inherits this PATH, and sessions started
5959- # from the *browser* use the server's catalog entry, whose command is
6060- # a bare `omp acp` resolved here — without omp on PATH those turns
6161- # die with "inner executor error: [Errno 2] No such file or
6262- # directory". The `pi` shim alone is not enough: it only covers the
6363- # pi-native TUI path.
6464- omp
6565- pkgs.tmux
6666- pkgs.jq
6767- pkgs.git
6868- ];
6969- text = ''
7070- server=${server}
7171- tokens="$HOME/.omnigent/auth_tokens.json"
7272-7373- # `webbrowser.open` walks a candidate list that includes terminal
7474- # browsers; on a headless box one of those can seize the tty we are
7575- # about to hand to omp's TUI. With no display, make the "open" a
7676- # no-op — the URL is printed either way.
7777- if [ -z "''${DISPLAY:-}" ] && [ -z "''${WAYLAND_DISPLAY:-}" ]; then
7878- export BROWSER=''${BROWSER:-echo}
7979- fi
8080-8181- login() {
8282- echo "covibe: authenticating against $server" >&2
8383- echo "covibe: open the link below on any device that has your pocket-id passkey" >&2
8484- omnigent login "$server"
8585- }
8686-8787- case "''${1:-}" in
8888- login)
8989- login
9090- exit 0
9191- ;;
9292- host)
9393- # Register this machine so sessions can also be STARTED from the
9494- # browser (New Chat → this host → the `omp` agent). That path runs
9595- # `omp acp` from the runner's PATH, which only has omp when the
9696- # daemon was spawned with it — as it is here. Runs in the
9797- # foreground; ^C stops hosting.
9898- shift
9999- exec omnigent host "$server" "$@"
100100- ;;
101101- esac
102102-103103- # The cached session is a JWT with a server-side TTL
104104- # (OMNIGENT_OIDC_SESSION_TTL_HOURS), so "a token exists" is not the
105105- # same as "we are logged in": a stale entry makes omnigent fail with
106106- # `Pi session creation failed (401): Authentication required`
107107- # instead of re-authenticating. Check the recorded expiry (with a
108108- # minute of slack, via jq's own clock so this needs no coreutils).
109109- if ! jq -e --arg s "$server" \
110110- '((.[$s].expires_at // 0) > (now + 60)) and ((.[$s].token // "") != "")' \
111111- "$tokens" >/dev/null 2>&1; then
112112- login
113113- fi
114114- exec omnigent pi --server "$server" "$@"
115115- '';
116116- }).overrideAttrs
117117- { passthru.usage = builtins.readFile ./usage.kdl; };
118118- };
119119-}
-51
tools/covibe/pi-shim.sh
···11-# shellcheck shell=bash
22-# Argument translation between omnigent's `pi-native` harness and omp.
33-#
44-# omnigent builds `--extension <js> [--approve] --session-dir <dir>
55-# [--session <id>] <user args>` (omnigent/runner/native/orchestration.py
66-# _build_pi_native_args) against the upstream `pi` CLI at 0.79.x. omp 17.x
77-# differs in three ways:
88-#
99-# 1. `--approve` (pi's project-trust override) does not exist; omp exits
1010-# "unknown flag: --approve". Drop it — a human is attached to the TUI and
1111-# omp's own approval prompts are what we want.
1212-# 2. `--session <id>` is spelled `--resume <id>` in omp.
1313-# 3. omnigent's bridge extension is CommonJS (`module.exports = fn`), while
1414-# omp dynamic-imports the file and accepts only `module` /
1515-# `module.default` as the factory — a CJS file fails with "Extension does
1616-# not export a valid factory function". Point omp at a generated ESM
1717-# adapter that re-exports the CJS factory as a default export instead.
1818-# Verified: the status line then reads "· linked", prompts typed in the
1919-# TUI appear in the dashboard transcript, and messages sent from the
2020-# dashboard execute in the TUI.
2121-argv=()
2222-while [ "$#" -gt 0 ]; do
2323- case "$1" in
2424- --approve)
2525- shift
2626- ;;
2727- --session)
2828- argv+=(--resume "$2")
2929- shift 2
3030- ;;
3131- --extension)
3232- ext=$2
3333- shift 2
3434- adapter="$(dirname "$ext")/omnigent-esm-adapter.mjs"
3535- if [ ! -e "$adapter" ]; then
3636- cat >"$adapter" <<EOF
3737-import { createRequire } from "node:module";
3838-const require = createRequire(import.meta.url);
3939-export default require("$ext");
4040-EOF
4141- fi
4242- argv+=(--extension "$adapter")
4343- ;;
4444- *)
4545- argv+=("$1")
4646- shift
4747- ;;
4848- esac
4949-done
5050-5151-exec "$OMP_BIN" "${argv[@]}"
-9
tools/covibe/usage.kdl
···11-bin "covibe"
22-about "Run omp's TUI as a shared session on the omni.lassul.us omnigent dashboard (transcript, diff view and read-only/read-write sharing live on the server)"
33-44-cmd "login" help="Authenticate this machine against omni.lassul.us; prints a link to open on any device with your pocket-id passkey"
55-cmd "host" help="Keep this machine registered as a host so sessions can also be started from the browser"
66-77-flag "-c --continue" help="Continue the previous omp session in this directory"
88-flag "-r --resume" help="Resume an omnigent session (no value opens the picker)"
99-arg "[omp-args...]" help="Arguments passed through to omp"
···103103104104Delegation policy:
105105Backend tools:
106106-- Hermes agent: a full AI agent with tools. It can run commands on lass's machines, read and edit files, manage the calendar, inspect the coding agents in herdr, browse the web, search, and remember things across sessions. It is the one who actually does work and knows facts.
106106+- Hermes agent: a full AI agent with tools. It can run commands on lass's machines, read and edit files, manage the calendar, browse the web, search, and remember things across sessions. It is the one who actually does work and knows facts.
107107108108Delegate to the backend when:
109109- The user asks a question that needs facts, current information, or careful reasoning.
···335335336336 # Workspace manager integration: cd to workspace directory on new terminal.
337337 # Only for shells that start in $HOME: a pane spawned with an explicit
338338- # directory (herdr, tmux split in cwd) must keep it.
338338+ # directory (tmux split in cwd) must keep it.
339339 if [[ "$PWD" == "$HOME" ]]; then
340340 _ws_dir=$(${self.packages.${pkgs.system}.workspace-manager}/bin/workspace-manager dir 2>/dev/null)
341341 [[ -n "$_ws_dir" && -d "$_ws_dir" ]] && cd "$_ws_dir"