searxng-mcp.py is a stdlib-only stdio MCP server with one web_search tool
against search.rcjohnstone.com. It is in both profiles and asks first on
oc-work.
lsp: true in opencode.json, and the launcher sets
OPENCODE_EXPERIMENTAL_LSP_TOOL and OPENCODE_DISABLE_LSP_DOWNLOAD.
The launcher adds gitea-mcp to oc-local through OPENCODE_CONFIG_CONTENT when
the binary and GITEA_MCP_TOKEN exist, limited to 15 tools.
gitea_issue_write asks.
oc-work: local models only on the local key, edits and most commands ask,
loads instructions/work.md. oc-local (also oc, opencode): every model,
defaults to qwen3.8-flash-next-iq3s-med, edits and commands run except
destructive and outward-facing ones. On sctfw004 oc-local presents the work
key. personal.json and work-strict.json removed.
Agents: drop variant, which has no effect on LiteLLM models. dev takes its
permissions from the profile. The read-only agents deny redirects and the
write/exec flags of allowed commands, swap find for fd, and only allow
listing forms of git branch. review defaults to the uncommitted diff.
CLAUDE.md: add the comment, process and shell-tool rules from the laptop's
AGENTS.md, and note that OpenCode reads this file.
taste.md: briefs and feedback for Fall, Skatepark Punks, Lo-fi Beats,
Game Soundtracks, Big Gay Pregame, Pop Italiano, Rap Italiano, Musica
Latina, Ska, Basic Alt-Rock and i still remember myspace. Corrects the
System of a Down note, which recorded a two-song limit he never set.
SKILL.md: downloads stay untagged until the pipeline job runs; the
search preview can differ from what the downloader fetches; Owned
answers can come from compilation-tagged rows.
The tracked preset had print_host 10.0.0.34:4408, which breaks CFS sync.
This is the working one from mainframe: print_host http://10.0.0.34 and
the Device UI on :4408.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dev, architect, review, duck and brainstorm move from
litellm/muse-glimmer-30b-low to litellm/qwen3.8-27b-gsq-s-med, in both the
local-only and personal profiles. Glimmer's model entries are left in place so
reverting is a one-line change.
⚠ MEDIUM, not low -- do not "align" this with the -low convention Glimmer uses.
Qwen's own model card: "lower reasoning effort does not always reduce overall
task completion time" on multi-turn agentic work, because insufficient analysis
causes more failed actions and retries, raising latency AND total tokens. The
low-effort result that justifies muse-glimmer-30b-low was measured on Glimmer
and does not transfer.
⚠ The key is reasoning_effort, NOT Glimmer's reasoning_strength. The wrong key
is dropped by kwarg filtering before it reaches the template, returns 200, and
silently leaves the model at its default -- which for qwen3.8 is xhigh. Levels
are low / medium / xhigh; there is no `high`.
Verified the control actually moves rather than merely being accepted:
171 reasoning tokens at low against 430 at medium on one prompt.
Requires qwen3.8-27b-gsq-s-med in ~/config/litellm/config.yaml (untracked;
snapshot at config.yaml.pre-qwen38low).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZomrX5fYmPYLusxcXTwvq
The ChatGPT subscription meters on TOKENS, not messages. OpenAI moved to
token-based accounting and states plainly that "a longer task can use more of
your allowance than a short task, so a fixed number of messages is not a
reliable measure of remaining usage". On that plan the declared context IS
the budget.
Reverts chatgpt/* and agent/*-gpt-5.x from 1,050,000 back to 272,000 in all
three profiles. Raising them to the API's published figure was correct for
capability and wrong for cost: measured over 450 requests, chatgpt/gpt-5.6-
terra averaged 128,395 prompt tokens against 374 completion -- a 343:1 ratio
-- and peaked at 249,647, i.e. sessions grew until they hit the declared
ceiling and compacted there. Quadrupling the ceiling quadruples the room they
grow into, and every turn re-sends the whole context. 272,000 also matches
Codex's own documented context.
⚠ THE LESSON FROM THE LOCAL TIER DOES NOT TRANSFER. Declaring the true
context is strictly right for a local model, where context is free -- that
fix took muse-glimmer-30b from 15/24 to 21/24. On a metered subscription the
same change is a bill. Same edit, opposite sign, and the distinguishing
question is who pays per token.
Also points dev/architect/review/duck/brainstorm at a new
chatgpt/gpt-5.6-terra-low alias, which pins reasoning_effort server-side.
GPT-5.6 defaults to MEDIUM when effort is omitted, and nothing anywhere in
these configs set it, so every work agent has been running at medium. The
hypothesis being tested is not that this saves the ~374 completion tokens
directly, but that less reasoning means fewer tool calls, fewer turns, and
therefore less context re-transmission -- which is where the tokens actually
go.
⚠ THAT HYPOTHESIS IS UNVERIFIED, and so is whether the parameter arrives at
all. LiteLLM documents dropping reasoning_effort from gpt-5.4-and-newer
requests that carry tools, which is every agentic request; this route goes
through the Responses transformer, which may exempt it. A dropped param
returns a normal 200 and changes nothing. A synthetic A/B was run and was
INCONCLUSIVE: the fixture was too easy, reasoning came back 11 tokens and 0
in the two arms, so there was no signal to compare, and low made MORE tool
calls (9 vs 8) at n=1. Real sessions will settle it -- watch completion_
tokens in the spend log against the 374 baseline.
OMO agents are untouched: they are defined in ~/.omo/omo.jsonc, not here.
personal.json's five stay on muse-glimmer-30b-low from the local trial.
Hardening pass over the script. Every fix below was reproduced before being
made; two findings that looked serious were tested and dropped.
THE ONE THAT MATTERED. The write was not atomic and could destroy a day's
todos. It did:
cp -p "$src" "$dst" # yesterday's UNPRUNED note lands at the target
cat "$tmp" > "$dst" # then gets overwritten with the real content
Interrupted between those, today's note silently becomes yesterday's,
unpruned. Interrupted during the redirect, it is truncated. Content is now
built in a temp inside the notes directory and renamed over the target, so
the target is either the old file or the new one and never a half-written
mixture.
⚠ `cp -p` was NOT redundant, and a review pass that recommended removing it
was wrong. A bare `cat > "$dst"` preserves the mode only when the target
already exists; on the normal path the target is new and takes 0644 from the
umask. Measured: source 600 -> new file via redirect 644, pre-existing file
600. The mode is now set explicitly with `chmod --reference` before the
rename, which is correct in both cases.
OUTPUT THAT LIED. The success line always said "(pruned)", including when
the prune script was missing and the plain-copy fallback ran. That is the
same defect as the error message that advertised a --force flag before one
existed: a user-facing string asserting work the code did not do. The
message now reports which path was taken, and a prune script that FAILS is
reported instead of aborting on set -e with no explanation.
ARGUMENT HANDLING. Anything that was not --force became a date, so
`todo_rollover --dry-run` failed with "source note .../--dry-run.md not
found" -- a missing-file error for what was really a bad flag. Unknown
options are now rejected, `--` and `-h/--help` work, arity is checked, and
dates are validated for shape AND realness (2026-13-45 is caught up front).
ALSO FIXED
- `[[ -x $PRUNE ]]` was the wrong test: PRUNE is run as `python3 "$PRUNE"`
and needs no execute bit, so a working prune script was silently skipped.
Now -f.
- src == dst aborted on a raw `cp: ... are the same file`. Checked explicitly
up front with a clear message. (It did fail safe -- a review pass claiming
the source was destroyed was tested and is not reproducible.)
- The heading regex rejected "# 2026-09-21 " with a trailing space and
prepended a duplicate heading. Now tolerates surrounding whitespace.
- `${tmp}.new` escaped the EXIT trap and leaked on interrupt.
- Dead `mkdir -p "$NOTES"` after the source check had already proven the
directory exists, and a `mode=` variable that was assigned and never used.
Verified: unknown flag/bad date/missing source exit 1, existing target exits
2, --help exits 0, mode 600 survives the rollover, a non-executable prune
script is used, a failing one leaves the target untouched, the trailing-space
heading is retitled rather than duplicated, and no temp files are left behind.
Adds muse-glimmer-30b and muse-glimmer-30b-low to the local and personal
profiles, and points the five named agents at the -low alias.
-low, not the bare alias, because that is the configuration llmbench
actually measured: 15/24 tests_pass on the real suite at reasoning_strength
low, the best any local model has scored there. The bare alias runs at
Glimmer's default, which is its HIGHEST reasoning setting and cannot be
turned off -- measured 906 completion tokens for a one-line riddle against
360 at low. Both are declared, so switching is a model change, not an edit.
Context is 131072, from llama-swap's own `-c`.
These agents already exist in work.json/work-strict.json on gpt-5.6-terra
and are untouched there; local and personal had no agent block at all, so
this is additive.
Two things, one of them overdue.
1. ~/.config/opencode/opencode.json is now dotbot-linked on mainframe and
sctfw004. It has lived in the repo since August while being linked
NOWHERE -- each machine had a hand-placed copy and nothing checked they
agreed, or that the Zen guard was there at all. That file is what stops a
bare `opencode` falling back to built-in providers including OpenCode Zen,
which is under a ToS block. An unverified guard is not a guard.
2. It carries `agent.plan.disable: true` and `permission.plan_enter: deny`.
opencode's native Plan Mode was activating unbidden and taking sessions
read-only with "Plan mode is active ... This supersedes any other
instructions you have received."
⚠ (2) IS A PREFERENCE, NOT A FIX. Remove those two keys to restore Plan Mode.
⚠ This is NOT OMO, despite looking like it. OMO's agents carry only
description/model/tools -- there is no keyword or prompt-text trigger
anywhere in ~/.omo/omo.jsonc. `plan` is native to opencode. Verified:
`agent list` shows plan gone under the local profile, and plan_enter
resolving to deny on `build` under personal, where the OMO plugin re-adds
plan as a subagent.
⚠ Turning OMO off would NOT have fixed this and WOULD have broken the
default model. personal.json defaults to litellm/agent/atlas-glm-5.3, and
that alias does not exist in LiteLLM -- measured, it returns 400, while
local/librarian returns 200. The agent/* names live only in the opencode
config and the OMO plugin rewrites them at request time. Disabling OMO needs
`model`/`small_model` repointed in the same change. `"plugin": []` in the
base does not disable it either; the profile wins on that key.
Applies to mainframe and sctfw004 only -- system76 and nas link no opencode
config at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9UH1zGZZtA9yhohmQrcT6
Checked every hosted entry against published documentation 2026-09-21. There
were errors in BOTH directions, and the overstatements are the dangerous
half -- an understated limit wastes tokens, an overstated one is a hard API
rejection partway through a session.
anthropic/claude-haiku-4-5 1,000,000 -> 200,000 OVERSTATED 5x
openai/gpt-5.6-sol 200,000 -> 1,050,000
openai/gpt-5.5 200,000 -> 1,050,000
openai/gpt-5.4 200,000 -> 1,050,000
openai/gpt-5.4-mini 200,000 -> 1,050,000
chatgpt/* (4 aliases) 272,000 -> 1,050,000
agent/*-gpt-5.x (11 aliases) 272,000 -> 1,050,000
agent/*-glm-5.3 (4 aliases) 150,000 -> 200,000 deliberate cap
anthropic/claude-{opus,sonnet}-5 unchanged, already correct
Haiku is the one that mattered. Opus 5 and Sonnet 5 really are 1M, the whole
Anthropic block was set to 1M, and Haiku inherited a limit five times its
real 200k. Opencode would have grown a session past the ceiling and the API
would have refused it.
⚠ glm-5.3 is capped at 200,000 BY CHOICE. Its real maximum is 1M. GLM bills
per input token and cost on this stack tracks context size and cache-read
volume more than model choice, so the cap is budget control, not ignorance.
Do not "correct" it upward against the docs. This is the one place where a
declared limit below the vendor maximum is intentional.
⚠ chatgpt/* now carries the API's 1,050,000, but OpenAI does not publish a
context limit for ChatGPT-plan routing and it has historically been far
below the API's. This is deliberately optimistic and being watched for a
crash; if long sessions start failing, that is the first thing to lower.
Left alone for want of evidence, not because they are right:
openai/gpt-5.3-codex (200,000), agent/librarian-glm-5.3-flash (128,000),
agent/sisyphus-junior-glm-5.3-flash (150,000). Raising an unconfirmed limit
risks the Haiku failure mode, so these stay until measured.
Unrelated but found while checking: LiteLLM declares kimi/kimi-k2.7-code at
272,000 against a published 262,144. Overstated by ~10k.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9UH1zGZZtA9yhohmQrcT6
Every local model was declared at 32768 (16384 for the OMO local aliases)
regardless of what llama-swap is configured to serve. gpt-oss-20b runs with
`-c 65536`, so opencode was compacting at half the real window -- and
local/librarian and local/explore, which both route to gpt-oss-20b, at a
quarter of it.
Compaction is not free and it is not neutral. Measured on mainframe
2026-09-21, one agent session against a model told it had 32k when it had
131k: four cache-cold prefills, and 780 seconds spent after a working fix
re-reading the same forty lines, because each compaction discarded the memory
of having already verified it. Reasoning spend per step went from 2-5 tokens
to 1739. The model was not confused; it had been made to forget.
Values are llama-swap's own `-c`:
gpt-oss-20b 65536 (was 32768)
local/librarian 65536 (was 16384 / 32768)
local/explore 65536 (was 16384 / 32768)
qwen3-coder-30b 32768 unchanged, already correct
gemma3-12b 32768 unchanged, already correct
The hosted entries were already right and are untouched.
⚠ These are hand-maintained and llama-swap's config is the source of truth.
If a `-c` changes there, change it here. llmbench now derives the same
numbers by parsing that file, and raises rather than guessing when an alias
is unknown -- a wrong context limit produces plausible numbers instead of an
error, which is how this went unnoticed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9UH1zGZZtA9yhohmQrcT6
minuet links MinuetVirtualText -> Comment, which in vague is #606079 -- the
same colour as real comments, so a suggestion reads as if it were already part
of the file. Dropped to #4a4a5c, between Comment and Whitespace (#333738).
⚠ Re-applied on ColorScheme. Loading a colourscheme runs ,
which drops custom groups, so setting this once at startup works until the
first :colorscheme and then silently stops. The reference values for the
palette are in the comment so the number can be tuned without going and
looking them up again.
vim.pack.add installs on first launch and that needs to reach github, which is
not a given on a work-issued machine behind a proxy. A hard require there takes
down everything below it -- MinuetStatus, the colorscheme, the lot -- and
reports an error pointing at minuet rather than at the network.
MinuetStatus is now registered unconditionally and reports whether the plugin
loaded at all. A status command that only exists once everything already works
is not a status command.
⚠ 1. The whole require() was guarded behind "is the key present", so with no
key the module never loaded, `:Minuet` was never registered, and nvim gave NO
indication why. That is exactly the failure it hit in practice. `:Minuet` is
created at module load, not inside setup(), and the plugin already notifies
clearly when the key is missing -- let it do that job. setup() now always
runs, and `:MinuetStatus` reports endpoint, model and whether a key resolved.
⚠ 2. The FIM template took `(ctx)` and read `ctx.lines_before`. minuet calls
it as `template.prompt(before, after, opts)` -- three positional args -- so the
first real completion died with "attempt to concatenate field 'lines_after'
(a nil value)". The config loaded perfectly cleanly the whole time. A config
that loads is not a config that works, and only a functional test found it.
The key now resolves lazily and is cached: env var first, then rbw with the
host-appropriate entry (litellm-key-work on sctfw004, where that key IS the
policy boundary). `false` is cached for "looked and found nothing" so a locked
rbw costs one probe per session, not one per keystroke.
Verified against the live path: provider reports available, the key resolves,
and /v1/completions returns correct FIM infills both streaming and not.
copilot has been commented out on every machine with "deliberately off". This
is what replaces it: minuet-ai in virtual-text mode against qwen3-coder-30b on
mainframe's 3060. Nothing leaves the LAN -- llm.rcjohnstone.com has no public
A record, uses the internal CA, and Caddy source-IP gates it.
⚠ Route is /v1/completions, NOT /infill. Measured through the proxy:
/v1/completions returns 200 with FIM tokens in a raw prompt, /infill is 404
because LiteLLM does not expose it. llama.vim wants /infill, which would mean
talking to llama-server directly and bypassing the virtual key -- and on the
work laptop that key IS the policy boundary making Anthropic and PRC models
403. Not worth routing around for an editor plugin.
⚠ max_tokens is 32 on purpose. Measured on the real path at 48: `def add` came
back with `sub` and `mul` attached, so most of the latency was spent generating
text that would be discarded. Stop tokens include a blank line for the same
reason.
⚠ Expect occasional multi-second pauses: llama-swap is -np 1 with a ttl, so a
completion after another model was used pays a full reload. ~10 s cold,
0.7-1.3 s warm, measured end to end through the work key. Accepted because
concurrent agentic work is not viable on this hardware anyway.
Virtual text rather than a cmp source because there is no completion engine
installed here, and ghost text is the copilot-shaped one. Silent no-op when
LITELLM_KEY_NVIM is unset, so the headless NAS is unaffected.
Measured 2026-09-19. After a reboot, eleven containers reported `Up 9 hours`
with a State.Pid that no longer existed -- ntfy, bitwarden, authelia, gitea,
grafana, prometheus among them. ntfy's healthcheck had failed 539 consecutive
times while podman ps called it up, and it stayed that way overnight because
every ordinary check said yes.
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.
Fix applied as ~/config/host/podman-restart-killmode.sh (KillMode=process),
which also carries a --check mode for finding zombies.
⚠ Also records that `kill -0 <pid>` is the wrong liveness test here: the
containers run as root, so as a normal user it fails with EPERM rather than
ESRCH and reports every container dead. That misdiagnosis was made during
this very investigation and read as a total outage for a minute.
Two independent reasons the nightly backup was not producing a snapshot.
zstd refuses to overwrite its -o target. A dump that fails midway therefore
wedges the job permanently rather than just losing one night: pg_dumpall dies
(the immich db is not up yet right after a boot), zstd still writes its empty
frame, pipefail aborts before the mv, and the orphaned .tmp then makes every
later run fail instantly on "already exists; not overwritten". Three nights
were lost that way from 2026-09-15. The sqlite branch already rm -f'd its tmp;
these two did not. -f closes it.
sftpgo moved its state onto the /var/lib/sftpgo bind mount, so the old
/home/connor/docs/sftpgo.db only tripped the "db missing, skipping" WARN --
sftpgo went unbacked-up from 2026-09-09 while the run still reported success.
Left as a WARN deliberately: a decommissioned service should not break the
nightly run. Read the WARNs.
42c4277 split the ssh config into common plus a host layer and restored
system76's aliases verbatim, but stubbed nas and mainframe out as having no
machine-specific aliases. Those are the two machines that run restic, and
RESTIC_REPOSITORY is sftp:storagebox:/home/restic -- so the NAS backup failed
every night from 2026-09-15 with "Could not resolve hostname storagebox",
three nights with no offsite copy.
mainframe was unaffected only by accident: its job runs as root and reads
/root/.ssh/config, which dotbot does not manage. Worth remembering before
assuming an ssh change here reaches every backup.
sub2, not sub1 -- the hosts share one Storage Box account but are chrooted
into separate sub-account homes.
RECS was the absolute path /home/connor/.local/bin/movie_recs. That worked
while ~/.local/bin was a dotbot link into hosts/<host>/bin, but base.conf.yaml
now *creates* it as a real directory owned by the package managers, so the
movie_recs link went away with it. Every timer run since died on
FileNotFoundError before reaching LiteLLM, which is why the ntfy push went
quiet rather than reporting a failure.
The unit already invokes this script as %h/.dotfiles/hosts/mainframe/bin/...,
so deriving the sibling path from __file__ cannot drift from however the unit
is spelled, and does not care what ~/.local/bin becomes next.
The file covered the inference stack in detail and said almost nothing about
the repo it now lives in, so every session rediscovered the same handful of
conventions.
Adds the four hosts with their addresses, roles and package managers --
including that sctfw004 is US-government adjacent and stays on OpenAI and
local models only -- and the five things here that actually cost an hour: the
allowlist .gitignore, and why a new directory being invisible to git is
expected rather than broken; secrets living only in an untracked
~/.secrets.zsh and never in a tracked zshenv; ConditionHost as the guard that
keeps a one-machine timer from firing on all four; and dots / pkg-snapshot as
the maintenance pair, with the deliberate silence of dots --nag explained.
Also notes that ~/.claude/CLAUDE.md is a symlink into this repo, since
editing it silently edits tracked config, and sharpens the .pre-<thing> note:
that convention is for the untracked ~/config and ~/docs trees, where it is
the only backup those files get. Inside a tracked repo git already is the
backup and .gitignore excludes the pattern anyway.
eb6a42c set HEADLESS-1 from SUNSHINE_CLIENT_WIDTH/HEIGHT/FPS but never
actually ran: Sunshine execs a prep-cmd directly, with no shell, so ${...}
was passed through literally. swaymsg received "mode ${SUNSHINE_CLIENT_WIDTH}
x..." as its argument, exited 2, and Sunshine reports any failed prep-cmd to
the client as the one opaque "failed to start the specified application".
The variable names were right and Sunshine does export them; the shell was
the missing part, which is why upstream's own example wraps the command.
Verified live at 2560x1440@60 and 1920x1080@120.
Worth keeping for the next time this breaks: swaymsg exits 2 when the socket
is reachable and the command is rejected, and 1 when it cannot find the
socket at all. That distinction is what ruled out a SWAYSOCK problem and
confirmed the malformed mode string without guessing.
The work laptop drives two 3840x2160 panels, so a fixed 1920x1080 capture
was being upscaled 2x on the client — fine for Big Picture's large UI,
visibly soft for terminal text on the Desktop app.
A headless wlroots output has no physical size, so it can take any mode.
Both apps now set HEADLESS-1 to SUNSHINE_CLIENT_WIDTH/HEIGHT/FPS in their
prep-cmd and restore 1920x1080 on undo. SWAYSOCK joins the environment
import so Sunshine's prep-cmds can reach the compositor at all.
Audited every non-shell context in the repo — hypr, waybar, systemd,
desktop entries — for repo scripts named without a path. Two real hits
beyond the scratchpad bind:
hyprctl_modify_wkspc1 runs from exec-once at every Hyprland startup, so it
has been failing silently on both laptops since ~/.local/bin stopped being
a symlink into the repo. Nothing surfaces an exec-once that cannot find its
binary.
system76's playlistBuilder bind had the same bare hypr_workspace. Its inner
command already used `zsh -lc`, so `play` resolved; the outer call did not.
Scratchpad terminal. The original config called hypr_workspace by absolute
path; splitting the config into common and host layers I rewrote those to
bare names, assuming PATH would resolve them. It does not: Hyprland's exec
runs with whatever PATH the session inherited at startup, which here is the
system default — no ~/.local/bin, no repo directories. That was survivable
while ~/.local/bin was a symlink into the repo, but the cutover made it a
real directory and removed the 29 duplicated scripts, so the binding broke.
Repo scripts are now named via $bin, defined per host in local-env.conf
because the home directory differs.
Same commit fixes toggleFloating.sh, which the shared config referenced as
/home/connor/.config/... — correct on two machines, wrong on the work
laptop. Now ~.
rbw. The old install.conf.yaml linked ~/.config/rbw on every host; I put it
in the mainframe and system76 host configs and forgot the work laptop, so
rbw there had no config at all. Moved to base, where it was always meant to
be.
The stream rendered but ignored keyboard and mouse. Sunshine injects input
through uinput exclusively — it has no support for the Wayland virtual
keyboard/pointer protocols — so the compositor has to have a libinput
backend to see those devices. WLR_BACKENDS=headless creates only the
headless backend, so sway had zero input devices against the kernel's 24.
libinput needs a seat, and a systemd --user service belongs to no logind
session, so libseat needs seatd. seatd is now enabled on this host.
Its socket defaults to group `seat`, which does not help here: the user
manager's group list is fixed when it starts, Linger=yes means that is at
boot, and so adding connor to `seat` would not take effect until a reboot —
re-logging in changes nothing. The drop-in points seatd at `video`, a group
connor already holds for GPU access, which makes this deterministic instead
of dependent on group-propagation timing.
sway now reports 7 input devices including libvirtualhid Mouse and
Keyboard, which are Sunshine's.
hosts/mainframe/etc/ is a new tree for system-level config; install flags
it the same way it flags sbin.
The previous fix added DISPLAY to the import but left it as a separate
exec line. sway runs exec lines asynchronously and does not order them, so
Sunshine won the race every time — it started in the same second as the
compositor, before DISPLAY existed in the manager environment, and passed
no DISPLAY to the apps it launched. Steam still failed with "Unable to
open a connection to X" while Xwayland was up and the manager environment
looked correct, which is what made it confusing.
One exec with && instead of two.
The earlier verification was wrong for the same reason: I launched Steam
by hand with DISPLAY=:0 set, which tested Xwayland rather than the path
Sunshine actually uses. Re-verified properly by launching Steam in a shell
carrying only the environment read out of Sunshine's own /proc entry.
Two reasons both Sunshine apps streamed a blank screen.
Steam is an X client, and sway starts Xwayland lazily. DISPLAY therefore
did not exist at the moment the config imported the environment for
Sunshine, so Steam came up with "Unable to open a connection to X" and
rendered nothing. `xwayland force` starts it at compositor launch instead,
and DISPLAY joins the import.
The Desktop app was not broken at all — the desktop was genuinely empty.
swaybg was painting #101920 on an output with no windows and no bar, which
over a stream is indistinguishable from a failed capture. There are now
keybinds for a terminal and a launcher, and kitty starts with the session
so a working capture looks obviously working.
Verified: Xwayland on :0, DISPLAY exported, xdpyinfo and zenity connect,
and Steam Big Picture Mode renders as a window on HEADLESS-1.
Reaching the web UI from another machine sends an Origin header naming
this host rather than localhost, and Sunshine's CSRF check rejects it:
Web UI: [10.0.0.8] -- CSRF protection blocked request from origin:
https://10.0.0.2:47990
csrf_allowed_origins takes a comma-separated list of bare origins, NOT a
JSON array — brackets and quotes are rejected entry by entry, which is how
the first attempt failed. Both the IP and hostname spellings are listed
because the browser sends whichever was typed.
origin_web_ui_allowed=lan set explicitly alongside it, so the two settings
that gate remote access to the UI are stated in one place.
Moonlight went on the work laptop as a flatpak, which neither apt-mark nor
pacman can see — so a rebuild from the manifests would have silently left
it out. pkg-restore adds the flathub user remote before installing, since
Ubuntu ships flathub as a system remote that --user cannot resolve.
System-scope flatpaks are deliberately not recorded: those belong to
whoever administers the machine, not to these dotfiles.
Measured rather than assumed: the compositor and Sunshine together hold
220 MiB on the 3060 — sway ~112, Sunshine ~108. That card also runs the
local LLM, and with an 11.3 GB model resident on a 12 GB card the free
headroom goes from 992 MiB to 772. Losing a fifth of what is left matters
for KV cache growth, so leaving it running was the wrong default.
sway-headless and sunshine no longer start at boot. `stream on` brings them
up, `stream status` reports state and VRAM, and sunshine-idle-stop.timer
checks every five minutes whether any Moonlight client still holds a
connection to 47984/47989/48010 — stopping the stack after 15 idle minutes
so a forgotten session does not sit on memory the inference stack wants.
Note for future edits: `systemctl --user disable` on a unit dotbot has
symlinked into ~/.config/systemd/user deletes the symlink itself, because
systemd treats it as a linked unit. Re-run ./install after disabling one.
mainframe has no desktop and the only connected output hangs off the Intel
iGPU, while the RTX 3060 that encodes drives no monitor. Capturing the real
output would copy every frame between GPUs. So: a minimal sway session on
the headless wlroots backend, pinned to card0, with one 1920x1080 virtual
output for Sunshine to capture. Verified end to end — Sunshine finds
zwlr_screencopy_manager_v1 and creates h264_nvenc and hevc_nvenc.
(av1_nvenc fails because Ampere has no AV1 encoder; that is expected.)
Two things had to be fixed to get there. The installed sunshine-git was
built against boost 1.89 against a system now on 1.92 and could not load
five libraries — which is why the service had been enabled but never once
run. Replaced with sunshine-bin, which is current and prebuilt.
And Sunshine now gets a unit of ours rather than a drop-in: the source AUR
package ships sunshine.service while -bin ships
app-dev.lizardbyte.app.Sunshine.service, so a drop-in silently stops
applying the moment you switch between them.
apps.json replaces the stock file, whose "Low Res Desktop" entry shelled
out to xrandr and would have done nothing on Wayland.
Connor's call: the work laptop's commented-out copilot is the state he
wants on all four machines, so the host conditional added in 8ddbbe2 is
unnecessary machinery. Back to a plain plugin list.
zshenv is read by every zsh, so a nested shell prepended the repo's bin
directories a second time. The NAS showed each of them twice after one
`zsh -l -i`. typeset -U keeps the first occurrence, which is also the
ordering this file intends.
The NAS is a minimal Arch install with no inetutils, so hostname(1) does
not exist and install died on its first line of real work. uname -n is
POSIX and always present. The same assumption was in dots, pkg-snapshot,
pkg-restore and zshenv's fallback.
Taking the work laptop's init.lua as canonical carried its commented-out
copilot line to every machine. Copilot was enabled on mainframe, system76
and the NAS before the refit — only the work laptop had it off. Sharing the
file silently disabled it on the other three.
The plugin list is now built in a table and copilot appended unless the
host is sctfw004. Keyed on vim.fn.hostname() rather than $DOTFILES_HOST,
because nvim launched from a desktop entry does not inherit the shell's
environment.
Promoting the work laptop's copy to common/ carried its hardcoded fdfind,
which is the Debian/Ubuntu binary name. On Arch the binary is fd, so the
script would have failed on system76 — quietly, since it runs from a timer.
The work laptop's first install left five files dirty, all of them wallust
targets. Tracking generated colour files means each machine rewrites the
others' palette on every theme change — exactly the churn a shared repo
should not have.
hypr, waybar and swaync now get their colours as real files written into
real directories, so nothing is linked and nothing is tracked. kitty and
qutebrowser land inside directories this repo links wholesale, so those two
are ignored by path instead. swaync switched from a directory link to two
file links to make room for its generated colors.css.
Regeneration is automatic: hypr_refresh runs every 15 seconds on both
laptops, so a fresh machine has colours within a quarter of a minute.
Both laptops run hypr_refresh and their unit files had diverged. The work
laptop's is the refined one — Type=oneshot, which is correct for a script
that exits, a 15s interval with AccuracySec, and a comment explaining the
wallpaper-daemon guard. system76 had Type=simple with Restart=on-failure,
which respawns a oneshot script continuously. Shared version wins.
Three more units existed only on the work laptop and were untracked:
backlight-probe, kanshi and mopidy.
Two passes, both aimed at a consistent experience rather than a faithful
snapshot of each machine's accidents.
Standardisation. Audited every host-layer config present on more than one
machine and asked whether the difference was intent or drift:
- mpv was byte-identical; swayimg differed only because system76 still
had the shipped default and the work laptop had the real config; bob
differed by one setting the work laptop was simply missing.
- user-dirs.dirs disagreed with BOTH machines and with zshenv, which has
said $HOME/downloads all along. Standardised on that.
- mimeapps.list is now the union of both machines' associations.
- swaync's config and style are shared; only wallust's generated
colors.css stays per host, alongside hypr's and waybar's.
All of the above moved to common/.
Dropped as pure state rather than config: gtk-2.0 (file-chooser geometry),
pavucontrol.ini (window size), zoom.conf (a per-install random_key) and
zoomus.conf (panel widths). gtk-3.0 stays per host — its bookmarks encode
the two different home directories.
App config. GIMP, inkscape, FreeCAD, cura, OrcaSlicer, LightBurn and OBS
all hold real settings, so a curated subset of each is now tracked:
settings, keybindings and user content, but not session state, tool
options, device lists or window geometry. kicad is skipped — both library
tables hold a single entry, i.e. untouched defaults.
Content lives once in common/ even when only one machine has the app; the
host configs decide who gets the link. That is the distinction that lets
53% of link sources be shared while the NAS still does not get GIMP.
Two exclusions on credential grounds: obs-studio/plugin_config holds a
websocket server_password, and gh is dropped entirely because `gh auth
login` writes a token into hosts.yml — now also blocked by name.
Five directories swept in held no files — matplotlib, three gtk dirs and
one mpv. Git cannot represent an empty directory, so they existed on
mainframe where they were created but vanished on clone, and system76's
install failed with "Nonexistent target".
install now checks every link source exists before running dotbot, so this
surfaces on the machine that introduced it rather than the next one to
pull.
Reactive capture missed wallust entirely, so this is a systematic pass over
~/.config on all four machines: every entry that is not already a symlink,
judged on whether it is hand-authored config or app-generated state.
Shared and identical everywhere -> common/: lazygit, ncmpcpp, neofetch,
matplotlib. Everything else differs per machine and goes in
hosts/<host>/config/: gtk-2.0/3.0/4.0, mimeapps.list, user-dirs.dirs,
mpv, swaync, swayimg, bob, pavucontrol.ini, zoom; plus beets, bottom,
glow and mpd on system76; fastfetch, gh, uv, traggo, tiling-assistant and
xdg-desktop-portal on the work laptop; inbox-tidy and llmfit on mainframe.
Deliberately excluded, all verified to hold live credentials: the work
laptop's Claude OAuth tokens (~/.config/anthropic/credentials), the NAS
restic repo password and hetzner.env, subsonic passwords in subtui, stmp
and termsonic, mopidy, supersonic, the Proton bridge env and the LiteLLM
virtual keys. The ignore list now names those shapes directly — a bare
`password` file would not have been caught before.
Also excluded as generated state or too large to be meaningful: GIMP,
kicad, BambuStudio, OrcaSlicer, calibre, LightBurn, obs-studio, feishin,
libreoffice, VirtualBox, FortiClient; and mainframe's dead X11-era
leftovers (i3, openbox, lxpanel, lxsession, pcmanfm).
The router resolves it to 10.0.0.2 at the DNS level and the remote URL
already carries :2222, so the alias was redundant everywhere and wrong on
mainframe, where it pointed at localhost. Verified: connecting with
-F /dev/null succeeds from both mainframe and system76.
pkg-snapshot and dots used plain sort, which is locale-dependent: the same
package set ordered differently on two machines, so dots reported drift
that did not exist. A maintenance tool that always cries wolf is the exact
failure mode dots exists to avoid.
The laptops are on different Hyprland versions — system76 on 0.55.4,
the work laptop on 0.54.3 — and the option was removed in 0.55. Taking the
work laptop's config as canonical therefore introduced a config error on
system76 that did not exist before.
It is redundant either way: 0.54.3 reports the default as already enabled.
This is the first case of the shared config needing to tolerate two
compositor versions; it will not be the last.
Git reads both, and ~/.gitconfig wins. Linking only the XDG path left the
stale ~/.gitconfig in charge, so system76 kept signing commits as a
previous employer even after the cutover appeared to succeed — the symlink
was correct and had no effect.
Putting the whole ssh config in common/ was wrong twice over. It mapped
git.rcjohnstone.com to localhost:2222, which is only true on mainframe
where Gitea runs -- on system76 it broke git entirely. And it replaced a
77-line file holding 13 personal host aliases (storagebox, several
forwarded localhost ports, older work hosts) with a five-entry fleet list.
common/ssh/config now Includes ~/.ssh/config.local first, so host entries
win on the first-value-obtained rule, and carries only the fleet aliases.
Each host keeps its own list; system76's is restored verbatim.
The laptops are themed by wallust, whose config and five templates lived
only in ~/.config/wallust on each machine. A fresh install would have had
no colours at all: hyprland.conf sources colors.conf on its first line and
waybar's style.css imports colors.css on its first line, and nothing in
the repo generated either. Both generated files are now linked per host so
bootstrap works, and wallust writes back through the symlinks.
update_colors.sh was its predecessor and is now actively harmful: it
writes sway/config and wofi/style.css, neither of which exists on any
machine any more, and it regenerates waybar/style.css from a template that
was never carried across -- so running it would have overwritten the
shared Hyprland waybar style with sway-era output.
common/colors (255 theme files, 1.1 MB) was only ever its input.
The earlier survey grepped for the exact package name and concluded the
binding in hyprland.conf was dead. It is not: system76 has it from AUR as
networkmanager-dmenu-git, and the work laptop carries the upstream script
in its own bin. The $mainMod+W binding already works on both.
Adding the repo package to system76.pacman would have made pkg-restore
install it alongside the AUR one — both ship /usr/bin/networkmanager_dmenu,
so pacman would have refused with a file conflict.
dotbot does not carry a `defaults` block between config files, so the
host configs ran without force/relink and refused to replace paths that
already existed as real files — the opencode profiles, the Claude files,
the sunshine config and the old systemd units all failed to link. Each
host config now restates the block.
ddns.timer used OnCalendar=*-*-*/2, which systemd rejects: a day-of-month
step needs an explicit start value. The retired cron line was
`0 2 */2 * *`, so the correct spelling is *-*-1/2.
Replaces the previous repo, which had split into two histories that never
met (mainframe on a dead GitLab remote, the laptops on Gitea) with 146
dirty files across three machines and the NAS never enrolled at all.
Branch-per-machine is gone. One main, with host differences expressed as
small files under hosts/<hostname>/ rather than as branches, so there is
nothing to merge. The reconciled zsh layer reduces 15-33 line forks to
1-7 effective lines per host; distro differences (oh-my-zsh prefix,
syntax-highlighting path, fd vs fdfind) are probed in common/ instead.
Fresh history: the old one carried six plaintext credentials, 45 MB of
mail caches, browser caches and vendored binaries. 5,096 tracked files
and 144 MB become 462 files and 2.6 MB. The .gitignore is now an
allowlist, which is what keeps that true.
Root cause of the rot: ~/.local/bin was a symlink to scripts/ with GOPATH
inside it, so every go install wrote into version control (2.2 GB on the
work laptop). PATH now points at the repo instead of the reverse.
Also: Hyprland replaces sway and is sourced in two halves so $browser is
defined before use; singleton automations carry ConditionHost= alongside
host-layer-only placement; ddns moves from cron to a guarded timer;
package manifests and pkg-snapshot/pkg-restore replace the X11-era
install_scripts/; networkmanager-dmenu added to system76 (the binding
always existed, the package never did).