Supersedes the old flat .config/ layout (last published 2026-06-28) with the private repo's structure: one shared base plus per-host overlays. - packages: common/ gui/ lw/ fl/ wm/ plus install.sh and bin/ tooling (dotsync, reconcile-hyde.sh) - new README covering the layout, deploy order and the HyDE dependency - current HyDE waybar rig (layouts/, cava), pi agent extensions, claude/ config, tmux, presenterm, aichat roles - fish: kp (keepassxc-cli + fzf picker, db path from $KP_DB) and bind_M_n_history (alt+1..9 recalls the nth history entry) - drops cruft that should never have been tracked: the duplicate top-level .pi/ copy, btop.log, zellij config.kdl.bak, fish_variables - .pi/agent/auth.json is gitignored; auth.json.example ships instead Host-specific work sessions and the personal backlog stay in the private tree. Endpoint locators in the llamacpp/whisper guides are placeholders ($SERVER, <own-domain>) — the guides themselves stay, since they are the useful part.
16 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
This is not application source — it's the on-disk configuration tree for pi, the TUI coding agent from earendil-works/pi (binary at /usr/bin/pi). It lives inside the user's dots repo (~/.dots) and is surfaced into $HOME via GNU Stow's per-file symlinks (--no-folding), e.g. ~/.pi/agent/extensions/notify.ts -> ../../../.dots/common/.pi/agent/extensions/notify.ts. Editing an existing file edits the live config directly — no deploy step. But adding a new file (e.g. a new extension) is not surfaced until you re-stow: run ./install.sh (or stow --no-folding -R -d ~/.dots -t ~ common) to create its symlink, on each machine.
Because it's JSON + markdown config, there are no tests/lint/build. The only useful "validation" is JSON well-formedness, e.g. jq . settings.json or python -m json.tool < settings.json.
Config directory — single source of truth
All live config is .pi/agent/ (surfaced as ~/.pi/agent). The pi coding agent reads $PI_CODING_AGENT_DIR, which defaults to ~/.pi/agent and is not overridden here; per pi's docs, settings/auth/trust/sessions/extensions/AGENTS.md all resolve under that one directory. Nothing reads ~/.pi/* at the top level or ~/.config/pi.
History: there used to be three drifted copies of this config (
.pi/*top-level,.pi/agent/, and~/.config/pi/). The two unread mirrors were deleted and the dangling~/.config/pistow symlink removed, leaving.pi/agent/as the only copy. Don't reintroduce mirrors — edit.pi/agent/directly.
Layout of .pi/agent/ (the live config)
settings.json— runtime settings:defaultProvider/defaultModel/defaultThinkingLevel,theme,enabledModels(glob allowlist for the Ctrl+P model picker),compaction(auto-summarize long sessions),retry, HTTP timeouts,npmCommand(pinned tofnm exec --using=22 -- npm),packages(installed extension packages, currentlynpm:pi-vim) and their config blocks (piVim— see Vim input editor).models.json— the provider + model catalog (see below).auth.json— maps each provider to its credential. Values are references, not secrets ($DUSKADIY_API_KEY,no-key-required); pi resolves$VARfrom the environment. Gitignored — the tracked copy isauth.json.example; a new host needscp auth.json.example auth.jsonbefore pi can resolve a provider (stow links whatever is present, tracked or not, so the live file keeps working here).prompts/*.md— custom slash commands (pi "prompt templates")./commitand/revieware defined here. Format: YAML frontmatter (description,argument-hint) + body, with${1:-default}positional-arg substitution.themes/*.json— color themes following the schema atearendil-works/pi .../theme/theme-schema.json: avarspalette referenced by semanticcolorskeys, plus anexportblock for HTML session export.sessions/— runtime session transcripts (.jsonl), gitignored. One subdir per project cwd; each line is an event (session,model_change,message, …).extensions/*/index.ts— auto-discovered TypeScript extensions, loaded via jiti (no build step).import typefrom@earendil-works/*is erased at runtime; value imports resolve against pi's own bundled packages, so a vendored extension needs nonode_modules. Editor TS "cannot find module" warnings on these imports are therefore expected noise.npm/— extension packages installed bypi install(currentlypi-vim), gitignored;settings.json > packagesis the tracked source of truth. On a fresh host runpi install npm:pi-vimonce — plus pi-vim's manual peer-dep install (see Vim input editor), whichpi installdoes not do — thenpi update npm:pi-vimto bump. A package needs no re-stow — unlike a new file underextensions/.keybindings.json— key remaps. A user entry replaces the default keys for that action (it does not merge). Action ids and defaults are listed in/opt/pi-coding-agent/docs/keybindings.md.
Plan mode (Shift+Tab)
extensions/plan-mode/ is pi's bundled plan-mode example (pi has no built-in plan mode), vendored here and rebound from its upstream Ctrl+Alt+P to Shift+Tab. In plan mode it disables edit/write and restricts bash to a read-only allowlist (footer shows ⏸ plan); /plan also toggles it, --plan starts in it. The rebind is two coupled edits — keep them together:
extensions/plan-mode/index.ts(~line 157):pi.registerShortcut("shift+tab", …), and the upstreamimport { Key } … }is removed. This file is a local fork of/opt/pi-coding-agent/examples/extensions/plan-mode/; on a pi upgrade, re-pull from there and re-apply these two edits.keybindings.jsonmovesapp.thinking.cycleoff Shift+Tab toctrl+shift+t, so the toggle fires deterministically (otherwise it collides with the built-in thinking-cycle binding).
After editing an extension or keybindings.json, run /reload in pi to apply without restarting.
Status bar (custom footer)
extensions/statusbar.ts replaces pi's default footer via ctx.ui.setFooter(). It
mirrors the Claude Code status line (~/.config/claude/statusline.py) but swaps the
emoji for Nerd Font (Material Design) glyphs — the same family already used in
tmux (waybar cpu/mem/net glyphs) and nvim — so it renders natively in kitty
(CaskaydiaCove Nerd Font Mono). Colors come from the active theme, not raw ANSI.
Icon legend (each glyph echoes the Claude emoji it stands in for):
| Glyph | Codepoint | Segment | ~ Claude |
|---|---|---|---|
|
U+F024B nf-md-folder |
cwd (~-collapsed) |
📁 |
|
U+F062C nf-md-source_branch |
git branch | 🌿 |
|
U+F06A9 nf-md-robot |
model id + · thinking |
🤖 |
|
U+F021A nf-md-text_box |
context-window % used (+ tokens) | 📝 |
|
U+F04C5 nf-md-speedometer |
last response's decode throughput (t/s) | — |
|
U+F002A nf-md-alert_outline |
context-budget warning widget (above editor, ≥80%) | — |
↑ ↓ |
— | session input / output tokens | 💰 |
Two extras beyond the footer line: a themed "breathing" pulse working-indicator
(the streaming spinner), and a context-budget warning widget above the editor
that appears once the window is ≥80% full (warning, then error ≥90%) nudging a
/compact. Both are reset when the footer is toggled off.
Segments render left→right and truncate at the terminal edge. git only shows inside
a git repo; context, t/s and tokens only appear after the first response (t/s is
usage.output ÷ (message_end − first streamed token), so prompt-eval time is
excluded). Extension statuses (e.g. plan-mode's ⏸ plan) are preserved at the far
left. The Claude bar's system row (RAM/CPU/temp/disk) is intentionally omitted —
that data isn't in the footer API, and the tmux bar below pi already shows
cpu/mem/net. /statusbar toggles it off (restores the built-in footer) and back on;
/reload picks up edits to this file (a new extension needs a re-stow first — see
top).
Vim input editor
The modal (vim-like) input editor is the npm package
pi-vim (pinned by settings.json > packages,
installed into the gitignored .pi/agent/npm/, configured under settings.json > piVim;
v0.14.1 at adoption). It replaced the hand-rolled local fork on 2026-08-05; the fork was parked
as extensions/vim-editor.ts.disabled and deleted 2026-08-09 once the package had proven
itself in daily use. git show c6566af^:common/.pi/agent/extensions/vim-editor.ts brings it back
if it is ever wanted.
That swap is the whole point: no more re-pull-and-re-apply on every pi upgrade — use
pi update npm:pi-vim, and pi remove npm:pi-vim to back out.
⚠️ This pi build needs pi-vim's peer dep installed by hand. pi here is the AUR
pi-coding-agent: a Bun-compiled ELF at /opt/pi-coding-agent/pi with the
@earendil-works/* packages embedded as virtual modules, not present on disk
(/opt/pi-coding-agent/node_modules/ holds only @mariozechner). pi-vim's
clipboard-mirror.ts calls import.meta.resolve("@earendil-works/pi-coding-agent") at
module top level — it bakes that URL into the source of a spawned clipboard helper —
so after a bare pi install npm:pi-vim the resolve throws and the whole extension fails
to load: Cannot find module '@earendil-works/pi-coding-agent'. No setting dodges it;
clipboardMirror: "never" can't, because the call runs at import time. Fix, once per host:
cd ~/.pi/agent/npm
fnm exec --using=22 -- npm install --save-exact @earendil-works/pi-coding-agent@0.83.0 # match `pi --version`
pi's own pi install deliberately does not pull peers (it would duplicate the runtime),
so this is manual, costs ~170 MB inside the gitignored npm/, and wants re-pinning after a
pi upgrade (hence --save-exact: npm's default ^ range would let a later npm install
drift the peer off pi --version on its own). The static
CustomEditor/SettingsManager/matchesKey imports still bind to pi's embedded copy —
verified in the TUI — so the on-disk copy stays inert apart from the spawned helper. Upstream has no issue filed for this (checked 2026-08-05); the proper fix is
making that resolve lazy. @burneikis/pi-vim and pi-vimmode use no import.meta.resolve
and need no peer install, if this ever gets annoying.
Five modes — INSERT, NORMAL, VISUAL, V-LINE, EX. The active one renders as a word label
(NORMAL) at the bottom-right of the editor border, doubling as a pending-command
display: 3d2w, ci", 25gg appear as you type them (NORMAL 3d2w_). That's a
position change from the fork, which put a single [N] tag bottom-left.
- motions —
hjkl·w/b/e+W/B/E·0/^/$·{/}paragraph ·gg/Gwith counts (25gg) ·f/F/t/T+;repeat ·%matching pair - operators —
d/c/y+ any motion ·dd/cc/yy/Y·x/X/s/S/C/D·o/Oopen line ·rreplace char ·J/gJjoin ·p/Pput - text objects —
iw/aw·iW/aW·i"/a"·i(/a(·i[/a[·i{/a{ - undo/repeat —
uandCtrl+R, scoped to vim changes ·.repeats the last edit - EX —
:dispatches real pi commands (:model,:tree) and shells out via:!git status. pi exposes no command-dispatch API, so it re-submits the line as if typed, snapshotting and restoring the prompt around it. - cursor shape follows the mode (bar in INSERT, block elsewhere) via DECSCUSR.
Upstream implements no search (/, ?, n, N), no macros (q/@) and no visual-block.
The fork had none of those either, so nothing regressed; pi-vimmode has them but is
rougher (5★/18 open issues vs 75★/2 at the time of choosing).
Config (settings.json > piVim)
modeColors— theme tokens, ported from the fork:insert: success(green),normal: accent(mauve),visual: warning. ⚠️ the fork's header comment claimed visual was "peach", butwarningresolves to yellow#f9e2afin catppuccin-mocha — the port keeps what actually rendered and hands the peach (bashMode) to the new EX mode.borderSyncallhost,labelSyncallmode— also the upstream defaults, but pinned explicitly so an upstream default change can't start repainting the input border. Matches the fork: only the label is tinted, the border is left to the host.clipboardMirror: "yank"— only an explicityreaches the OS clipboard. Upstream's default"all"mirrors deletes too, so everydd/xwould clobber the Wayland clipboard;"never"is exact fork parity (pi's kill-ring only). Writes go through@mariozechner/clipboardin a spawned helper, not a directwl-copy.exCommand.piDispatch: true— the:bridge above.copyInputToClipboard: false: it copies the composed prompt out, an exfiltration path, so upstream only honors it from the user-global file (never project settings) — leave it off.modeChange(unset) — would run a shell command on every INSERT/NORMAL transition.
/vim still toggles the editor, now from extensions/vim-toggle.ts (pi-vim registers no
commands of its own): off drops the editor component for the rest of the session, on calls
ctx.reload() — the same flow as /reload — which re-runs discovery and reinstalls it.
No clash with statusbar.ts: pi-vim draws its label inside its own editor component, not
through setFooter().
Compaction tuning (small local windows)
settings.json > compaction is set for the small local context windows (24k–128k;
several presets are only 24k). reserveTokens: 6144 (headroom for the
response; auto-compaction fires at contextTokens > contextWindow − reserveTokens)
and keepRecentTokens: 6000 (kept verbatim, not summarized) — both well below pi's
16384/20000 defaults so short windows aren't dominated by the reserve or thrash into
repeated compaction. These are global (pi has no per-model compaction): on the
128k model you could raise keepRecentTokens for richer retained context; if you
see responses truncate near a full window, raise reserveTokens toward the models'
maxTokens (8192). All model contextWindows are verified to match the server
ctx-size in fl/.config/llamacpp/config.ini.
Providers and models
Two OpenAI-compatible providers are configured, both serving the same catalog of small local/self-hosted models (Qwen3-Coder-30B, Gemma 4, GLM-4.7-Flash, etc.). All have cost: 0:
duskadiy— remote,https://llm.duskadiy.com/api/v1, key$DUSKADIY_API_KEY.localcpp— LAN llama.cpp server athttp://192.168.0.204:11343/v1, no key.
⚠️ duskadiy is parked out of the model picker (2026-08-05): settings.json > enabledModels lists only localcpp/*. pi resolves auth.json's $DUSKADIY_API_KEY from the environment, and this host has no ~/.config/fish/conf.d/secrets.fish, so the credential resolves empty, pi drops the whole provider, and the duskadiy/* glob then matched nothing — printing Warning: No models match pattern "duskadiy/*" on every single start. Its box is down independently of the missing key: llm.duskadiy.com still resolves (24.135.113.16) but TCP 443 times out, as does the apex. Provider, catalog and auth entry are all left intact — re-add "duskadiy/*" to enabledModels once the key is back on the host and the box answers.
defaultProvider stays duskadiy on purpose. With the provider dropped pi falls back to the same model id under localcpp (the two catalogs are mirrors), so startup resolves fine today — and when duskadiy returns it is the provider that works off-LAN, unlike localcpp's 192.168.0.204. Don't "simplify" it to localcpp without weighing that.
When adding/editing a model, keep id exactly matching the server's model id (the section name in fl/.config/llamacpp/config.ini), and set contextWindow to match the ctx-size the model is actually loaded with server-side.
Secrets
$DUSKADIY_API_KEY is the only real secret. It is defined in .config/fish/conf.d/secrets.fish (gitignored) and referenced — never inlined — in tracked config. Keep it that way: tracked files (auth.json, models.json) must contain $DUSKADIY_API_KEY, not the literal token.
auth.json is no longer tracked (2026-08-08, ahead of making the repo public): pi's interactive /login rewrites it with the literal key, so a tracked copy was one stray /login away from committing a real token. auth.json.example carries the same $VAR references. Every historical version of the old tracked file was audited and only ever contained references — nothing leaked. Installed-package and runtime artifacts (.pi/agent/npm/, git/, trust.json, sessions/) are gitignored.
Running pi
pi— interactive TUI.pi -p "<prompt>"— non-interactive, print and exit.pi -c/pi -r— continue / pick a session to resume.pi config— TUI to enable/disable discovered resources.pi --list-models [search]— list available models.--provider/--model/--thinkingoverride thesettings.jsondefaults per-run.- pi also auto-discovers
CLAUDE.md/AGENTS.mdas context files (disable with-nc) — i.e. pi reads this very file too, not just Claude Code.