all repos — dot @ 6c7059865a615cf27226fa09562e6f1deb9beccb

Shell, editor and terminal configuration, and the tools that go with them

6c705986
pi alarm: keep pending alarms across /reload

/reload dropped every pending alarm and stopped its when_done command,
without a word. An agent that had ended its turn to wait for one was never
woken -- nor a pi-goal goal_wait counting on it.

The old copy of the extension now leaves what is pending on globalThis
instead: each alarm as plain data, the due queue, the next id, and a
when_done command's ChildProcess with its own listeners removed. The copy
pi loads next arms fresh timers from each alarm's absolute time, listens
on the command again (one that exited meanwhile still holds its status),
and resumes polling PIDs. Nothing crosses that would reach the old,
stale pi or ctx.

A handoff from another session or another HANDOFF_VERSION is refused and
its commands stopped, with a notice. If nothing takes it within 30s --
the file failed to load, or was removed -- the old copy stops its
commands. Quitting, /new, /resume and /fork still drop everything.
Igor Soarez igor@soarez.org
Fri, 30 Oct 2026 18:36:08 +0200
b3448031
pi: generate mcp.json from ~/.config/pi/mcp.d fragments

pi reads user-level MCP servers from one file, and a private layer
linked that file to the same copy on every host -- so every host got a
server only one machine could run. Build it the way models.json is
built, from fragments another layer or one machine drops in mcp.d; this
repo ships none, and with none the file is left absent.

pi-models.py becomes pi-merge.py, which takes the key to merge and an
optional base file, and serves both.
Igor Soarez igor@soarez.org
Thu, 29 Oct 2026 21:40:43 +0000
b5100c2e
sz-final-review-loop: wait with a wake armed, not blocked in sz-wait-for

The loop's agents blocked in a foreground sz-wait-for because nothing could
bring an agent back from a background one. Both harnesses now can: pi's
alarm fires when a when_done command exits, and Claude Code starts a new
turn when a run_in_background Bash task exits (checked on 2.1.310). So an
agent arms sz-wait-for as a wake and ends its turn: no context resent every
30 minutes to re-arm, the human can talk to it while it waits, and "arm,
then signal" becomes literal. The foreground wait stays for a harness with
neither.

The invariant is unchanged in substance: never idle, where idle now means
the other side's turn with no wake armed. sz-wait-for stays the watcher, for
its settle window.

pi-subagent: §5 says how to be woken in Claude Code too.
Igor Soarez igor@soarez.org
Tue, 27 Oct 2026 11:36:43 +0200
11adcd3f
pi alarm: fire when a command exits or when processes exit

