14 KiB
Working notes for Claude on mainframe
LLM / inference stack
Connor runs a self-hosted inference stack: LiteLLM proxy in front of a local GPU
model (llama-swap), OpenAI, Anthropic, and Moonshot/Kimi, with OpenCode + OMO on
top. Before touching anything under ~/config/litellm/, ~/.config/opencode/,
or the model routing, read ~/guides/llm/robots.md. It carries the design
decisions, the measured data, and — more importantly — the specific wrong
conclusions this project has already reached and corrected. ~/guides/llm/README.md
is the short human version.
The five things most likely to waste an hour if you don't know them:
-
A config value is not applied until an invalid value produces an upstream 400. LiteLLM silently discards parameters it doesn't route (
store: falsebare inlitellm_paramsreturns 200 and never reaches OpenAI; it needsextra_body). -
Editing bind-mounted config does nothing without
sudo podman restart <container>. Changing an env var needs a fullsudo podman-compose up -d <svc>— a restart is not enough. The reverse trap is worse and bites silently:podman-compose up -dwill NOT restart a container when only a bind-mounted file changed, because the compose definition is unchanged. It prints the container name and does nothing. This applies to Grafana provisioning as much as tolitellm/config.yaml. -
If every hosted model hangs (not errors) while the host has internet, it's Docker:
dockerdsetsip filterFORWARD to policy drop and kills all podman egress. DNS still resolves, which makes it look like a broken vendor. Fix:sudo bash ~/config/host/podman-docker-forward.sh. Don't remove Docker — the Gitea Actions runners need it.
3a. podman ps saying running is not evidence a container is alive.
After a reboot on 2026-09-19, eleven containers — ntfy, bitwarden,
authelia, gitea, grafana, prometheus among them — reported Up 9 hours
with a State.Pid that no longer existed. ntfy's healthcheck had failed
539 consecutive times while podman ps called it up. Cause:
podman-restart.service is Type=oneshot and forks a conmon per
container into its own cgroup; ONE container failing to create fails the
unit, and systemd's default KillMode=control-group then SIGKILLs every
conmon it just started. conmon is what updates container state, so killed
it updates nothing and the state stays running forever.
Check with bash ~/config/host/podman-restart-killmode.sh --check, which
compares each running container's pid against /proc. That script also
applies the fix (KillMode=process), applied here 2026-09-19 — it takes
effect on the next boot, so a reboot before then can still produce this.
Recover one with sudo podman restart -t 5 <name> && sudo podman start <name>; the restart is EXPECTED to error with conmon process killed,
and the start afterwards is what works.
⚠ Do not test liveness with kill -0 <pid>. The containers run as
root, so as a normal user it fails with EPERM, not ESRCH — it reports
every container dead and reads as a total outage. That misdiagnosis was
made once already. /proc/<pid> needs no privilege.
-
HTTP 200 with empty content is not necessarily a bug. Three causes: an Anthropic refusal, a reasoning model eating its whole
max_tokensbudget before emitting text, or a dropped parameter. Local models do it too. Give reasoning models real headroom before concluding anything. -
Zen (
zen/*) is under a ToS block — make no calls to it, including "harmless" ones. Entries stay in config in an access group no key holds. -
The OpenCode sidebar under-reports cost — badly. It omits delegated child sessions, where most agent work happens: it showed ~$5.80 against a vendor-verified $17.39. Use
opencode statsor the LiteLLM spend log. -
Open WebUI gives every model its full built-in tool suite by default (
meta.builtinToolsdefaults to True when absent). Models that are weak at tool discipline loop onquery_knowledge_filesand friends even with no knowledge base configured. Symptom: many round trips with 25-30 token completions. Disable per-model in Admin → Models; there is no global switch. -
On this box, local inference is RAM-bandwidth-bound, not VRAM-bound. Decode speed is set by a model's active parameters, not its total, so an 80B MoE with 3B active runs at the same speed as a 30B with 3B active. Free VRAM buys prefill, not decode. Don't recommend a bigger GPU without checking which wall you're actually against.
-
The OMO agent aliases embed their model family on purpose —
personal/atlas-kimi-k2.7-code, notpersonal/atlas. OMO picks its per-model prompt by string-matching the last path segment of the model name, so a "tidy-up" rename silently disables every Kimi/GPT-specific prompt and guard with no error. Never shorten them.local/librarianandlocal/exploreare deliberately left un-suffixed —isGptModelmatches a bare"gpt"substring andgpt-oss-20bwould trip it.
Cost work: read model_group, never model, to attribute a request. Cost is
driven by cache-read volume and context size far more than by model choice.
The dotfiles repo and the fleet
~/.dotfiles is one branch linked onto four machines by dotbot.
There are deliberately no per-host branches: the layout this replaced was three
machines with two histories that had never met, and host layers cost a few small
files and remove the merge entirely.
| Host | Address | Role | Packages |
|---|---|---|---|
mainframe |
10.0.0.2 | the server — containers, inference stack, Moonlight host | pacman + AUR |
nas |
10.0.0.3 | bulk storage; NFS-exports audio books docs downloads photo video; own 03:30 backup run |
pacman + AUR |
system76 |
10.0.0.4 | personal laptop | pacman + AUR |
sctfw004 |
10.0.0.8 | work laptop — work_laptop in ssh config, user connorjohnstone |
apt + flatpak |
sctfw004 is US-government adjacent: OpenAI and local models only, never
Anthropic. Don't propose an Anthropic-backed workflow for that machine.
Five things about this repo that will cost you an hour:
-
The
.gitignoreis an allowlist, and it is load-bearing. It opens with**/*and then names what is allowed back in. A new top-level directory is invisible to git until it is explicitly un-ignored, so "I added the file and git doesn't see it" is expected, not a bug — fix it by adding the!entry, never by relaxing the**/*. The denylist it replaced accumulated browser caches, mail caches and vendored binaries. It also re-excludes credential shapes (**/credentials,**/*.env,**/hosts.yml,**/password,.secrets.zsh); every one of those has held a live credential on these machines. Leave them alone. -
Secrets never live in a tracked file, and specifically not in
zshenv.common/zsh/zshenvends by sourcing~/.secrets.zsh, which is untracked and mode 600 —common/zsh/secrets.zsh.exampleis the shape to copy. Keys for OpenCode are read out ofrbwat launch and passed in, never written to disk. A new secret goes in~/.secrets.zsh, never anywhere under~/.dotfiles. -
Any unit that must run on exactly one machine carries
ConditionHost=<host>. One branch is linked everywhere, so an unguarded timer fires on all four.inbox-tidy,movie-recs,rent-utilitiesandddnsmutate a live mailbox, send notifications, send bills and update live DNS records, so they are guarded twice: the units exist only inhosts/mainframe/systemd/and never incommon/, and each carries the condition. Genuinely per-machine things likehypr_refreshare correctly unguarded — don't "fix" them. -
dotsis the one command that answers "is this machine clean". Committed, pushed, and does the package manifest still match reality. It is wired intozshrcasdots --nag, which prints nothing when everything is clean. That silence is the point — a login banner that always fires is one you stop reading. -
pkg-snapshotwrites what is installed intopackages/<host>.*— pacman, AUR, apt and flatpak as separate files. Run it after installing something worth keeping and commit the diff; the diff doubles as the log of what was added and when.pkg-restoregoes the other way.
This file is a symlink. ~/.claude/CLAUDE.md →
~/.dotfiles/common/claude/CLAUDE.md. Editing it edits the repo, so a change
here wants a commit and reaches every machine at the next install.
Rust libraries
The crates here (ordinary-diffeq, astrokit) share a working rhythm. Follow it
in a new one.
-
One coherent change at a time, and the commit is Connor's to make. Finish the piece of work, leave it in the working tree, show what changed, and stop. Don't commit, push, tag or
cargo publishunless the request says to. Tests passing is not the same as reviewed. -
Zero warnings, zero fmt diff, every time. The bar is the whole tree, not the lines you touched:
cargo fmt --checkwith no output.cargo clippy --all-targets -- -D warnings.cargo doc --no-depswith no warnings at all.cargo test, which runs the doctests too. Include the readme as the crate's front page with#![doc = include_str!("../readme.md")]so its examples are compiled and cannot drift from the API.cargo test --no-default-featuresif the crate has feature flags.
A warning that was already there still counts. Fix it in the same pass and say so. Three rustdoc warnings sat in
ordinary-diffeqacross four releases because each session read them as someone else's problem. -
Run
cargo fmtand keep the tree clean. If a repo isn't clean yet, clean it in its own commit rather than letting formatting ride along with a change — but clean it. Where rustfmt would wreck something laid out on purpose, a Butcher tableau or a table of interpolation coefficients, put#[rustfmt::skip]on that item with a one-line reason;--checkhonors it, so the tree stays clean and the table stays readable. Checking one file isrustfmt --check --edition 2021 <file>, becausecargo fmt --check -- <file>ignores the file argument and does the whole workspace. -
Hooks, split by what they cost. Measure before deciding where a check goes; on
ordinary-diffeq's warm treecargo fmt --checkis 0.1s, clippy 0.6s andcargo test --lib0.6s, but the fullcargo testis 16s because every doctest links its own binary. Sopre-commitgets the first three andpre-pushgets the doctests andcargo doc. Keep the benches and anything networked out of both — a hook that takes a minute is one that gets--no-verify. Git does not track where it looks for hooks, so commit them tohooks/and point each clone at it once withgit config core.hooksPath hooks. -
Benchmarking, when a change could plausibly cost something. criterion, and measure rather than assert — the method and mainframe's noise floor are in the
rust-crate-releaseskill. -
In 0.x, a breaking change is a minor bump. Changing a trait method signature took
ordinary-diffeqfrom 0.5.0 to 0.6.0. -
The release process — branch, bump
Cargo.tomlalone, merge--no-ff, annotated tag, publish. The exact sequence is in therust-crate-releaseskill. A publish cannot be undone; a bad version can only be yanked. -
Each feature keeps a plan that outlives it, as an issue on the Gitea remote. Don't create
roadmap/features/files in a repo that doesn't already have them. Comment on the issue when the work lands: what was done, what was measured, what is still a known limitation, and which approach was tried first and why it was wrong. The last of those is what saves the next session. Leave the issue open until given permission to close it.
General
~/config/and~/docs/are deliberately untracked. Back up edits there by hand as<file>.pre-<thing>snapshots — the established convention, and the only backup those trees get. Inside a tracked repo git already is that backup, so a.pre-*file there is redundant (and.gitignoreexcludes the pattern anyway).~/guides/is tracked in the~repo and is where reference material goes,~/.dotfilesis its own repo. Check which one you are in before committing.- Don't create project-level
CLAUDE.mdfiles in~— Connor keeps guidance at the~/.claudelevel and reference material in~/guides/. - Measure before concluding, and when a previous conclusion turns out to have been reached from bad data, record both the correction and why the original reasoning was unsound — otherwise someone re-opens it.
- US English spelling everywhere — code, comments, docs, commit messages,
prose.
meters,normalize,stabilize,recognize,license,modeling,maneuver,behavior,analyze. British spellings keep slipping into generated text, so check before handing work over. - Plain prose, not persuasive. Write the fact and stop. One line of comment where one line does it. Commit messages say what changed, not why it was a good idea. No bold for emphasis, no aphorisms, no "X is not Y" constructions, no sentence whose only job is to make a point land. Concrete examples that explain something are welcome; salesmanship is not.
- No AI attribution in commit messages. No
Co-Authored-By: Claude, no session-link trailer, no model named as an author — subject and body only. This overrides the harness default, which asks for the trailer.