The bot no longer shells out to `opencode run`. Instead it POSTs to the
OpenAI-compatible /chat/completions endpoint exposed by llama-server on
halo.hoyer.tail:8000 directly. This removes the Bun/sqlite cold-start
overhead per request, drops the pkgs.opencode runtime dependency, and
eliminates the ExecStartPre dance that materialized config.json into the
service's $HOME.
Conversation history is now stored as a proper OpenAI `messages` list
with system/user/assistant roles, instead of the XML blob that was
inlined into a single `opencode run` argument. The interactive opencode
setup (config/opencode/config.json) is unchanged — only the bot stops
depending on it.
The module gains a `modelBaseUrl` option; `model` is now the bare model
name (`halo-8000`) without the provider/ prefix that the opencode CLI
required.
Vendors the npm tarball + lockfile and wraps the `pi` binary with `fd` and
`ripgrep` on PATH. Also installs it on the m4 darwin host.
`buildNpmPackage` is pulled from `inputs.unstable` because nixos-25.11's
`prefetch-npm-deps-0.1.0` panics on cacache index entries that contain
either multiple lines or JSON values with embedded spaces (npm's
`accept: application/...; q=1.0, ...` headers). For this lockfile,
`@esbuild/netbsd-arm64` and `@rollup/rollup-linux-x64-musl` trigger
both conditions and `--map-cache` fails with `EOF while parsing a
string at line 1 column 369`. Fixed upstream in nixos-unstable, which
now uses `lines()` + `split_once('\t')`.
Mirrors the existing nextcloud-claude-bot setup but invokes `opencode run`
against the local `halo-8000` provider/model. The bot listens on
127.0.0.1:8086, is exposed via the `/_opencode-bot/` location on
nc.hoyer.xyz, and uses `@Halo` as its mention trigger in group chats.
The opencode config (config/opencode/config.json) is installed into the
service's $HOME/.config/opencode/ on each start, so the bot picks up the
same provider definition the user uses interactively. The model map keys
are renamed to `halo-8000` / `halo-8001` so the canonical
`provider/model` reference works without an alias indirection.
tailscale set is strict about boolean flags and silently ignores
--advertise-exit-node without =true. Result: the tailscaled-set unit
ran cleanly but AdvertiseRoutes stayed null. Spell the value out so the
flag takes effect.
Introduces a headscale ACL policy (file-mode) plus matching client config:
- New systems/x86_64-linux/attic/headscale-policy.hujson:
* tag:llm restricts a node to talking only to halo:8000
* all other harald@ nodes have full mesh access to each other
* harald@ nodes can route internet traffic via approved exit nodes
* autoApprovers.exitNode = [tag:llm] auto-approves the exit route
advertised by any tag:llm node (currently mx)
- attic headscale.nix: wire policy.mode = "file" / policy.path to
the .hujson above.
- mx default.nix: enable useRoutingFeatures = "server" (needed for IP
forwarding) and add extraSetFlags = ["--advertise-exit-node"] so the
flag is reapplied on every activation, not just initial login.
Operational steps after deploy:
headscale nodes tag -i 10 -t tag:llm
Avoid breaking existing clients and the registered OIDC redirect URI by
keeping the original domain. Only the host backing it changes (mx -> attic);
DNS just needs to be repointed.
Headscale is moving off the mx mailserver onto the attic cache host.
The new public URL is https://headscale.hoyer.world.
- Switch from useACMEHost = "hoyer.xyz" (mx wildcard DNS-01) to
enableACME = true, since attic only has HTTP-01 configured.
- Move headscale port to 8081 to avoid clashing with atticd on 8080.
- Drop the 192.168.178.254 LAN nameserver from dns.nameservers.global,
which isn't reachable from the Hetzner instance.
Operational steps still required on attic:
- Provision /var/lib/headscale/client_secret
- Migrate the headscale state DB from mx
- Point headscale.hoyer.world DNS at attic
- Update the Nextcloud OIDC client's redirect URI
New `metacfg.services.opencode` module under modules/nixos/services/opencode/
with options for port, user, homeDir, sopsFile, and extraPackages. User and
homeDir default off `metacfg.user`. Host configs for amd and sgx reduce to
enabling the module and pointing at their respective sops file.
Service PATH gains jq, yq-go, python3, gh, gnutar, gzip, unzip, wget,
diffutils, patch, file, tree, bun, uv, ast-grep, claude-code, and tmux for
agent ergonomics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was set defensively without knowing the actual GPU arch; if ROCm
supports the card natively, the override is at best a no-op and at
worst masks the real arch. Add it back with the right value if the
service actually fails to detect the GPU.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runs llama.cpp's ROCm build under DynamicUser, with the HF model cache
in StateDirectory (survives systemctl clean) and KV slot saves in
CacheDirectory. Listens on :8000.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The full nix-ld library list shadowed nix's own curl, breaking
libnixstore.so with "CURL_OPENSSL_4 not found". The prebuilt node
watcher binding only needs libstdc++/libgcc_s, so use stdenv.cc.cc.lib
and let nix-built tools resolve their own deps via RUNPATH.
The file watcher binding (and other node-precompiled .node modules
loaded via dlopen) failed with "libstdc++.so.6: cannot open shared
object file" because systemd services don't inherit the user shell's
LD path. Reuse the nix-ld library list so the service sees the same
common libraries unwrapped binaries get globally.
The opencode-serve unit ran with systemd's minimal default PATH, so
shell commands invoked by the agent (git, make, nix, node, rg, etc.)
were not found. Set systemd.services.opencode-serve.path on both sgx
and amd to a common dev toolset.
Mirror of the sgx opencode setup: systemd service on port 4196 fronted
by nginx with a per-host ACME cert (DNS-01 via internetbs). Adds amd
key + path rule to .sops.yaml so secrets under .secrets/amd/ encrypt
for the host.
Wire up restartUnits on secrets whose consumers cache them in memory
(daemons read at startup), so sops-nix restarts the affected unit on
activation when the decrypted content changes:
- firefly: app_key → phpfpm-firefly-iii;
auto_import_secret + access_token → phpfpm-firefly-iii-data-importer
- searx: secret_key → uwsgi
- opencode: web password → opencode-serve
- mail: sasl_passwd → postfix
- forgejo: gitea_dbpass → forgejo; runner-token → gitea-runner-default
Secrets read on demand by oneshots/timers (firefly sparda_pin, ntfy
token, restic backup creds, acme dns creds, wg conf) are left as-is.
Bulk imports of 100+ transactions per chunk hit the default 60s
fastcgi timeout on the main Firefly III vhost too — not just the
importer endpoint. The importer's per-transaction API call to Firefly's
/api/v1/transactions can take 20+s on a fresh DB without ANALYZE,
which compounds with the 30s PHP max_execution_time cap.
- nginx fastcgi_read_timeout=600s on both `firefly` and `firefly-import`
vhosts
- php_admin_value[max_execution_time]=600 + memory_limit=512M on both
PHP-FPM pools
- VANITY_URL on the importer now points to the main Firefly III URL
(was wrongly pointing at the importer's own domain, breaking
clickable transaction-show links in importer log messages)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SQLite was slow under btrfs CoW, and the no-CoW migration path turned
out to be fragile (WAL deletion without checkpoint = data loss). Move
to PostgreSQL on Unix-socket peer auth — no password needed for the
local-host setup, NixOS provisions the database+user declaratively.
Drop the now-unused +C tmpfiles rule on the sqlite directory; the
leftover database.sqlite* files at /var/lib/firefly-iii/storage/database/
are harmless and can be removed manually after switch is verified.
Migration of existing Firefly III data is not preserved by this
commit — fresh-start path: re-register admin, re-issue PAT, re-POST
the bulk CSV through the importer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Random-write SQLite traffic fragments CoW filesystems quickly. The `h`
tmpfiles directive sets +C on the database directory; new SQLite files
(WAL, SHM, recreated main DB) inherit no-CoW automatically. No-op on
non-btrfs filesystems.
Migration of existing files must be done manually with checkpoint-first:
systemctl stop phpfpm-firefly-iii.service
sqlite3 .../database.sqlite 'PRAGMA wal_checkpoint(TRUNCATE);'
# then recreate main file inside the +C dir
systemctl start phpfpm-firefly-iii.service
Skipping the wal_checkpoint and naively deleting .sqlite-wal will lose
all writes that haven't been checkpointed (PHP-FPM SIGTERM does not
trigger a checkpoint).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end verified: aqbanking-cli fetches Sparda Südwest transactions
via FinTS PIN/TAN + SecureGo+, exports CSV using a custom decimal-amount
profile, POSTs to firefly-iii-data-importer's autoupload endpoint, which
creates transactions in Firefly III via API.
Changes vs. previous WIP commit:
- firefly/access_token sops slot for the importer's Firefly III API auth
(FIREFLY_III_ACCESS_TOKEN_FILE — was the missing piece causing 401s
from the API after the autoupload secret authenticated)
- nginx fastcgi_read_timeout=600s on the importer vhost (prevents 504
while PHP-FPM is still processing the batch)
- PHP-FPM max_execution_time=600s + memory_limit=512M on the importer
pool (PHP's stock 30s aborts mid-import for batches > ~50 transactions)
- timer re-enabled, wantedBy=[timers.target]
Caveats baked into a code comment:
- Sparda online-banking PIN must be [A-Za-z0-9] only. aqbanking 6.8.2's
-P pinfile mangles `:`, `+`, `'`, `?`, `@`, `%`, `*`; bank locks the
access (3 soft / 9 hard strikes) on rejected attempts. Same applies
whenever the sops secret is rotated.
- Bulk historical imports beyond the PSD2 90-day window need interactive
SCA approval per ~30-day chunk and cannot run from the timer; the
daily 35-day rolling window stays inside the no-SCA region.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end FinTS pipeline against Sparda Südwest is wired up but
disabled — aqbanking 6.8.2's `-P pinfile` flag does not consume the
file content correctly on this build (verified: pinfile bytes match
the manually-typed PIN exactly, yet the bank receives a wrong PIN).
Three rejected attempts locked the access at Sparda; do not re-arm
the timer until the auth path is replaced (likely python-fints).
What works:
- aqbanking config and FinTS dialog (manual PIN entry)
- getaccsepa workaround for HKCAZ "Mussfeld 9160" rejection
- custom CSV profile (decimal amounts + IBAN columns) wired in
- Firefly importer auto-upload settings + sops secret slot
- inbox + profile-symlink tmpfiles
What's broken:
- Headless PIN delivery via aqbanking-cli -P
- Timer left wantedBy=[] so it cannot fire post-deploy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lays the groundwork for Sparda-Bank Südwest transaction sync via
direct FinTS (no third-party data proxy). aqbanking-cli in the system
PATH, persistent state at /var/lib/firefly-aqbanking, sops slot for
the online-banking PIN. Initial enrollment must be done interactively
on the host; systemd timer for automated fetches comes in a follow-up.
Share the check script via a parameterized mkDiskCheck function over
{ name, mountPoint, label } and iterate an attrset to emit the boot
and root services plus their daily timers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>