when_done runs a command (bash -c, in pi's cwd, output to a log file) and
fires when it exits, with its exit status; cancelling the alarm or ending
the session stops it. when_exits polls PIDs already running and fires once
all are gone. Either takes an optional deadline, 24 hours by default, at
which the alarm fires anyway and a still-running command is stopped.

A process exiting is a signal any condition can be turned into -- gh run
watch, sz-wait-for, an until-curl loop -- so the agent can start something
long, end its turn and be woken, instead of blocking on it. The delivery
path is the existing one, unchanged.

pi-subagent: §5 sets an alarm on the runs' PIDs instead of waiting.
Igor Soarez igor@soarez.org
Tue, 27 Oct 2026 11:12:21 +0200
1613e5d0
tmux: advertise OSC 8 hyperlinks so pi and other TUIs link under tmux
Igor Soarez igor@soarez.org
Tue, 27 Oct 2026 11:07:27 +0200
65d3da05
tmux: Shift+Enter reaches pi, Neovim and Claude Code under Ghostty

With extended-keys off, tmux hands every pane a plain CR for Shift+Enter and
Ctrl+Enter, so pi submits where it should add a line, and warns about it at
every start.

Turning it on is not enough on its own, though pi's warning then goes quiet:
tmux asks the outer terminal for modified keys only when it knows the
terminal can send them, and tmux does not recognise Ghostty, whose terminfo
does not declare it either. So Ghostty also gets the extkeys feature, and
the format is csi-u, the one pi asks for. `on`, not `always`, which would
send zsh ESC[13;2u for Shift+Enter.

The paste corruption the old comment blamed on extended keys shows up only
in a display-popup, where tmux re-encodes the newlines of a paste for an app
that asked for modified keys; normal panes get the pasted bytes unchanged.
Nothing here opens popups.

Checked on tmux 3.7c against a pty answering as Ghostty 1.3.1, with pi
itself in the pane, and by hand in Ghostty on a separate tmux server. The
csi-u line is set with -q, since extended-keys-format arrived in tmux 3.5;
3.4 was not tried, only an unknown option name standing in for it.
Igor Soarez igor@soarez.org
Mon, 26 Oct 2026 16:20:45 +0200
4f40b8ea
yubikey-git: opt a host in to git over ssh at git.soarez.net with the YubiKey git key; ssh entry includes enabled.d/
Igor Soarez igor@soarez.org
Sun, 25 Oct 2026 17:56:18 +0200
9064d88b
pi: track pirc-extension default branch instead of pinning v0.1.1
Igor Soarez igor@soarez.org
Sun, 25 Oct 2026 05:14:39 +0000
3ee27f8b
claude: never reach the operator's tmux server by accident
Igor Soarez igor@soarez.org
Sat, 24 Oct 2026 08:18:30 +0100
3a519e62
pi alarm: steer a due alarm into a running task

An alarm that fell due mid-task used to wait in the extension until the
run settled. Now it is steered in: pi adds it after the tool calls in
progress finish, before the next model call, and the model weighs it
against the task. The alarm text tells it to say which it chose.

pi's Esc empties its queue and drops queued extension messages without
a word, so an alarm stays listed until message_end shows it in the
conversation. One not seen by the end of a stopped run is held and sent
again after the next run completes; resending into that run could
deliver it twice, since pi takes one queued steer per turn and other
aborts leave the original queued.
Igor Soarez igor@soarez.org
Fri, 23 Oct 2026 10:34:07 +0100
13d7482a
pi: alarm extension, timed notes the agent sets for itself

An `alarm` tool sets, lists and cancels alarms: after a delay, at a
wall-clock time, or repeating (at most 12 fires, the last within 24 hours).
A fired alarm is a pi.sendMessage custom message, customType "alarm",
rendered as set by the agent rather than typed by the user, and wrapped in
fixed text the agent cannot drop: it is the agent's own earlier note, not
the user's, and grants no permission -- outward actions still need the
user's approval. /alarms lists and cancels.

An alarm due while pi is busy waits for the task to finish, held here
rather than in pi's queue, which drops a custom message on Esc. A run that
ended stopped -- Esc, an error, or the abort before /compact, /new, /resume
and /fork -- holds due alarms until a later run completes. Timers die with
the session; the tool is withdrawn outside the TUI.

Reviewed by Fable and GLM 5.3 Flash. Fixed from the review: unbounded
every_minutes overflowed setTimeout into 1 ms fires; a due alarm ran right
after Esc, or unseen on a session being replaced; stale repeat fires piled
up; the session_compact delivery never ran; impossible dates and times
skipped by a clock change were accepted; the note could forge the
wrapper's delimiters.

Declines dot request 20261021-220352-work-laptop-c46033 (sz-agent-alarm, a
skill typing into the agent's tmux pane) in favour of this: a custom message
is recorded and shown as the agent's, needs no screen-scraping, and loads
only in pi.
Igor Soarez igor@soarez.org
Thu, 22 Oct 2026 15:20:59 +0100
599b0bf8
pi-review-panel: GLM 5.3 Flash and DeepSeek V4.1 Flash, from OpenRouter

They replace GLM 5.2 and DeepSeek V4 Flash on both providers: fireworks
returns 404 for glm-5p2, and neither fireworks model is in the work laptop's
catalog. The default panel is six runs.
Igor Soarez igor@soarez.org
Wed, 21 Oct 2026 12:39:44 +0100
9faef452
pi-subagent: opt-in sessions, to follow up, resume after a timeout, or fork

run-agent.sh still runs with --no-session by default. --session saves the
run as session <name> under <workdir>/sessions/; --followup TEXT sends the
next message in it, from any provider and model; --fork FROM starts <name>
as a copy of FROM. A follow-up or fork inherits --tools, --thinking, --cwd
and --context-files, so a read-only subagent stays read-only, and the
earlier round's files move to <name>.round<N>.*.

Sessions are opened by file path: by id, pi looks only among sessions
started in the current directory and otherwise silently creates an empty
one, exit 0. A fresh retry moves the old session aside, since pi would
resume it given the same id.

pi-review-panel launches members with --session, so a contradiction can be
put to both reviewers before it is settled; verdicts are still tallied from
the original reviews.

From dot request 20261021-123436-work-laptop-d0adf2.
Igor Soarez igor@soarez.org
Wed, 21 Oct 2026 12:39:32 +0100
32123f16
skills: pi-subagent, extracted from pi-review-panel, which now runs on it

The panel's mechanics -- headless pi runs that don't hang, answer
extraction, billed cost, retries, verifying what comes back -- are not
review-specific. pi-subagent covers one delegated task, one brief fanned out
to several models, or a job split into parallel parts; pi-review-panel keeps
only what a review adds.

run-agent.sh replaces run-member.sh and fixes what it got wrong:

- `timeout` is not on a Mac without coreutils, which failed every member
  with exit 127; it falls back to gtimeout, then perl's alarm (142 -> 124).
- pi exits 0 when the provider refuses the request, or the answer is empty;
  both are now exit 1, with stopReason and error in the cost file.
- a retry moves the earlier attempt to <name>.attempt<N>.* instead of
  overwriting its cost file.

New options: --tools (an allowlist; read,grep,find,ls is read-only by
construction), --cwd, --context-files, --brief, --deliver. status.sh
replaces the `pgrep -f run-member.sh` loop, which counted every panel on the
machine, and prints the cost table.

From dot request 20261021-114826-work-laptop-2b868c.
Igor Soarez igor@soarez.org
Wed, 21 Oct 2026 12:23:28 +0100
485aec09
nvim: FantasqueSansM Nerd Font Mono in Neovide

Keeps the h16 size the Agave line had.

From dot request 20261019-093806-work-laptop-3f5dd6, font part only: the
cmd+c/cmd+v mappings it also asked for are already set further down the
same block and would override them.
Igor Soarez igor@soarez.org
Mon, 19 Oct 2026 09:44:04 +0100
0a0471fe
ghostty: stable update channel, download updates in the background

The tip channel ships a build for every commit to main, so ghostty offered
an update every day. Stable prompts only for tagged releases, and
auto-update = download fetches it before asking.

A Mac already on a tip build is ahead of stable and Sparkle will not
downgrade it: install the latest stable on it once by hand.

From dot request 20261019-093208-work-laptop-2881de.
Igor Soarez igor@soarez.org
Mon, 19 Oct 2026 09:44:03 +0100
db0e9cc4
alacritty-mosh: unique per-launch config paths

The per-target files in /tmp/alacritty were deleted by the session's
cleanup trap, so a second window to the same host (or a relaunch racing
a previous exit) lost its config mid-flight and Alacritty logged
"Unable to load config ... File not found" on live reload. Suffix the
paths with the launcher PID so each instance owns its own files.
Igor Soarez igor@soarez.org
Sun, 18 Oct 2026 10:25:29 +0300
95056eac
theme-gen: derive tmux and ghostty colours for any theme

`theme --set <mode> <name>` needs a tmux conf and a ghostty colour file that
alacritty-format themes do not provide, so such a name fell back to the
generic dark/light tmux colours and left ghostty unchanged, exiting 0.
bin/theme-gen reads a theme in alacritty .toml format (by alacritty-theme
checkout name or path) or Ghostty's bundled `palette = N=#hex` format, and
writes config/tmux/colors/<name>.conf and config/ghostty/colors/<name>, plus
config/alacritty/colors/<name>.toml for a ghostty-format source.

Generated with it: sonokai, solarized_osaka_night, kanagawabones and
dracula_plus_high_contrast, with nvim stand-ins for the three that have no
nvim port installed.

Parsed keys go through eval, so only plain section.key names and hex values
are accepted: a theme file could otherwise run commands.

From dot request 20261017-110255-work-laptop-48c22c.
Igor Soarez igor@soarez.org
Sat, 17 Oct 2026 11:07:32 +0100
c0771c83
dot-request: --inbox, and xz compression past 400 KB

--inbox files into another inbox than dot: the path it posts to and the
namespace it signs in both follow it, so the key signs for that inbox only.
DOT_REQUEST_URL still overrides the whole URL for testing.

A signed request over 400 KB -- the inbox's limit on the wire -- now goes
xz -9e compressed with Content-Encoding: xz, which the inbox undoes before
checking anything; a smaller one goes as it is. --compress and --no-compress
override that either way. Compressing needs xz, so it joins the Brewfile;
without it a request that must be compressed is refused, never sent plain.

Checked against a local inbox: 148 KB sent plain, 740 KB compressed to 37 KB
and accepted, --no-compress over the limit refused with 413, --compress on a
small request accepted, a bad --inbox name refused before signing.
Igor Soarez igor@soarez.org
Tue, 13 Oct 2026 14:52:43 +0100
20b4e6a4
nvim: one pyright client per project, each on its own venv

pyright resolves one pythonPath per server instance, so in a monorepo with a
venv per service, every file was checked against whichever interpreter the
single client found. The root is now the nearest pyproject.toml, falling back
to the markers nvim-lspconfig uses, and the client points at the nearest .venv
at or above it -- a uv workspace's shared venv covers members without one.

The requested version set params.settings in before_init, which the
initialize request does not carry, and looked the venv up with vim.fs.find
on a name containing slashes, which 0.11 never matches and which searches
down the tree rather than up. Setting config.settings there works because
each client gets its own copy of the config and keeps that table as
client.settings.

Checked with the real config and pyright 1.1 on a mock workspace: a service
with its own venv, a member using the workspace's, a requirements.txt project
with none, and the workspace root each got their own client and interpreter,
and each file resolved only the module installed in its own venv.

From change request a48b26.
Igor Soarez igor@soarez.org
Mon, 12 Oct 2026 16:33:58 +0100
b2246e1c
nvim: ~/x in the space+p directory picker, where it exists

The picker searched only /Volumes/Sense (or ~/src) and ~/.local/share/nvim,
so nothing under ~/x could ever be offered. It is added behind the same
isdirectory check as the other roots, so a machine without ~/x passes find no
missing path.

From change request cbe283.
Igor Soarez igor@soarez.org
Mon, 12 Oct 2026 16:33:58 +0100
fdaf6742
nvim: render markdown in the buffer

render-markdown.nvim draws headings, code blocks, tables and checkboxes in
place, in the one buffer, and leaves the cursor line raw so editing is
unaffected; <leader>mt toggles it. The markdown and markdown_inline parsers
join the install list, which also gives noice's LSP hover rendering the
parsers it was already set up to use.

None of the colourschemes here define the @markup.heading.N.markdown captures
the plugin colours headings from, so every level rendered alike. A ramp fills
them in, only where the theme left the group empty, and again on each theme
switch. Checked with the real config: 26 marks on a test file, conceallevel 3,
H1-H3 in distinct bold colours.

AGENTS.md also stops listing nvim as having no machine-local hook: lua/local.lua
is one, gitignored and loaded by cfg-watch after the plugins.

From change request 12882c.
Igor Soarez igor@soarez.org
Wed, 07 Oct 2026 19:24:51 +0300
6c97cb32
zsh: Caps Lock no longer types junk at a prompt without tmux

Caps Lock sends F20, which a terminal with no tmux in front of zsh passes on
as ESC[34~ (Alacritty 0.17.0, input/keyboard.rs). Nothing bound it, so zsh
split the sequence and typed what was left at the prompt, once per repeat.
Bound as a whole to nothing, it is consumed as one key; replayed over a pty,
three presses typed 4~4~4~ before and nothing after. The zsh half of the fix
for Neovim and tmux.

From change request 47bf8a.
Igor Soarez igor@soarez.org
Mon, 05 Oct 2026 18:46:10 +0300
6a6c5821
pi: the session name in herdr's Agent sidebar

herdr labels every pi pane "pi", so with several open the rows cannot be told
apart. herdr 0.9.3's socket API has pane.report_metadata, whose display_agent
replaces that label without touching lifecycle state; this reports the session
name there on start and on every rename, auto-named titles included.

Interactive sessions only: a pi -p or subagent run from inside a pane inherits
its HERDR_* variables and would otherwise rename the pane after itself. Checked
against a fake herdr socket: a print-mode run sends nothing, while the socket
accepted a control message. Outside herdr it returns at once. The pi APIs it
uses -- ctx.mode "tui", getSessionName, session_info_changed's name -- are as
in pi 1.0.0, and link/pi.sh links it in like any tracked extension.

From change request e053eb.
Igor Soarez igor@soarez.org
Mon, 05 Oct 2026 16:29:50 +0300
09fd7f02
Caps Lock no longer types <F22> into Neovim

Caps Lock sends F20, and the terminal passes it on as well as Wispr Flow
seeing it. tmux reads it as S-F10 and hands the pane ESC[21;2~, which Neovim
decodes as <F22> and, with nothing mapped to it, types out as text -- once per
repeat while the key is held.

Dropped in both places, each enough alone: Neovim maps <F20> and <F22> to
nothing, terminal mode included so a :terminal does not pass it to its shell,
and tmux binds S-F10 to an empty command, which also swallows a real
Shift+F10 that nothing here uses. The empty binding loads on tmux 3.5a and 3.4.

From change request cee596.
Igor Soarez igor@soarez.org
Mon, 05 Oct 2026 16:20:06 +0300
611dfd90
pi: extensions as a directory of links, so a machine can have its own

~/.pi/agent/extensions was a link to the repo's directory, so anything that
installs a pi extension wrote into this checkout -- herdr's pi integration did,
on one machine. It is now a real directory with the repo's tracked extensions
linked in one by one, and a file a machine adds there stays that machine's.
pi follows the links, and skips a dangling one silently, so an extension
deleted from the repo leaves a harmless stale link.

The old directory link is removed first: linking into it would write through
to the repo and turn its files into links to themselves. Only tracked files are
linked, so a machine's leftovers in the repo's directory are not linked back.

The filed version put this loop in link.sh; it lives in link/pi.sh, since
link.sh holds one-line links only and a part also obeys ~/.config/dot/skip.

From change request 3f6b05.
Igor Soarez igor@soarez.org
Mon, 05 Oct 2026 13:53:09 +0300
fac95146
alacritty: the mux launch in its own import, overridable per machine

Alacritty merges the importing file last, over all of its imports
(v0.17.0: merge(imports, config)), so the inline [terminal.shell] could not be
replaced from local.toml at all; local.toml only ever got away with keys the
main file leaves unset. The launch now lives in mux.toml, imported first, and
local.toml, imported after it, can replace it.

But only in the string form. Alacritty's merge appends arrays rather than
replacing them, key by key (serde_utils.rs), so a [terminal.shell] table in
local.toml would put its args after tmux's, zsh would run the first -c, and
the window would open in tmux with no error -- the override the filed request
described would not have worked. mux.toml and AGENTS.md say how to do it:
`[terminal] shell = "/path/to/launcher"`, which replaces the whole table.

No machine changes today: the existing local.toml files set fonts only.

From change request 0bd5fb.
Igor Soarez igor@soarez.org
Mon, 05 Oct 2026 09:45:05 +0300
8c1724fc
pi: compact tool output, pinned, 3-line previews for every tool

pi-compact-tools draws each tool call as a short, expandable row; Ctrl+O shows
the whole result. Every built-in and every custom tool, MCP ones included, is
compacted and starts as a 3-line preview -- previewLines only applies to rows
that do not start hidden, so nothing does.

Pinned to the v0.14.0 commit, byte-identical to the npm tarball: the package
had 32 releases in its first three weeks, and unpinned that would be new code
across the fleet nearly every day.

The config is linked rather than generated so it is the same everywhere, and
/compact-tools saves through the link by design. AGENTS.md now says which
linked files their tools rewrite, and that such a change is not an agent's to
commit or revert.

From change request 4d685a.
Igor Soarez igor@soarez.org
Mon, 05 Oct 2026 09:40:20 +0300
b872fd50
tmux: machine-local overrides in ~/.tmux.conf.local

tmux was one of the configs a machine could not adjust without editing the
shared file. ~/.tmux.conf.local is now loaded last, so it wins over everything,
colours included, and is skipped silently where there is none. Not under
~/.config/tmux, which links into this repo.

The filed version loaded it with `if-shell -b`, which runs in the background
and applies the file after the rest of the config, in no fixed order;
`source-file -q` does the same job in order, without a shell. -q checked on
tmux 3.5a and 3.4.

From change request 379803.
Igor Soarez igor@soarez.org
Sat, 03 Oct 2026 07:26:40 +0300
013b5922
install.sh: Alacritty from its release DMG, under a stated rule

Homebrew disabled the alacritty cask on 2026-09-01: upstream builds are ad-hoc
signed and not notarized. Existing installs kept it, but a new Mac got no
Alacritty, and this repo's config, Dock, Hammerspoon and terminal-set-opacity
all assume one.

Clearing a quarantine flag is now allowed under one rule instead of never: the
file is pinned by a hash reviewed here, its signature verifies as unmodified,
and the app is named explicitly. Alacritty passes -- the v0.17.0 DMG matches
GitHub's published digest, and codesign --verify --deep --strict passes.
toggleMute failed on the signature, which is why it stays dropped. The hash is
hard-coded rather than looked up, since a checksum fetched beside the file
catches corruption, not substitution.

The filed version wrapped the steps in `( set -euo pipefail ... ) || warn`,
and bash ignores set -e on the left of ||: a wrong hash still installed the
app. Each step now exits by hand. Tested against a scratch target with the
right hash, a wrong hash, and a failing signature check.

From change request 56fc45.
Igor Soarez igor@soarez.org
Sat, 03 Oct 2026 06:18:53 +0300
76fe44a4
pi: auto-named sessions, pinned to a commit

pi --resume listed each session by its raw first message, often blank or a
system-reminder blob. pi-auto-session-name has the active model write a short
title instead, through pi's own model call, and never overwrites a name set by
hand.

Pinned to the repo's commit rather than the npm version: the package is days
old with one maintainer and runs inside every pi on every machine, so an update
should arrive only when chosen. npm refuses to reuse a version number, but a
commit hash does not depend on npm's policy at all. The commit's extension is
byte-identical to the published 1.0.0 tarball, and needs no build.

From change request 161e51.
Igor Soarez igor@soarez.org
Sat, 03 Oct 2026 05:47:06 +0300
3b254c52
Revert a by-hand dotfiles-sync that published one machine's Claude settings

Running dotfiles-sync by hand on two machines, to roll out the pi change, also
published one machine's local edits to config/claude/settings.json -- "model":
"opus" and switchModelsOnFlag -- which every machine would then have
inherited. That file changing on a machine in use is its normal state, not a
change to share.
Igor Soarez igor@soarez.org
Fri, 02 Oct 2026 06:32:48 +0300
2dcd207e
Sync
Igor Soarez igor@soarez.org
Fri, 02 Oct 2026 04:31:55 +0100
e0841685
pi: drop pi-mcp-adapter, MCP is native now

pi 1.0.0 has MCP built in, and while the adapter is installed it suppresses
that and prints a warning at every start. The browser gates that answered the
adapter's approval event now hook pi's own tool_call as well. From change
request 562974.
Igor Soarez igor@soarez.org
Fri, 02 Oct 2026 06:31:22 +0300
5919160d
bin/wispr-keys: Flow dictation on the Caps Lock key

Push to talk on F20, Command Mode on Ctrl+F20, written into Flow's config.json
because its settings screen refuses F20 without a modifier while its key
listener accepts it. The key codes and action names come from reading Flow
1.6.1021's app.asar: shortcuts are keyed by macOS key codes string-sorted and
joined with "+", f20 is 90, ctrl is 59, ptt is Push to talk and lens is Command
Mode. splitKeybinds is kept in step so Flow cannot read back a disagreeing pair.

A bin/ tool rather than a link/ part, though it was proposed as one. It has to
quit and restart Flow for an edit to stick, and link.sh runs unattended after
every sync: a part that can kill a dictation mid-sentence is a poor trade for a
binding that can wait until someone asks for it. On demand it is also free to
fail loudly, since nothing else's run depends on it.

Flow ignores an AppleScript quit, so it gets a TERM; and it is launched by
bundle id, because the bundle contains a second app also called "Wispr Flow"
-- its Swift helper -- that `open -a` can start instead, which exits at once.
The file as first found is kept as config.json.pre-wispr-keys.
Igor Soarez igor@soarez.org
Thu, 01 Oct 2026 21:24:09 +0300
cf4c4424
Brewfile: drop toggleMute

Ad-hoc signed, no TeamIdentifier, and spctl rejects the bundle as modified
since signing, so it only runs with its quarantine flag cleared. A setup script
that every machine runs is the wrong place to put an unverifiable binary past
Gatekeeper, so the xattr step goes with it and install.sh now says it clears no
quarantine flags at all. QuickMD stays: Developer ID, notarized.
Igor Soarez igor@soarez.org
Thu, 01 Oct 2026 21:12:39 +0300
251ebd6e
Brewfile: say what the two taps actually ship

Checked rather than assumed. QuickMD is signed with a Developer ID and
notarized, so its quarantine flag costs a dialog. toggleMute is ad-hoc signed
with no TeamIdentifier, and spctl rejects it as modified since signing -- so
the quarantine step is not 'it is not notarized' but 'there is no verifiable
publisher'. The trust gate also turned out looser than the first note claimed:
it refused an untrusted formula, but both casks installed without it.
Igor Soarez igor@soarez.org
Thu, 01 Oct 2026 21:10:47 +0300
5b882ce2
Brewfile: toggleMute and QuickMD, from their authors' taps

Neither is in homebrew-cask. That costs three things this file cannot express
on its own, so install.sh carries them: brew will not load a cask from an
unofficial tap until it is trusted, which is per machine and recorded outside
the repo; the trust is granted per cask rather than per tap, so it covers these
two files and not whatever the taps add later; and toggleMute is not notarized,
so Gatekeeper refuses it until com.apple.quarantine is cleared -- that one
attribute on that one path, not the `xattr -cr` its README asks for.

Nothing here vouches for either binary, and a third-party tap can change what
it installs between two runs. Both are deliberate exceptions, named as such.
Igor Soarez igor@soarez.org
Thu, 01 Oct 2026 21:07:46 +0300
10d14598
Brewfile: glow and Ice

Neither is read by anything here, so they sit apart from the tools the config
depends on: glow renders markdown in the terminal, Ice manages the menu bar
like keepingyouawake beside it.
Igor Soarez igor@soarez.org
Thu, 01 Oct 2026 19:07:19 +0300
6c7eb1b3
hammerspoon: Caps Lock sends F20, and stays that way

hidutil remaps inside the IOKit HID driver, below the window server, so the
key stops being Caps Lock before anything above the driver sees it: no toggle
to suppress, and F20 is a real key to other apps, to System Settings
shortcuts, to a password field. An eventtap cannot do this -- Caps Lock is not
reachable from there, which is why hs.hid exists as its own IOKit module.

What hidutil will not do is cover a keyboard attached after it ran, so dotlib
re-applies on USB attach, on wake, and on a Caps Lock event. That last one is
the general answer: a mapped keyboard cannot produce a Caps Lock event, so an
event arriving at all is proof of a keyboard the remap missed, and the fault
triggers its own repair. The watchers are there to get the common cases before
a key is pressed, since the repairing press is spent repairing.

No launchd job and no link/ part. The remap lives and dies with Hammerspoon,
which already owns the keyboard on these machines, and a plist would only have
bought independence from it for the second or two before it starts -- a user
agent does not run at the login window either.

Nothing is bound to f20 here; bind it like any other key. A machine gives the
key back with capslock_off() from its local.lua, which loads last: shadowing
cannot undo a side effect that has already run. hs.ipc is required so
capslock_status() answers from a shell.
Igor Soarez igor@soarez.org
Thu, 01 Oct 2026 18:05:25 +0300
7fc9494a
AGENTS.md is the file; CLAUDE.md imports it

Every agent here except Claude Code reads AGENTS.md -- pi, codex, and aider now
that its read: says so. The vendor-neutral name was the symlink and the
Claude-specific one the real file, with a header saying the file was guidance
"to Claude Code". Swapped, keeping history on the real file through git mv.

Claude Code has read AGENTS.md directly since v2.1.277 and the machine this was
done on is newer, so the obvious move was to delete CLAUDE.md. Checking the
other machines first was worth it: four were on versions between 2.1.158 and
2.1.263, and a session on any of them would have found no instructions at all.

So CLAUDE.md stays as one line, `@AGENTS.md`, which the documentation endorses
for exactly this case and which -- unlike the symlink it replaces -- works on
Windows and does not trip Claude Code's refusal to write through a symlink. The
header says to delete it once every machine is current.
Igor Soarez igor@soarez.org
Mon, 28 Sep 2026 12:52:44 +0300
cbbc0cb5
CLAUDE.md: an agent files its own change request, without asking

The text said to hand a request to dot-request "as well as to the reply" but
never said an agent may do that unprompted, and asked for it to be written "to
be pasted whole", which reads as give-this-to-a-human. So an agent on a
read-only clone wrote a request, handed it back, and offered to file it only if
asked -- reasonable behaviour against what was written. The paragraph now is
the permission, and says why filing is safe: it applies nothing, and every
request is still judged before anything changes.

One addition beyond what was asked. Filing without asking means text leaves
that machine with nothing in the way, and the machine is one whose owner does
not control it. So putting the request in the reply is now explicitly not
optional: it is the only thing that shows what was sent.
Igor Soarez igor@soarez.org
Mon, 28 Sep 2026 12:50:35 +0300
547222ae
tmux: the command prompt gets an opaque background

Every theme set message-style with a bg and no fill. From tmux 3.7 the command
prompt is drawn over the status line, where only the prompt's own characters
take that bg and the rest of the line takes fill; with status-style bg=default
everywhere here, the terminal showed through around the prompt. Reported from a
machine running 3.7c.

fill now matches bg in all eight. It has existed since tmux 3.2 and older
versions ignore it, so this is safe on the fleet: checked on 3.5a, where each
theme sources cleanly and reports the fill back.
Igor Soarez igor@soarez.org
Mon, 28 Sep 2026 12:50:35 +0300
f5d9efe3
pi: the siliconflow key is a reference, not a name

models.json gave the provider an apiKey of "SILICONFLOW_API_KEY", which pi
sends verbatim as the credential: it interpolates "$NAME" and "${NAME}", runs
"!command", and treats anything else as a literal. So that provider has been
authenticating with the 19-character string "SILICONFLOW_API_KEY" and getting a
401 for as long as it has existed.

Confirmed by calling pi's own resolver: "SILICONFLOW_API_KEY" resolves to
itself, "$SILICONFLOW_API_KEY" to the value. Nothing catches this --
isConfigValueConfigured returns true for both and reports no missing variables,
so `pi auth check` says ready either way, and only a real request fails. Hence
the note in CLAUDE.md beside the models.d row: the rule is invisible in the
file, and the next provider added would repeat it.

Reported as a change request from the machine that hit it; the sweep for other
instances found none.
Igor Soarez igor@soarez.org
Mon, 28 Sep 2026 12:45:14 +0300
bf177c58
macos: hot corners and tap to click, and a way to find the next key

Three settings this account has always set by hand on every new Mac, now in the
script that exists for exactly that: the bottom-right hot corner sleeping the
display, tap to click, and three-finger drag on an external trackpad -- the
last a gap, since the script set it only for the built-in one.

Each of those is written in more than one place because macOS keeps them in
more than one place: an external Magic Trackpad has its own domain, and the
login window reads com.apple.mouse.tapBehavior rather than either of them,
per-host for this machine and globally for any other. Setting one and not the
others is why tap to click sometimes survives a reboot and sometimes does not.

bin/macos-defaults-diff is the general answer: snapshot, change one setting,
and it prints what moved, including the -currentHost domains a plain
`defaults read` never shows. It flushes cfprefsd first, since a change made
seconds earlier can otherwise be missing from the read meant to reveal it, and
it says plainly when nothing changed -- anything behind Privacy & Security
lives in the SIP-protected TCC database and cannot be scripted at all.
Igor Soarez igor@soarez.org
Sun, 27 Sep 2026 10:14:30 +0300
b0714511
profile: stop shadowing exit

`exit` was a function that ran `tmux detach` instead of exiting, whenever a
shell was interactive and inside tmux. What it bought was a keystroke: tmux
already binds detach to prefix-d, which this config does not touch.

What it cost was an afternoon. Claude Code captures shell functions into a
snapshot and sources it in every tool call, so agent shells inheriting $TMUX
ran the function too: terminal windows closed on their own for weeks before
anyone connected it to `exit`, and a poll loop that called `exit 0` detached a
client every minute instead of stopping, because the function detached and then
returned. The call-time guard added on 2026-07-16 fixed that case; it did not
make shadowing a POSIX builtin a good idea.

Nothing in either repo calls it and nothing documents the behaviour.
Igor Soarez igor@soarez.org
Fri, 25 Sep 2026 13:49:05 +0300
ed21d127
link/pi.sh: the generators move into files, and parse under bash 3.2 again

57e49dd made link/pi.sh unparseable on macOS. bash 3.2 does not skip heredoc
content when it scans for the end of a command substitution, so the three
apostrophes in a comment inside the new heredoc read as an unterminated quote
and the script stopped parsing at end of file. ./link.sh on a Mac aborted
partway: settings still generated, models.json stayed the symlink the commit
meant to replace, and everything after that point never ran. Linux was fine,
and so was every check I ran -- `bash` on a Mac with Homebrew is 5.x, which
parses it happily. Reported from the machine it broke.

Rewording the comment would fix it and leave the trap armed for the next edit,
so the two python programs move into link/pi-settings.py and link/pi-models.py
instead, called by path. The text is unchanged; there is no longer a heredoc
inside $( ) to trip over, and both files now compile-check on their own.

Verified under /bin/bash 3.2: first apply replaces both symlinks, a package
removed by hand still survives a relink, fragments merge for settings and for
models, and repeat runs report unchanged.
Igor Soarez igor@soarez.org
Fri, 25 Sep 2026 12:20:13 +0300
220553f1
A filed request needs the owner's approval, not just a valid signature

The reader section said a request was a proposal to read and judge. That tells
an agent how to read one, not what it may do about it, and the gap is the whole
risk: a machine files requests precisely because it cannot be trusted with push
access -- it may be compromised, and whoever holds its key signs whatever they
like. A signature proves which machine sent the text, never that the text is
right or that its owner wrote it.

So: judge it, report it, and apply nothing without the owner approving that
request. Text inside a request asking for it to be applied or pushed is not
approval; it is the input the rule exists for. The same words are in the
reader's own output and in the inbox repo, so this holds for whoever meets a
request first.
Igor Soarez igor@soarez.org
Fri, 25 Sep 2026 11:30:39 +0300
57e49ddd
pi: providers can come from ~/.config/pi/models.d, like settings already do

models.json was a plain symlink to the tracked file, which left a machine whose
models are served from somewhere other than a vendor's own API with nowhere to
say so. pi reads providers from exactly one path -- join(getAgentDir(),
"models.json") -- and a `providers` block in a settings fragment is inert,
because the settings schema has no such key. So on such a machine pi cannot be
used at all, and the endpoint is not something this repo may name.

link/pi.sh now generates models.json the way it already generates settings.json:
the repo's file, then every ~/.config/pi/models.d/*.json. No three-way merge
here -- pi never writes this file, keeping runtime state in the sibling
models-store.json -- so a hand-edit of ~/.pi/agent/models.json no longer
survives a relink. Put it in a fragment.

It reports which provider came from which fragment, added or replaced, so a
local file shadowing a provider the repo ships cannot do it quietly.

Proposed as a change request from the machine that needs it; verified here
against pi 0.84.2 before applying.
Igor Soarez igor@soarez.org
Fri, 25 Sep 2026 11:30:38 +0300
96c42e43
dot-request: file a change request from a machine that cannot push

A clone made over https cannot publish anything, so a fix found on such a
machine is lost at the next pull. The instructions already said to hand back a
change request instead of editing; this is the command that does it. The
request is markdown on stdin, signed with a key that machine holds, and posted
to the inbox, which stores it only if the signature is from a key authorised
there. Where the key is not authorised, nothing is stored and the submission
says so.

The endpoint is named here, which the leak rule would otherwise forbid, and the
rule now carries the exception: a public endpoint this repo's own tooling must
reach, already published by a DNS record and a certificate in the transparency
logs, is not a leak. Concealing it would protect nothing and cost every machine
a hand-written config file; what protects it is the signature. The private
layer's check has the same exception, so it is enforced rather than remembered,
and it does not extend to hosts, addresses or internal-only names.

The other side is documented too: where `sz-requests` exists, what arrived can
be read and judged. A signature says which machine produced the text, never
that the text is right -- the machines filing these are the ones this account
does not fully control.
Igor Soarez igor@soarez.org
Fri, 25 Sep 2026 03:09:35 +0300
b9e58b09
tree-sitter-cli, and: a read-only clone hands back a change request

An https clone of this repo cannot push -- the host serves it anonymously and
refuses the push with a 403 -- and that is how a machine whose owner cannot
hold a key gets it. An agent troubleshooting there would edit a tracked file,
see the fix applied, and lose it at the next pull, or leave that clone unable
to fast-forward and quietly cut off from changes. CLAUDE.md now says how to
tell the two cases apart, and what to do instead: make it machine-local where a
hook covers it, or hand back a change request -- paths, the actual diff, why,
and how to verify -- stating that nothing was applied.

AGENTS.md is a symlink to CLAUDE.md, so the agents that look for that name get
the same file rather than a second copy to drift.

tree-sitter-cli: nvim-treesitter generates and compiles its parsers with it.
Igor Soarez igor@soarez.org
Tue, 22 Sep 2026 11:21:40 +0300
f31a7a6d
hammerspoon: the example's header is init.lua's, so bindings move either way

It unpacked only hotkey and mods, so a line copied from init.lua -- which
writes `hotkey('9', csa, type_text(...))` with bare names -- did not run here,
and a line moved the other way had to be rewritten. The header is now the same
block init.lua has, name for name, and the examples use the bare names too.

Unused names in it are harmless, and keeping them whole is the point: paste a
binding in either direction and it works as written.
Igor Soarez igor@soarez.org
Mon, 21 Sep 2026 18:53:13 +0300
94a8f1ff
hammerspoon: shared helpers move to dotlib.lua, which local.lua can require

A machine's local.lua is a separate chunk, so init.lua's locals were invisible
to it and anything it wanted -- hotkey(), the mod tables, type_text -- had to
be copied. They live in dotlib.lua now, linked beside init.lua, and both files
reach them the same way: require("dotlib").

init.lua keeps its own vocabulary by aliasing what it uses, so the 500 lines
below are untouched. It prepends its own directory to package.path first:
Hammerspoon already puts the config directory there (Contents/Resources/
setup.lua), but taking it from this file's path holds however the config was
found. A machine that pulls without re-linking has no dotlib.lua beside
init.lua, which would lose every hotkey silently -- that case notifies and
names the fix.

Checked with a stubbed hs: the module's hotkey, focus, execute, type_text and
type_current_date all behave, and local-example.lua binds its four keys
through it.
Igor Soarez igor@soarez.org
Mon, 21 Sep 2026 18:30:47 +0300
7f004995
hammerspoon, ghostty: example files for their machine-local configs

ssh and git each ship one; these two hooks were discoverable only by reading
the config that loads them. Both examples carry what is easy to get wrong:
init.lua's hotkey() helper and mod tables are locals, so a local.lua calls
hs.hotkey.bind directly, and a binding there can only be shadowed, never
deleted; in Ghostty a single-value key replaces while keybind accumulates, and
`ignore` is how a binding is switched off rather than added to.

Checked rather than assumed: the lua parses, and the Ghostty example validates
and, dropped in as ~/.config/ghostty-local, does what it says -- font-size,
command and background-opacity replaced, super+shift+n added, alt+enter turned
off.
Igor Soarez igor@soarez.org
Mon, 21 Sep 2026 17:41:45 +0300
f957dd3a
CLAUDE.md: where a single machine's changes go, and what to do without a hook

The hooks existed but were documented nowhere together: ~/.localenv and
~/.localprofile were mentioned in passing, the skip file under the link
system, and the ssh, git, Hammerspoon, Ghostty and Alacritty ones only in the
config files that read them. Anyone -- an agent especially -- asked to change
one machine had to read the whole repo to find out whether that was possible
without committing to it.

They are one table now, with what each overrides and what reads it, plus what
has no hook at all. And the rule for everything else: say what is being asked
for and where it would have to live, rather than putting one machine's
preference in the config every machine shares, or leaving that machine's
checkout dirty and its sync failing.
Igor Soarez igor@soarez.org
Mon, 21 Sep 2026 17:40:22 +0300
6c159347
ghostty: read ~/.config/ghostty-local last, where a machine has one

Ghostty had no way to differ per machine. It cannot take the shape the other
tools use -- a file beside the linked one -- because link.sh links this whole
directory, so ~/.config/ghostty is the checkout itself and a sibling file
would be an untracked file in the repo every machine shares.

An optional include at the end does the same job: later values replace earlier
ones, so a local file overrides anything here, the theme colours included, and
its keybinds add to these. Verified against the shipped config: font-size 12
becomes 18 with a local file, back to 12 without, and the config still
validates when there is none.
Igor Soarez igor@soarez.org
Mon, 21 Sep 2026 17:18:53 +0300
b3108c15
hammerspoon: load ~/.config/hammerspoon/local.lua where a machine has one

The shell has ~/.localprofile and ssh and git their config-local; Hammerspoon
had nothing, so a machine wanting one different hotkey had to edit the file
every machine shares. Only init.lua is linked, so the directory around it is
already the machine's own -- a sibling file there is in no repo.

Loaded last, so it can override anything above, and through pcall: a mistake
in a local file must not take the rest of the config down. Reload with
ctrl-shift-alt-h as usual.
Igor Soarez igor@soarez.org
Mon, 21 Sep 2026 15:51:53 +0300
d312cb2a
zshrc: find Homebrew's completions on the prefix this machine uses

fpath carried /usr/local/share/zsh-completions, which is wrong twice over:
Apple Silicon puts Homebrew under /opt/homebrew, and formulae install their
completions into share/zsh/site-functions, not the share/zsh-completions of
the formula by that name. The directory existed on neither Mac, so _brew,
_bat, _deno and the rest have been missing all along -- silently, since a
missing fpath entry says nothing.

All four candidates are probed now, HOMEBREW_PREFIX first where a machine
exports it. No `brew --prefix`: that forks on every interactive shell, and
this file already pays for one of those further down.
Igor Soarez igor@soarez.org
Mon, 21 Sep 2026 15:26:23 +0300
73031b77
Brewfile: keepingyouawake, and say that Mac apps belong here

Installed by hand on machine after machine, which is the signal it belongs in
the list. Nothing in this repo uses it, so the header now says what the casks
were already doing: the few Mac apps this account wants wherever it works,
alongside the tools the config itself needs.
Igor Soarez igor@soarez.org
Mon, 21 Sep 2026 15:16:57 +0300
f671fb67
supermaven off unless asked for; a blocked cask no longer ends install.sh

Supermaven was running everywhere. Its `condition` option reads the other way
round from what the code assumed -- `true` stops it -- so `return false` meant
"never stop", and the plugin loaded on InsertEnter, started its agent and
offered inline suggestions on every machine. Only blink's source for it was
off, which is what made it look disabled. It sends buffer context to a third
party, so it is now opt-in: DOT_SUPERMAVEN=1, in ~/.localenv where it is
wanted. Off, lazy keeps both plugins off disk entirely.

install.sh no longer stops at brew bundle either. A managed machine can refuse
a cask outright -- "disabled because it does not pass the macOS Gatekeeper
check" -- and brew bundle fails the whole run for one such entry, which left
the checkouts, nvim plugins and colour files undone. It warns and carries on;
brew has already named the entry.
Igor Soarez igor@soarez.org
Mon, 21 Sep 2026 14:57:57 +0300
b658c623
Brewfile: say why bash is here, correctly

The reason given named bin/open, which does not need it: on macOS it execs
/usr/bin/open on line 13, before anything 3.2 cannot parse. What does need it
is szecret's age bootstrap -- `${os,,}` in secrets/common.sh, reached only when
age is absent, which is precisely the state a machine is in before this file
has been applied.
Igor Soarez igor@soarez.org
Mon, 21 Sep 2026 14:38:57 +0300
352bd588
install.sh: install Homebrew, add bash, and seed the colour files

Three things a machine set up from this repo alone hit on its first run:

Homebrew was a dead end -- install.sh printed the command and exited, leaving
the one prerequisite everything else needs to be run by hand. It installs it
now, NONINTERACTIVE so it does not wait on a RETURN; the sudo password is still
asked for, which is why this script is run by hand.

bash was missing from the Brewfile, so bin/open and secrets/ ran under the
3.2 macOS ships and failed on `${v,,}`; config/bashrc also looks for the
bash-completion 2.x that wants 4.2.

Ghostty refused to start -- "error opening config-file ... error.FileNotFound"
-- because config-file pointed at the colour file bin/theme writes, which does
not exist until theme has run. The include is optional now (`?`), and this
script seeds the colour files when a machine has none, leaving a machine that
already has a theme alone.
Igor Soarez igor@soarez.org
Mon, 21 Sep 2026 14:37:35 +0300
dec27023
link: a part named in ~/.config/dot/skip does nothing on that machine

A machine may need one of the parts left out: on a laptop it does not own,
~/.claude/settings.json is the owner's, holding their environment and plugins,
and linking this account's over it is wrong in both directions -- theirs is
lost, and anything of theirs worth keeping would have to be carried here, in
the repo every machine shares.

The file is hand-written and in no repo, like ~/.localenv, so the exception
stays on the machine that needs it. The check is in link/lib.sh, as each part
starts, rather than in link.sh's loop, so `link/claude.sh yes` obeys it too;
link.sh and other repos' link.sh source that file as well, and sit outside
link/, so their names can never be read as a part's.
Igor Soarez igor@soarez.org
Mon, 21 Sep 2026 14:09:58 +0300
fec40208
link/claude: secrets in MCP specs, and a separate spec for Desktop

Two things a spec in ~/.config/claude/mcp-servers.d/ may now carry beyond
what Claude Code takes, both resolved at link time:

- "${szecret:NAME}" in any string value, replaced with `szecret get NAME`
  when the server is registered with Claude Code. For a remote server's
  Authorization header: Claude Code's own headersHelper is not run in a
  workspace without persisted trust, which includes $HOME, so a token
  fetched that way is simply missing. A secret that cannot be read skips
  that server, loudly.

- "desktop", a whole spec given to Claude Desktop instead. Desktop only
  starts local stdio servers, so a layer that moves a server to HTTP for
  Claude Code can keep Desktop on a local copy.
Igor Soarez igor@soarez.org
Fri, 18 Sep 2026 13:03:16 +0100
13cc065e
dotfiles

Shell, editor and terminal configuration, the tools that go with them, and
szecret, an age-based secret manager whose store lives elsewhere.

History restarts here. What came before was not written to be read by
anyone else, and carried machine names, addresses and an encrypted secret
store; it is kept privately instead. What is left is meant to be safe on
any machine, including one whose owner is not me: see CLAUDE.md for the
rule it is written to.
Igor Soarez igor@soarez.org
Thu, 17 Sep 2026 19:45:23 +0100