Skip site navigation (1)Skip section navigation (2)
Date:      Thu, 07 May 2026 08:28:04 +0000
From:      Olivier Cochard <olivier@FreeBSD.org>
To:        ports-committers@FreeBSD.org, dev-commits-ports-all@FreeBSD.org, dev-commits-ports-main@FreeBSD.org
Subject:   git: 5dbeba4008f9 - main - misc/hermes-age=?utf-8?Q?nt:=E2=80=AFNe?=w port
Message-ID:  <69fc4d14.1a11f.1c4d6998@gitrepo.freebsd.org>

index | next in thread | raw e-mail

The branch main has been updated by olivier:

URL: https://cgit.FreeBSD.org/ports/commit/?id=5dbeba4008f9d546c7b4991b8e8cb9534d7614c6

commit 5dbeba4008f9d546c7b4991b8e8cb9534d7614c6
Author:     Olivier Cochard <olivier@FreeBSD.org>
AuthorDate: 2026-05-06 23:15:53 +0000
Commit:     Olivier Cochard <olivier@FreeBSD.org>
CommitDate: 2026-05-07 08:26:42 +0000

    misc/hermes-agent: New port
    
    AI agent with built-in learning loop
---
 misc/Makefile                                      |   1 +
 misc/hermes-agent/Makefile                         | 183 +++++++++++++
 misc/hermes-agent/distinfo                         |   5 +
 misc/hermes-agent/files/hermes_dashboard.in        |  89 +++++++
 misc/hermes-agent/files/hermes_gateway.in          | 101 ++++++++
 .../files/patch-hermes__cli_gateway.py             | 284 +++++++++++++++++++++
 misc/hermes-agent/files/patch-hermes__cli_main.py  |  66 +++++
 misc/hermes-agent/files/patch-hermes__cli_setup.py |  95 +++++++
 .../files/patch-hermes__cli_uninstall.py           |  35 +++
 misc/hermes-agent/files/pkg-message.in             |  25 ++
 misc/hermes-agent/files/wrapper.in                 |  13 +
 misc/hermes-agent/pkg-descr                        |  22 ++
 12 files changed, 919 insertions(+)

diff --git a/misc/Makefile b/misc/Makefile
index ce54990634cd..8913eeae2c67 100644
--- a/misc/Makefile
+++ b/misc/Makefile
@@ -206,6 +206,7 @@
     SUBDIR += hashdb
     SUBDIR += hello
     SUBDIR += help2man
+    SUBDIR += hermes-agent
     SUBDIR += heyu2
     SUBDIR += hicolor-icon-theme
     SUBDIR += histring
diff --git a/misc/hermes-agent/Makefile b/misc/hermes-agent/Makefile
new file mode 100644
index 000000000000..68032e3a5ce6
--- /dev/null
+++ b/misc/hermes-agent/Makefile
@@ -0,0 +1,183 @@
+PORTNAME=	hermes-agent
+PORTVERSION=	0.12.0
+CATEGORIES=	misc python
+MASTER_SITES+=	LOCAL/olivier:webcache
+DISTFILES+=	${PORTNAME}-web-offline-cache-${PORTVERSION}${EXTRACT_SUFX}:webcache
+
+MAINTAINER=	olivier@FreeBSD.org
+COMMENT=	AI agent with built-in learning loop
+WWW=		https://github.com/NousResearch/hermes-agent
+
+LICENSE=	MIT
+LICENSE_FILE=	${WRKSRC}/LICENSE
+
+BUILD_DEPENDS=	npm:www/npm
+RUN_DEPENDS=	${PYTHON_PKGNAMEPREFIX}anthropic>=0.39.0:misc/py-anthropic@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}croniter>=6.0.0:sysutils/py-croniter@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}edge-tts>=7.2.7:audio/py-edge-tts@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}exa-py>=2.9.0:www/py-exa-py@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}fal-client>=0.13.1:misc/py-fal-client@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}fastapi>=0.104.0:www/py-fastapi@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}fire>=0.7.0:devel/py-fire@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}firecrawl-py>=4.16.0:www/py-firecrawl-py@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}httpx>=0.28.1:www/py-httpx@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}Jinja2>=3.1.5:devel/py-Jinja2@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}openai>=2.21.0:misc/py-openai@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}parallel-web>=0.4.2:www/py-parallel-web@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}prompt-toolkit>=3.0.52:devel/py-prompt-toolkit@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}pydantic2>=2.12.5:devel/py-pydantic2@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}pyjwt>=2.12.0:www/py-pyjwt@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}pysocks>0:net/py-pysocks@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}python-dotenv>=1.2.1:www/py-python-dotenv@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}pyyaml>=6.0.2:devel/py-pyyaml@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}requests>=2.33.0:www/py-requests@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}rich>=14.3.3:textproc/py-rich@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}socksio>0:net/py-socksio@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}tenacity>=9.1.4:devel/py-tenacity@${PY_FLAVOR} \
+		${PYTHON_PKGNAMEPREFIX}uvicorn>=0.24.0:www/py-uvicorn@${PY_FLAVOR}
+
+USES=		python:3.11+,run shebangfix nodejs:lts,build
+USE_GITHUB=	yes
+GH_ACCOUNT=	NousResearch
+GH_PROJECT=	hermes-agent
+GH_TAGNAME=	v2026.4.30
+
+USE_RC_SUBR=	hermes_dashboard hermes_gateway
+
+SUB_FILES=	pkg-message
+
+NO_ARCH=	yes
+
+# Hermes is an application, not a Python library.  Upstream's Dockerfile,
+# Nix flake, and Homebrew formula all install it into a private directory
+# (/opt/hermes, the Nix store, libexec/ respectively) rather than into
+# site-packages, because the project ships top-level packages with generic
+# names (tools, agent, gateway, plugins, ...) and bare modules (cli.py,
+# utils.py, ...) that would collide with other Python packages.  We follow
+# the same convention: install the source tree under HERMES_LIBDIR and
+# create thin wrapper scripts in ${PREFIX}/bin that inject HERMES_LIBDIR
+# into sys.path before calling each entry point.
+HERMES_LIBDIR=	${PREFIX}/lib/${PORTNAME}
+
+PLIST_SUB+=	HERMES_LIBDIR=${HERMES_LIBDIR:S,^${PREFIX}/,,}
+
+# Web dashboard SPA (Vite/React) — upstream's release tarball does NOT ship
+# a prebuilt web_dist/, only the source under web/.  We bring our own npm
+# offline mirror as a second distfile (LOCAL/<committer>:webcache) and run
+# `npm ci --offline && npm run build` in do-build to produce
+# hermes_cli/web_dist/, which the dashboard serves at runtime
+# (web_server.py defaults to ${HERMES_LIBDIR}/hermes_cli/web_dist).
+#
+# How to (re)generate hermes-agent-web-offline-cache-${PORTVERSION}.tar.gz
+# on every PORTVERSION bump (run on a connected host with npm 10+ installed):
+#
+#   1. Extract the upstream source tarball:
+#        tar xzf ${DISTDIR}/NousResearch-hermes-agent-${PORTVERSION}-${GH_TAGNAME}_GH0.tar.gz
+#        cd hermes-agent-*/web
+#   2. Populate a fresh npm cache from web/package-lock.json:
+#        rm -rf /tmp/hermes-cache && mkdir -p /tmp/hermes-cache
+#        HOME=/tmp npm_config_cache=/tmp/hermes-cache \
+#            npm ci --no-audit --no-fund --prefer-offline
+#   3. Strip non-deterministic bits (logs, last-checked stamps):
+#        rm -rf /tmp/hermes-cache/_logs /tmp/hermes-cache/_update-notifier-last-checked
+#   4. Repackage with a top-level dir whose name matches the distfile:
+#        mv /tmp/hermes-cache /tmp/${PORTNAME}-web-offline-cache-${PORTVERSION}
+#        cd /tmp && tar --no-acls --no-xattrs --no-fflags --uid=0 --gid=0 \
+#            -czf ${PORTNAME}-web-offline-cache-${PORTVERSION}.tar.gz \
+#            ${PORTNAME}-web-offline-cache-${PORTVERSION}
+#   5. Upload to LOCAL/<committer>'s distcache directory and drop a copy
+#      into ${DISTDIR} so `make makesum` picks it up locally.
+#   6. cd ${.CURDIR} && make makesum
+#
+# `npm ci --offline` in do-build refuses any registry call, so a missing
+# entry in the mirror fails fast instead of silently going to the network.
+# npm reads cacache content from ${npm_config_cache}/_cacache.  Point
+# npm_config_cache at the *parent* of the _cacache/ tree we shipped — if
+# you point it at _cacache/ directly, npm appends _cacache/ a second time
+# and silently sees an empty cache (Index entries: 0), then fails every
+# install with ENOTCACHED.
+WEB_CACHE_DIR=	${WRKDIR}/${PORTNAME}-web-offline-cache-${PORTVERSION}
+WEB_NPM_ENV=	HOME=${WRKDIR} \
+		npm_config_cache=${WEB_CACHE_DIR} \
+		npm_config_update_notifier=false \
+		npm_config_audit=false \
+		npm_config_fund=false
+
+# Python packages and bare modules that constitute the runtime app.
+HERMES_PKGS=	acp_adapter agent cron gateway hermes_cli plugins tools tui_gateway
+HERMES_MODS=	batch_runner.py cli.py hermes_constants.py hermes_logging.py \
+		hermes_state.py hermes_time.py model_tools.py rl_cli.py \
+		run_agent.py toolset_distributions.py toolsets.py \
+		trajectory_compressor.py utils.py
+
+SHEBANG_FILES=	${HERMES_MODS}
+
+PORTDOCS=	README.md SECURITY.md CONTRIBUTING.md AGENTS.md
+OPTIONS_DEFINE=	DOCS
+
+PLIST_FILES=	"@(,,0755) bin/hermes" \
+		"@(,,0755) bin/hermes-agent" \
+		"@(,,0755) bin/hermes-acp"
+
+# Build the web dashboard SPA from the offline npm mirror.  npm reads its
+# package cache from npm_config_cache; --offline forbids any network call
+# so a missing dep fails fast instead of silently going to the registry.
+# The vite config writes the bundle to ../hermes_cli/web_dist (relative to
+# web/), which is then picked up by do-install below.
+do-build:
+	cd ${WRKSRC}/web && \
+		${SETENV} ${WEB_NPM_ENV} \
+		npm ci --offline --no-audit --no-fund
+	cd ${WRKSRC}/web && \
+		${SETENV} ${WEB_NPM_ENV} \
+		npm run build
+
+do-install:
+	${MKDIR} ${STAGEDIR}${HERMES_LIBDIR}
+.for d in ${HERMES_PKGS}
+	cd ${WRKSRC} && ${COPYTREE_SHARE} ${d} ${STAGEDIR}${HERMES_LIBDIR} \
+		"! -name __pycache__ ! -name *.pyc"
+.endfor
+.for f in ${HERMES_MODS}
+	${INSTALL_DATA} ${WRKSRC}/${f} ${STAGEDIR}${HERMES_LIBDIR}
+.endfor
+	${MKDIR} ${STAGEDIR}${PREFIX}/bin
+	${SED} -e 's|%%HERMES_LIBDIR%%|${HERMES_LIBDIR}|g' \
+		-e 's|%%PYTHON_CMD%%|${PYTHON_CMD}|g' \
+		-e 's|%%ENTRY_MODULE%%|hermes_cli.main|g' \
+		-e 's|%%ENTRY_FUNC%%|main|g' \
+		${FILESDIR}/wrapper.in > ${STAGEDIR}${PREFIX}/bin/hermes
+	${SED} -e 's|%%HERMES_LIBDIR%%|${HERMES_LIBDIR}|g' \
+		-e 's|%%PYTHON_CMD%%|${PYTHON_CMD}|g' \
+		-e 's|%%ENTRY_MODULE%%|run_agent|g' \
+		-e 's|%%ENTRY_FUNC%%|main|g' \
+		${FILESDIR}/wrapper.in > ${STAGEDIR}${PREFIX}/bin/hermes-agent
+	${SED} -e 's|%%HERMES_LIBDIR%%|${HERMES_LIBDIR}|g' \
+		-e 's|%%PYTHON_CMD%%|${PYTHON_CMD}|g' \
+		-e 's|%%ENTRY_MODULE%%|acp_adapter.entry|g' \
+		-e 's|%%ENTRY_FUNC%%|main|g' \
+		${FILESDIR}/wrapper.in > ${STAGEDIR}${PREFIX}/bin/hermes-acp
+	${MKDIR} ${STAGEDIR}${DATADIR}
+	cd ${WRKSRC} && ${COPYTREE_SHARE} skills ${STAGEDIR}${DATADIR}
+	cd ${WRKSRC} && ${COPYTREE_SHARE} optional-skills ${STAGEDIR}${DATADIR}
+
+# Walk the staged HERMES_LIBDIR and DATADIR trees and append every file
+# (and every directory we created) to the plist.  This avoids hand-
+# maintaining a 500-line pkg-plist for skill templates that change every
+# release.
+post-install:
+	@cd ${STAGEDIR}${PREFIX} && \
+		${FIND} ${HERMES_LIBDIR:S,^${PREFIX}/,,} ${DATADIR:S,^${PREFIX}/,,} \
+			-type f >> ${TMPPLIST}
+	@cd ${STAGEDIR}${PREFIX} && \
+		${FIND} ${HERMES_LIBDIR:S,^${PREFIX}/,,} ${DATADIR:S,^${PREFIX}/,,} \
+			-type d -mindepth 1 | ${SORT} -r | \
+			${SED} 's|^|@dir |' >> ${TMPPLIST}
+
+post-install-DOCS-on:
+	${MKDIR} ${STAGEDIR}${DOCSDIR}
+.for f in ${PORTDOCS}
+	${INSTALL_DATA} ${WRKSRC}/${f} ${STAGEDIR}${DOCSDIR}
+.endfor
+
+.include <bsd.port.mk>
diff --git a/misc/hermes-agent/distinfo b/misc/hermes-agent/distinfo
new file mode 100644
index 000000000000..9238c06174f8
--- /dev/null
+++ b/misc/hermes-agent/distinfo
@@ -0,0 +1,5 @@
+TIMESTAMP = 1778071003
+SHA256 (hermes-agent-web-offline-cache-0.12.0.tar.gz) = b176b6ce7de35e7720d6c3363911944cfb9eca2c8e4bd1af621207d45f0ad225
+SIZE (hermes-agent-web-offline-cache-0.12.0.tar.gz) = 50966122
+SHA256 (NousResearch-hermes-agent-0.12.0-v2026.4.30_GH0.tar.gz) = 3743db721cf6c93631f8446bdc8b77fd53e0c439ee8c42ec821ebfd6874c3949
+SIZE (NousResearch-hermes-agent-0.12.0-v2026.4.30_GH0.tar.gz) = 18882013
diff --git a/misc/hermes-agent/files/hermes_dashboard.in b/misc/hermes-agent/files/hermes_dashboard.in
new file mode 100644
index 000000000000..bdf958b0b889
--- /dev/null
+++ b/misc/hermes-agent/files/hermes_dashboard.in
@@ -0,0 +1,89 @@
+#!/bin/sh
+
+# PROVIDE: hermes_dashboard
+# REQUIRE: LOGIN NETWORKING
+# KEYWORD: shutdown
+#
+# Add the following lines to /etc/rc.conf to enable hermes_dashboard:
+#
+# hermes_dashboard_enable="YES"
+# hermes_dashboard_user="hermes"              # REQUIRED: account whose ~/.hermes is used
+# hermes_dashboard_host="127.0.0.1"          # OPTIONAL: bind host (default 127.0.0.1)
+# hermes_dashboard_port="9119"               # OPTIONAL: bind port (default 9119)
+# hermes_dashboard_profile=""                # OPTIONAL: -p <profile>
+# hermes_dashboard_args=""                   # OPTIONAL: extra args passed to `hermes dashboard`
+#                                            #   e.g. "--insecure" (DANGEROUS — exposes API keys)
+#                                            #   or   "--tui" (in-browser chat)
+#
+# NOTE: do NOT use ${name}_flags for extra args. rc.subr reserves *_flags
+# for the *command* (i.e. daemon(8)) and will inject them ahead of daemon's
+# own options, which causes daemon to fail with "unrecognized option".
+# Use hermes_dashboard_args instead — it is forwarded to `hermes dashboard`.
+#
+# WARNING: --insecure binds the dashboard to all interfaces and exposes
+# the configured provider API keys on the network.  Only use behind a
+# trusted reverse proxy with auth.  Default binding is 127.0.0.1 only.
+
+. /etc/rc.subr
+
+name="hermes_dashboard"
+rcvar="hermes_dashboard_enable"
+
+load_rc_config $name
+
+: ${hermes_dashboard_enable:="NO"}
+: ${hermes_dashboard_user:=""}
+: ${hermes_dashboard_host:="127.0.0.1"}
+: ${hermes_dashboard_port:="9119"}
+: ${hermes_dashboard_profile:=""}
+: ${hermes_dashboard_args:=""}
+
+# Back-compat: if someone set the legacy *_flags var, honor it but warn.
+if [ -n "${hermes_dashboard_flags}" ] && [ -z "${hermes_dashboard_args}" ]; then
+    warn "hermes_dashboard_flags is deprecated (it collides with rc.subr); use hermes_dashboard_args"
+    hermes_dashboard_args="${hermes_dashboard_flags}"
+fi
+# Suppress rc.subr's automatic *_flags injection — we route extras via
+# hermes_dashboard_args into the inner command instead.
+hermes_dashboard_flags=""
+
+if [ -n "${hermes_dashboard_user}" ]; then
+    hermes_dashboard_home=$(getent passwd "${hermes_dashboard_user}" 2>/dev/null | cut -d: -f6)
+    if [ -z "${hermes_dashboard_home}" ]; then
+        hermes_dashboard_home=$(eval echo "~${hermes_dashboard_user}")
+    fi
+else
+    hermes_dashboard_home=""
+fi
+
+piddir="/var/run/${name}"
+pidfile="${piddir}/${name}.pid"
+
+command="/usr/sbin/daemon"
+# Note: do NOT pass `-u ${hermes_dashboard_user}` to daemon. rc.subr already
+# drops privileges to ${name}_user via su(1) before exec, so adding -u here
+# causes daemon to call initgroups() as a non-root user → EPERM and a tight
+# restart loop with `-r`.
+command_args="-f -r -P ${pidfile} -S -T ${name} \
+    /usr/bin/env HOME=${hermes_dashboard_home} \
+    %%PREFIX%%/bin/hermes ${hermes_dashboard_profile:+-p ${hermes_dashboard_profile}} \
+    dashboard --no-open --host ${hermes_dashboard_host} --port ${hermes_dashboard_port} \
+    ${hermes_dashboard_args}"
+
+required_files="%%PREFIX%%/bin/hermes"
+
+start_precmd="hermes_dashboard_prestart"
+hermes_dashboard_prestart()
+{
+    if [ -z "${hermes_dashboard_user}" ]; then
+        err 1 "hermes_dashboard_user is not set in rc.conf — refusing to start"
+    fi
+    if [ -z "${hermes_dashboard_home}" ] || [ ! -d "${hermes_dashboard_home}" ]; then
+        err 1 "home directory for user '${hermes_dashboard_user}' not found"
+    fi
+    # piddir must be writable by the unprivileged user since daemon(8)
+    # drops privileges (-u) before writing the supervisor pidfile (-P).
+    install -d -m 0755 -o "${hermes_dashboard_user}" "${piddir}"
+}
+
+run_rc_command "$1"
diff --git a/misc/hermes-agent/files/hermes_gateway.in b/misc/hermes-agent/files/hermes_gateway.in
new file mode 100644
index 000000000000..06d23d772091
--- /dev/null
+++ b/misc/hermes-agent/files/hermes_gateway.in
@@ -0,0 +1,101 @@
+#!/bin/sh
+
+# PROVIDE: hermes_gateway
+# REQUIRE: LOGIN NETWORKING
+# KEYWORD: shutdown
+#
+# Add the following lines to /etc/rc.conf to enable hermes_gateway:
+#
+# hermes_gateway_enable="YES"
+# hermes_gateway_user="hermes"         # REQUIRED: account whose ~/.hermes is used
+# hermes_gateway_profile=""            # OPTIONAL: -p <profile> for multi-profile setups
+# hermes_gateway_args=""               # OPTIONAL: extra args for `hermes gateway run`
+#                                      #   e.g. "-v" for INFO logs, "-vv" for DEBUG
+#
+# NOTE: do NOT use ${name}_flags for extra args. rc.subr reserves *_flags
+# for the *command* (i.e. daemon(8)) and will inject them ahead of daemon's
+# own options, which causes daemon to fail with "invalid option".
+# Use hermes_gateway_args instead — it is forwarded to `hermes gateway run`.
+#
+# Notes:
+#   * Hermes stores all state under $HOME/.hermes (config, sessions, logs,
+#     credentials).  This script runs the gateway as ${hermes_gateway_user}
+#     so ~/.hermes resolves to that user's home directory.  There is no
+#     sane "root" default — running as root would target /root/.hermes
+#     which is almost never what the operator wants.
+#   * The gateway logs to ${HOME}/.hermes/agent.log via Hermes' own logging.
+#     daemon(8)'s syslog redirection captures any stray stderr to
+#     /var/log/messages with tag "hermes_gateway".
+#   * Crash recovery: daemon(8) -r restarts on non-zero exit.  Hermes' own
+#     drain-restart (exit code 75) is handled the same way.
+
+. /etc/rc.subr
+
+name="hermes_gateway"
+rcvar="hermes_gateway_enable"
+
+load_rc_config $name
+
+: ${hermes_gateway_enable:="NO"}
+: ${hermes_gateway_user:=""}
+: ${hermes_gateway_profile:=""}
+: ${hermes_gateway_args:=""}
+
+# Back-compat: if someone set the legacy *_flags var, honor it but warn.
+if [ -n "${hermes_gateway_flags}" ] && [ -z "${hermes_gateway_args}" ]; then
+    warn "hermes_gateway_flags is deprecated (it collides with rc.subr); use hermes_gateway_args"
+    hermes_gateway_args="${hermes_gateway_flags}"
+fi
+# Suppress rc.subr's automatic *_flags injection — we route extras via
+# hermes_gateway_args into the inner command instead.
+hermes_gateway_flags=""
+
+# Resolve home directory of the configured user so ~/.hermes works.
+if [ -n "${hermes_gateway_user}" ]; then
+    hermes_gateway_home=$(getent passwd "${hermes_gateway_user}" 2>/dev/null | cut -d: -f6)
+    if [ -z "${hermes_gateway_home}" ]; then
+        hermes_gateway_home=$(eval echo "~${hermes_gateway_user}")
+    fi
+else
+    hermes_gateway_home=""
+fi
+
+piddir="/var/run/${name}"
+pidfile="${piddir}/${name}.pid"
+
+# We launch via daemon(8):
+#   -f       fully detach
+#   -r       restart on non-zero exit (handles Hermes' SIGUSR1 drain-restart, code 75)
+#   -P pid   pidfile owned by daemon(8) for proper PID tracking + signaling
+#   -S       send stray stderr to syslog
+#   -T tag   syslog tag
+#   HOME=... so Hermes finds ~/.hermes for the right user
+#
+# Note: do NOT pass `-u ${hermes_gateway_user}` to daemon. rc.subr already
+# drops privileges to ${name}_user via su(1) before exec, so adding -u here
+# would cause daemon to call initgroups() as a non-root user → EPERM and a
+# tight restart loop with `-r`.
+command="/usr/sbin/daemon"
+command_args="-f -r -P ${pidfile} -S -T ${name} \
+    /usr/bin/env HOME=${hermes_gateway_home} \
+    %%PREFIX%%/bin/hermes ${hermes_gateway_profile:+-p ${hermes_gateway_profile}} \
+    gateway run --replace ${hermes_gateway_args}"
+
+required_files="%%PREFIX%%/bin/hermes"
+
+start_precmd="hermes_gateway_prestart"
+hermes_gateway_prestart()
+{
+    if [ -z "${hermes_gateway_user}" ]; then
+        err 1 "hermes_gateway_user is not set in rc.conf — refusing to start"
+    fi
+    if [ -z "${hermes_gateway_home}" ] || [ ! -d "${hermes_gateway_home}" ]; then
+        err 1 "home directory for user '${hermes_gateway_user}' not found"
+    fi
+    # piddir must be writable by the unprivileged user since rc.subr drops
+    # privileges (via ${name}_user) before daemon(8) writes the supervisor
+    # pidfile (-P).  Always (re-)assert ownership in case of stale state.
+    install -d -m 0755 -o "${hermes_gateway_user}" "${piddir}"
+}
+
+run_rc_command "$1"
diff --git a/misc/hermes-agent/files/patch-hermes__cli_gateway.py b/misc/hermes-agent/files/patch-hermes__cli_gateway.py
new file mode 100644
index 000000000000..cc8791634d1a
--- /dev/null
+++ b/misc/hermes-agent/files/patch-hermes__cli_gateway.py
@@ -0,0 +1,284 @@
+--- hermes_cli/gateway.py.orig	2026-05-06 08:29:55 UTC
++++ hermes_cli/gateway.py
+@@ -733,7 +733,175 @@ def is_windows() -> bool:
+     return sys.platform == 'win32'
+ 
+ 
++def is_freebsd() -> bool:
++    return sys.platform.startswith('freebsd')
++
++
+ # =============================================================================
++# FreeBSD rc.d service helpers
++# =============================================================================
++#
++# On FreeBSD the rc.d scripts (hermes_gateway, hermes_dashboard) are shipped by
++# the FreeBSD port at /usr/local/etc/rc.d/.  Hermes does NOT generate or write
++# them at runtime — there is no per-user scope on FreeBSD, only the system one,
++# and editing rc.d / rc.conf requires root.  These helpers therefore:
++#   * detect whether the rc script is present (port installed)
++#   * shell out to service(8) for start/stop/restart/status
++#   * shell out to sysrc(8) to flip hermes_gateway_enable=YES / -x in rc.conf
++#   * print exact sudo commands when running as a non-root user
++# This mirrors the systemd_*/launchd_* command shape so the dispatcher in
++# gateway_command() can branch on platform without duplicating logic.
++
++FREEBSD_RC_SCRIPT_NAME = "hermes_gateway"
++FREEBSD_RC_SCRIPT_PATH = Path("/usr/local/etc/rc.d") / FREEBSD_RC_SCRIPT_NAME
++FREEBSD_RC_VAR = "hermes_gateway_enable"
++
++
++def supports_freebsd_rc() -> bool:
++    """Return True when running on FreeBSD with the hermes_gateway rc.d script
++    installed by the port.  We do NOT try to install the rc script ourselves —
++    that's the package manager's job."""
++    if not is_freebsd():
++        return False
++    if shutil.which("service") is None:
++        return False
++    return FREEBSD_RC_SCRIPT_PATH.exists()
++
++
++def _freebsd_is_root() -> bool:
++    try:
++        return os.geteuid() == 0
++    except AttributeError:
++        return False
++
++
++def _freebsd_sudo_prefix() -> list[str]:
++    """Return [] if already root, ['sudo'] otherwise."""
++    return [] if _freebsd_is_root() else ["sudo"]
++
++
++def _freebsd_run_or_print(cmd: list[str], *, action: str) -> bool:
++    """Run *cmd* (prepending sudo when needed). If sudo is required and
++    unavailable in batch mode, print the exact command for the user to run.
++    Returns True on success, False otherwise.  *action* is a short verb used
++    for log messages (e.g. 'start', 'enable')."""
++    if _freebsd_is_root():
++        try:
++            subprocess.run(cmd, check=True)
++            return True
++        except subprocess.CalledProcessError as e:
++            print(f"✗ Failed to {action} hermes_gateway: exit {e.returncode}")
++            return False
++
++    if shutil.which("sudo") is None:
++        print(f"  Run as root: {' '.join(cmd)}")
++        return False
++
++    sudo_cmd = ["sudo"] + cmd
++    try:
++        subprocess.run(sudo_cmd, check=True)
++        return True
++    except subprocess.CalledProcessError as e:
++        print(f"✗ Failed to {action} hermes_gateway: exit {e.returncode}")
++        return False
++
++
++def freebsd_rc_install(force: bool = False, system: bool = False, run_as_user: str | None = None):
++    """Enable hermes_gateway in /etc/rc.conf and start it.
++
++    The rc.d script itself is provided by the FreeBSD port; this function only
++    flips the rcvar and (re)starts the service.  --system is accepted for
++    parity with the systemd dispatcher but is a no-op on FreeBSD (rc.d only
++    has system scope).  --run-as-user is also a no-op: the rc script reads
++    hermes_gateway_user from rc.conf, set it there with sysrc.
++    """
++    del force, system  # noqa: F841 — accepted for dispatcher parity
++
++    if not FREEBSD_RC_SCRIPT_PATH.exists():
++        print(f"✗ {FREEBSD_RC_SCRIPT_PATH} not found.")
++        print("  The hermes_gateway rc.d script is shipped by the FreeBSD port.")
++        print("  Install with: sudo pkg install hermes-agent")
++        sys.exit(1)
++
++    import getpass
++    target_user = run_as_user or getpass.getuser()
++
++    print(f"Enabling {FREEBSD_RC_VAR}=YES in /etc/rc.conf...")
++    if not _freebsd_run_or_print(
++        ["sysrc", f"{FREEBSD_RC_VAR}=YES", f"hermes_gateway_user={target_user}"],
++        action="enable",
++    ):
++        return
++    print(f"Starting {FREEBSD_RC_SCRIPT_NAME}...")
++    _freebsd_run_or_print(
++        ["service", FREEBSD_RC_SCRIPT_NAME, "start"],
++        action="start",
++    )
++
++
++def freebsd_rc_uninstall(system: bool = False):
++    """Stop hermes_gateway and remove its rcvar from /etc/rc.conf.  Does NOT
++    delete the rc.d script itself — that belongs to the FreeBSD package."""
++    del system  # noqa: F841
++    print(f"Stopping {FREEBSD_RC_SCRIPT_NAME}...")
++    _freebsd_run_or_print(
++        ["service", FREEBSD_RC_SCRIPT_NAME, "stop"],
++        action="stop",
++    )
++    print(f"Removing {FREEBSD_RC_VAR} from /etc/rc.conf...")
++    _freebsd_run_or_print(
++        ["sysrc", "-x", FREEBSD_RC_VAR],
++        action="disable",
++    )
++    print(f"  (The rc.d script {FREEBSD_RC_SCRIPT_PATH} is owned by the package")
++    print("   manager — use 'pkg delete hermes-agent' to remove it.)")
++
++
++def freebsd_rc_start(system: bool = False):
++    del system
++    _freebsd_run_or_print(
++        ["service", FREEBSD_RC_SCRIPT_NAME, "start"],
++        action="start",
++    )
++
++
++def freebsd_rc_stop(system: bool = False):
++    del system
++    _freebsd_run_or_print(
++        ["service", FREEBSD_RC_SCRIPT_NAME, "stop"],
++        action="stop",
++    )
++
++
++def freebsd_rc_restart(system: bool = False):
++    del system
++    _freebsd_run_or_print(
++        ["service", FREEBSD_RC_SCRIPT_NAME, "restart"],
++        action="restart",
++    )
++
++
++def freebsd_rc_status(deep: bool = False, system: bool = False, full: bool = False):
++    del deep, system, full
++    # `service X status` doesn't need root; run directly without sudo.
++    try:
++        result = subprocess.run(
++            ["service", FREEBSD_RC_SCRIPT_NAME, "status"],
++            check=False,
++        )
++        if result.returncode != 0:
++            print()
++            print("To start the gateway:")
++            if _freebsd_is_root():
++                print("  hermes gateway start")
++            else:
++                print(f"  sudo service {FREEBSD_RC_SCRIPT_NAME} start")
++                print(f"  sudo sysrc {FREEBSD_RC_VAR}=YES   # start at boot")
++    except FileNotFoundError:
++        print("✗ service(8) not found — is this really FreeBSD?")
++
++
++# =============================================================================
+ # Service Configuration
+ # =============================================================================
+ 
+@@ -4083,6 +4251,8 @@ def _gateway_command_inner(args):
+                 print_info("  Or use tmux/screen for persistence: tmux new -s hermes 'hermes gateway run'")
+                 print()
+             systemd_install(force=force, system=system, run_as_user=run_as_user)
++        elif supports_freebsd_rc():
++            freebsd_rc_install(force=force, system=system, run_as_user=run_as_user)
+         elif is_macos():
+             launchd_install(force)
+         elif is_wsl():
+@@ -4119,6 +4289,8 @@ def _gateway_command_inner(args):
+             sys.exit(1)
+         if supports_systemd_services():
+             systemd_uninstall(system=system)
++        elif supports_freebsd_rc():
++            freebsd_rc_uninstall(system=system)
+         elif is_macos():
+             launchd_uninstall()
+         elif is_container():
+@@ -4149,6 +4321,8 @@ def _gateway_command_inner(args):
+             sys.exit(1)
+         if supports_systemd_services():
+             systemd_start(system=system)
++        elif supports_freebsd_rc():
++            freebsd_rc_start(system=system)
+         elif is_macos():
+             launchd_start()
+         elif is_wsl():
+@@ -4187,6 +4361,12 @@ def _gateway_command_inner(args):
+                     service_available = True
+                 except subprocess.CalledProcessError:
+                     pass
++            elif supports_freebsd_rc():
++                try:
++                    freebsd_rc_stop(system=system)
++                    service_available = True
++                except subprocess.CalledProcessError:
++                    pass
+             elif is_macos() and get_launchd_plist_path().exists():
+                 try:
+                     launchd_stop()
+@@ -4208,6 +4388,12 @@ def _gateway_command_inner(args):
+                     service_available = True
+                 except subprocess.CalledProcessError:
+                     pass
++            elif supports_freebsd_rc():
++                try:
++                    freebsd_rc_stop(system=system)
++                    service_available = True
++                except subprocess.CalledProcessError:
++                    pass
+             elif is_macos() and get_launchd_plist_path().exists():
+                 try:
+                     launchd_stop()
+@@ -4240,6 +4426,12 @@ def _gateway_command_inner(args):
+                     service_stopped = True
+                 except subprocess.CalledProcessError:
+                     pass
++            elif supports_freebsd_rc():
++                try:
++                    freebsd_rc_stop(system=system)
++                    service_stopped = True
++                except subprocess.CalledProcessError:
++                    pass
+             elif is_macos() and get_launchd_plist_path().exists():
+                 try:
+                     launchd_stop()
+@@ -4256,12 +4448,14 @@ def _gateway_command_inner(args):
+             print("Starting gateway...")
+             if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
+                 systemd_start(system=system)
++            elif supports_freebsd_rc():
++                freebsd_rc_start(system=system)
+             elif is_macos() and get_launchd_plist_path().exists():
+                 launchd_start()
+             else:
+                 run_gateway(verbose=0)
+             return
+-        
++
+         if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
+             service_configured = True
+             try:
+@@ -4269,6 +4463,13 @@ def _gateway_command_inner(args):
+                 service_available = True
+             except subprocess.CalledProcessError:
+                 pass
++        elif supports_freebsd_rc():
++            service_configured = True
++            try:
++                freebsd_rc_restart(system=system)
++                service_available = True
++            except subprocess.CalledProcessError:
++                pass
+         elif is_macos() and get_launchd_plist_path().exists():
+             service_configured = True
+             try:
+@@ -4320,6 +4521,9 @@ def _gateway_command_inner(args):
+         # Check for service first
+         if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
+             systemd_status(deep, system=system, full=full)
++            _print_gateway_process_mismatch(snapshot)
++        elif supports_freebsd_rc():
++            freebsd_rc_status(deep=deep, system=system, full=full)
+             _print_gateway_process_mismatch(snapshot)
+         elif is_macos() and get_launchd_plist_path().exists():
+             launchd_status(deep)
diff --git a/misc/hermes-agent/files/patch-hermes__cli_main.py b/misc/hermes-agent/files/patch-hermes__cli_main.py
new file mode 100644
index 000000000000..557c100f3aff
--- /dev/null
+++ b/misc/hermes-agent/files/patch-hermes__cli_main.py
@@ -0,0 +1,66 @@
+--- hermes_cli/main.py.orig	2026-05-06 08:29:55 UTC
++++ hermes_cli/main.py
+@@ -8118,7 +8118,7 @@ def main():
+     gateway_start.add_argument(
+         "--system",
+         action="store_true",
+-        help="Target the Linux system-level gateway service",
++        help="Target the system-level gateway service (Linux only; ignored on FreeBSD/macOS)",
+     )
+     gateway_start.add_argument(
+         "--all",
+@@ -8131,7 +8131,7 @@ def main():
+     gateway_stop.add_argument(
+         "--system",
+         action="store_true",
+-        help="Target the Linux system-level gateway service",
++        help="Target the system-level gateway service (Linux only; ignored on FreeBSD/macOS)",
+     )
+     gateway_stop.add_argument(
+         "--all",
+@@ -8146,7 +8146,7 @@ def main():
+     gateway_restart.add_argument(
+         "--system",
+         action="store_true",
+-        help="Target the Linux system-level gateway service",
++        help="Target the system-level gateway service (Linux only; ignored on FreeBSD/macOS)",
+     )
+     gateway_restart.add_argument(
+         "--all",
+@@ -8166,23 +8166,23 @@ def main():
+     gateway_status.add_argument(
+         "--system",
+         action="store_true",
+-        help="Target the Linux system-level gateway service",
++        help="Target the system-level gateway service (Linux only; ignored on FreeBSD/macOS)",
+     )
+ 
+     # gateway install
+     gateway_install = gateway_subparsers.add_parser(
+-        "install", help="Install gateway as a systemd/launchd background service"
++        "install", help="Install gateway as a systemd/launchd/rc.d background service"
+     )
+     gateway_install.add_argument("--force", action="store_true", help="Force reinstall")
+     gateway_install.add_argument(
+         "--system",
+         action="store_true",
+-        help="Install as a Linux system-level service (starts at boot)",
++        help="Install as a system-level service (Linux only; FreeBSD rc.d is always system-scoped)",
+     )
+     gateway_install.add_argument(
+         "--run-as-user",
+         dest="run_as_user",
+-        help="User account the Linux system service should run as",
++        help="User account the service should run as (Linux systemd / FreeBSD rc.d)",
+     )
+ 
+     # gateway uninstall
+@@ -8192,7 +8192,7 @@ def main():
+     gateway_uninstall.add_argument(
+         "--system",
+         action="store_true",
+-        help="Target the Linux system-level gateway service",
++        help="Target the system-level gateway service (Linux only; ignored on FreeBSD/macOS)",
+     )
+ 
+     # gateway setup
diff --git a/misc/hermes-agent/files/patch-hermes__cli_setup.py b/misc/hermes-agent/files/patch-hermes__cli_setup.py
new file mode 100644
index 000000000000..b3543dae6ca5
--- /dev/null
+++ b/misc/hermes-agent/files/patch-hermes__cli_setup.py
@@ -0,0 +1,95 @@
+--- hermes_cli/setup.py.orig	2026-05-06 08:29:55 UTC
++++ hermes_cli/setup.py
+@@ -2409,11 +2409,13 @@ def setup_gateway(config: dict):
+ 
+         _is_linux = _platform.system() == "Linux"
+         _is_macos = _platform.system() == "Darwin"
++        _is_freebsd = _platform.system() == "FreeBSD"
+ 
+         from hermes_cli.gateway import (
+             _is_service_installed,
+             _is_service_running,
+             supports_systemd_services,
++            supports_freebsd_rc,
+             has_conflicting_systemd_units,
+             has_legacy_hermes_units,
+             install_linux_gateway_from_setup,
+@@ -2424,13 +2426,17 @@ def setup_gateway(config: dict):
+             launchd_install,
+             launchd_start,
+             launchd_restart,
++            freebsd_rc_install,
++            freebsd_rc_start,
++            freebsd_rc_restart,
+             UserSystemdUnavailableError,
+         )
+ 
+         service_installed = _is_service_installed()
+         service_running = _is_service_running()
+         supports_systemd = supports_systemd_services()
+-        supports_service_manager = supports_systemd or _is_macos
++        supports_freebsd = supports_freebsd_rc()
++        supports_service_manager = supports_systemd or _is_macos or supports_freebsd
+ 
+         print()
+         if supports_systemd and has_conflicting_systemd_units():
+@@ -2446,6 +2452,8 @@ def setup_gateway(config: dict):
+                 try:
+                     if supports_systemd:
+                         systemd_restart()
++                    elif supports_freebsd:
++                        freebsd_rc_restart()
+                     elif _is_macos:
+                         launchd_restart()
+                 except UserSystemdUnavailableError as e:
+@@ -2459,6 +2467,8 @@ def setup_gateway(config: dict):
+                 try:
+                     if supports_systemd:
+                         systemd_start()
++                    elif supports_freebsd:
++                        freebsd_rc_start()
+                     elif _is_macos:
+                         launchd_start()
+                 except UserSystemdUnavailableError as e:
+@@ -2468,7 +2478,12 @@ def setup_gateway(config: dict):
+                 except Exception as e:
+                     print_error(f"  Start failed: {e}")
+         elif supports_service_manager:
+-            svc_name = "systemd" if supports_systemd else "launchd"
++            if supports_systemd:
++                svc_name = "systemd"
++            elif supports_freebsd:
++                svc_name = "FreeBSD rc.d"
++            else:
++                svc_name = "launchd"
+             if prompt_yes_no(
+                 f"  Install the gateway as a {svc_name} service? (runs in background, starts on boot)",
+                 True,
+@@ -2478,6 +2493,9 @@ def setup_gateway(config: dict):
+                     did_install = False
+                     if supports_systemd:
+                         installed_scope, did_install = install_linux_gateway_from_setup(force=False)
++                    elif supports_freebsd:
++                        freebsd_rc_install(force=False)
++                        did_install = True
+                     else:
+                         launchd_install(force=False)
+                         did_install = True
+@@ -2486,6 +2504,8 @@ def setup_gateway(config: dict):
+                         try:
+                             if supports_systemd:
+                                 systemd_start(system=installed_scope == "system")
++                            elif supports_freebsd:
++                                freebsd_rc_start()
+                             elif _is_macos:
+                                 launchd_start()
+                         except UserSystemdUnavailableError as e:
+@@ -2501,6 +2521,8 @@ def setup_gateway(config: dict):
+                 print_info("  You can install later: hermes gateway install")
+                 if supports_systemd:
+                     print_info("  Or as a boot-time service: sudo hermes gateway install --system")
++                elif supports_freebsd:
++                    print_info("  (FreeBSD rc.d install runs sysrc + service start with sudo.)")
+                 print_info("  Or run in foreground:  hermes gateway")
+         else:
+             from hermes_constants import is_container
diff --git a/misc/hermes-agent/files/patch-hermes__cli_uninstall.py b/misc/hermes-agent/files/patch-hermes__cli_uninstall.py
new file mode 100644
index 000000000000..c971198c7ae1
--- /dev/null
+++ b/misc/hermes-agent/files/patch-hermes__cli_uninstall.py
@@ -0,0 +1,35 @@
+--- hermes_cli/uninstall.py.orig	2026-05-06 08:29:55 UTC
++++ hermes_cli/uninstall.py
+@@ -201,6 +201,32 @@ def uninstall_gateway_service():
+         except Exception as e:
+             log_warn(f"Could not remove launchd gateway service: {e}")
+ 
++    # 4. FreeBSD: stop the rc.d service and remove its rcvar from rc.conf.
++    #    The rc script itself (/usr/local/etc/rc.d/hermes_gateway) is owned
++    #    by the FreeBSD package — `pkg delete hermes-agent` removes it.
++    elif system == "FreeBSD":
++        try:
++            from hermes_cli.gateway import (
++                supports_freebsd_rc,
++                FREEBSD_RC_SCRIPT_NAME,
++                FREEBSD_RC_VAR,
++            )
++            if supports_freebsd_rc():
++                sudo = [] if os.geteuid() == 0 else (["sudo"] if shutil.which("sudo") else None)
++                if sudo is None:
++                    log_warn(f"  Need root to disable {FREEBSD_RC_SCRIPT_NAME}. "
++                             f"Run: sudo service {FREEBSD_RC_SCRIPT_NAME} stop && "
++                             f"sudo sysrc -x {FREEBSD_RC_VAR}")
++                else:
++                    subprocess.run(sudo + ["service", FREEBSD_RC_SCRIPT_NAME, "stop"],
++                                   capture_output=True, check=False)
++                    subprocess.run(sudo + ["sysrc", "-x", FREEBSD_RC_VAR],
++                                   capture_output=True, check=False)
++                    log_success(f"Stopped and disabled {FREEBSD_RC_SCRIPT_NAME}")
++                    stopped_something = True
++        except Exception as e:
++            log_warn(f"Could not disable FreeBSD gateway service: {e}")
++
+     return stopped_something
+ 
+ 
diff --git a/misc/hermes-agent/files/pkg-message.in b/misc/hermes-agent/files/pkg-message.in
new file mode 100644
index 000000000000..2ccb9172ae10
--- /dev/null
+++ b/misc/hermes-agent/files/pkg-message.in
@@ -0,0 +1,25 @@
+[
+{ type: install
+  message: <<EOM
+Hermes stores its working set under ${HOME}/.hermes/.  Before the first
+run, bootstrap that directory and configure API keys with:
+
+  hermes setup
+
+The bundled skill templates live under %%DATADIR%%/skills/ and
+%%DATADIR%%/optional-skills/; Hermes copies the ones it needs into
+${HOME}/.hermes/skills/ on demand.
+
+To run Hermes as a service, enable one or both of the rc(8) scripts:
+
+sysrc hermes_gateway_enable="YES"
+sysrc hermes_gateway_user="<account whose ~/.hermes is used>"
+
+sysrc hermes_dashboard_enable="YES"
+sysrc hermes_dashboard_user="<account whose ~/.hermes is used>"
+
+See %%PREFIX%%/etc/rc.d/hermes_gateway and hermes_dashboard for all
+tunables (host, port, profile, extra flags).
+EOM
+}
+]
diff --git a/misc/hermes-agent/files/wrapper.in b/misc/hermes-agent/files/wrapper.in
new file mode 100644
index 000000000000..79affb1bd613
--- /dev/null
+++ b/misc/hermes-agent/files/wrapper.in
@@ -0,0 +1,13 @@
+#!%%PYTHON_CMD%%
+# Wrapper installed by FreeBSD ports — see misc/hermes-agent.
+# Hermes is installed under %%HERMES_LIBDIR%% (not site-packages) because
+# upstream ships top-level packages with generic names (tools, agent,
*** 37 LINES SKIPPED ***


home | help

Want to link to this message? Use this
URL: <https://mail-archive.FreeBSD.org/cgi/mid.cgi?69fc4d14.1a11f.1c4d6998>