diff --git a/README.md b/README.md index 8af19db..214edb5 100644 --- a/README.md +++ b/README.md @@ -168,9 +168,12 @@ track neither. HyDE-managed customization goes via: - `gui/.config/waybar/modules/*.jsonc` — module defs, globbed into the generated config. **The custom cava rig is NOT HyDE-managed**: `layouts/custom.jsonc` + `layouts/custom.css` form a -self-contained two-bar config launched raw (`waybar -c …custom.jsonc -s …custom.css` — exec-once, -the Ctrl+Alt+W / Super+Shift+R binds, and the `wbar` abbr). **Never route it through -`hyde-shell waybar --set`** — waybar.py would clobber the repo's files as above. HyDE only reads +self-contained two-bar config run by `wbar` (`gui/.local/bin/wbar`) as the supervised systemd +user unit `dots-waybar.service` (`Restart=always` — waybar 0.15.0 dies on every DP-2 link +drop/return, and as a bare process nothing relaunched it). The `exec-once` in `userprefs.conf`, +the Ctrl+Alt+W / Super+Shift+R binds and the `wbar` command all go through it; **never `killall +waybar`** — systemd brings it back in seconds and a raw relaunch beside it means two bars +(`wbar toggle|restart|reload|stop`). **Never route it through `hyde-shell waybar --set`** — waybar.py would clobber the repo's files as above. HyDE only reads from `layouts/`, which is what makes them safe to track. On a rig host, HyDE's own bar must be disabled once, or **two bars appear at every login** — and the launcher differs by HyDE vintage: diff --git a/common/.config/aichat/config.yaml b/common/.config/aichat/config.yaml index 92b60b3..f3c29df 100644 --- a/common/.config/aichat/config.yaml +++ b/common/.config/aichat/config.yaml @@ -1,3 +1,4 @@ +# NOTE: the lw/ and wm/ host overlays carry full-file copies of this config — mirror changes there. # see https://github.com/sigoden/aichat/blob/main/config.example.yaml keybindings: vi editor: nvim @@ -22,34 +23,35 @@ clients: name: local api_base: http://192.168.0.204:11343/v1 models: &lan_models + # Speeds are benched decode t/s (clean-night sweeps) — see fl/.config/llamacpp/README.md roster. - name: Qwen3-Coder-30B-Instruct-UD-Q3_K_XL - max_input_tokens: 30000 # ctx 32768 + max_input_tokens: 30000 # ctx 32768 · ~30 t/s — main agent coder - name: Qwen3-Coder-Next-UD-IQ3_XXS - max_input_tokens: 128000 # ctx 131072 + max_input_tokens: 128000 # ctx 131072 · ~16 t/s — long sessions (128k) - name: Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS - max_input_tokens: 22000 # ctx 24576 + max_input_tokens: 22000 # ctx 24576 · ~36 t/s — daily driver supports_vision: true - name: Qwen3.6-35B-A3B-Thinking - max_input_tokens: 22000 # ctx 24576 + max_input_tokens: 22000 # ctx 24576 · ~39 t/s — hard problems supports_vision: true - name: Qwen3.5-9B-UD-Q6_K_XL - max_input_tokens: 30000 # ctx 32768 + max_input_tokens: 30000 # ctx 32768 · ~32 t/s — quick tasks supports_vision: true - name: Qwen3.8-27B-UD-IQ3_XXS - max_input_tokens: 22000 # ctx 24576 + max_input_tokens: 22000 # ctx 24576 · ~15 t/s — hybrid reasoner, quiet desktop only supports_vision: true - name: gemma-4-26B-A4B-it-UD-IQ4_XS - max_input_tokens: 22000 # ctx 24576 + max_input_tokens: 22000 # ctx 24576 · ~34 t/s — quality generalist, best vision supports_vision: true - name: gemma-4-E4B-it-UD-Q8_K_XL - max_input_tokens: 62000 # ctx 65536 + max_input_tokens: 62000 # ctx 65536 · ~57 t/s — fast generalist, long docs supports_vision: true - name: GLM-4.7-Flash-UD-Q4_K_XL - max_input_tokens: 22000 # ctx 24576 + max_input_tokens: 22000 # ctx 24576 · ~21 t/s — quality coder - name: gpt-oss-20b - max_input_tokens: 62000 # ctx 65536 + max_input_tokens: 62000 # ctx 65536 · ~38 t/s — fast reasoning + tools - name: gpt-oss-20b-low - max_input_tokens: 62000 # ctx 65536 + max_input_tokens: 62000 # ctx 65536 · ~37 t/s — snappy answers # Remote access via duskadiy.com (reachable from anywhere; requires an API key — # $DUSKADIY_API_KEY, from the gitignored conf.d/secrets.fish). The LAN endpoint above is keyless. # Same server, same model ids — reuse the list above via a YAML anchor. diff --git a/common/.config/claude/CLAUDE.md b/common/.config/claude/CLAUDE.md index 38c82f0..1ef5bff 100644 --- a/common/.config/claude/CLAUDE.md +++ b/common/.config/claude/CLAUDE.md @@ -8,14 +8,20 @@ - The user self-hosts models on the LAN at `192.168.0.204`: - llama.cpp server — `http://192.168.0.204:11343/v1` (OpenAI-compatible). - Ollama — `192.168.0.204:11434`. -- Recurring models: Qwen3-Coder-30B, gemma-4-26B, DeepSeek-Coder-V2-Lite, GLM-4.7-Flash. When configuring any AI/agent tool, default to these endpoints rather than a cloud API unless asked otherwise. +- The available models are the section names in `~/.dots/fl/.config/llamacpp/config.ini` (the router's client model ids) — read that file rather than trusting any list written here; the roster changes often. When configuring any AI/agent tool, default to these endpoints rather than a cloud API unless asked otherwise. ## Git -- **Do not run `git commit` or `git push`, and never offer or ask to do the committing/pushing yourself** — the user reviews and commits entirely manually (these are also denied in settings.json). Stage/prepare changes and stop there. You may suggest a commit message for the user to use, but nothing more. -- To hand off a suggested commit message, write it to the per-worktree file `$(git rev-parse --git-path CLAUDE_COMMIT_MSG)` — **not** `COMMIT_EDITMSG` (git overwrites that on `git commit`). A `prepare-commit-msg` hook prefills the editor from it (one-shot) on the next `git commit` / lazygit `C`. Just the raw message, no `#` comments. +- **Do not run `git commit` or `git push`, and never offer or ask to do the committing/pushing yourself** — the user reviews and commits entirely manually (denied in settings.json and enforced by the `~/.claude/hooks/git-guard.py` PreToolUse hook, which catches `git -C … push`-style spellings the prefix rules miss and also blocks history rewrites and working-tree discards — `revert`, `cherry-pick`, `rebase`, `merge`, `reset` that moves HEAD or unstages everything, `restore `, `checkout --`, `stash drop/pop/clear`, `branch -D` — plus `gh pr merge/create/close/edit`; several sessions share this checkout, so never discard changes you did not make). Stage/prepare changes and stop there. You may suggest a commit message for the user to use, but nothing more. +- To hand off a suggested commit message, feed it to `~/.config/git/claude-commit-msg save` **after staging** (it records the staged paths with the message), as a quoted heredoc so quotes and backticks in the body survive: + ```sh + ~/.config/git/claude-commit-msg save <<'EOF' + + EOF + ``` + Never write `CLAUDE_COMMIT_MSG` or `COMMIT_EDITMSG` directly — several sessions share this checkout, and the single file got overwritten. A `prepare-commit-msg` hook prefills the next `git commit` / lazygit `C` with the message(s) whose recorded paths are part of that commit, then consumes them. Just the raw message, no `#` comments. `claude-commit-msg list` shows what is pending. ## Formatting - Lua: format with `stylua` (2-space indent, 120 col, no call parentheses — see any `.stylua.toml`). ## Dotfiles -- Configs live in the dots repo under `.config/` and are surfaced via per-directory symlinks into `~/.config/` (and `~/.claude/`). Editing a file under the repo's `.config/` is editing the live config — no deploy step. +- Configs live in the dots repo under `.config/` and are surfaced via **per-file** symlinks (GNU Stow `--no-folding`; directories stay real) into `~/.config/`, and from there into `~/.claude/` by `~/.config/claude/link.sh`. Editing an existing file under the repo's `.config/` is editing the live config — no deploy step. A **new** file is not live until a re-stow creates its symlink (`cd ~/.dots && ./install.sh`), on each host. diff --git a/common/.config/claude/README.md b/common/.config/claude/README.md index 9ec13f9..87059de 100644 --- a/common/.config/claude/README.md +++ b/common/.config/claude/README.md @@ -7,20 +7,65 @@ back into this repo. ## What's tracked here -- `settings.json` — global settings: permissions (deny git commit/push + read of secret - files), default `model`, `enabledPlugins`, vim mode, dark theme, `effortLevel`, fullscreen - TUI, `statusLine`, `hooks`, `worktree` defaults, … +- `settings.json` — global settings: permissions (deny git commit/push, `gh pr` write ops, and + reads of secret files), default `model`, `enabledPlugins`, vim mode, dark theme, `effortLevel`, + fullscreen TUI, `statusLine`, `hooks`, `worktree` defaults, a generic `autoMode.environment`, … + Nothing in here may name a specific project, path or remote: this file is published by the + public mirror, and the auto-mode classifier only reads `autoMode` from user/managed settings + anyway (never from a project's `.claude/settings*.json`). - `statusline.py` — rich status line (dir, git, model, context %, cost, disk — system metrics live in the tmux bar). See [StatusBar.md](StatusBar.md) for what each segment and colour means. -- `keybindings.json` — custom keybindings (vim-style scroll/navigation). +- `keybindings.json` — custom keybindings: vim-style scroll/navigation, plus `ctrl+x v` for the + verbose transcript view (`app:toggleTranscript`, same keystroke as opencode's `v`; the + default `ctrl+o` still works, and `ctrl+e` inside the view expands everything). - `CLAUDE.md` — global user preferences applied to every project. - `hooks/format.py` — PostToolUse hook: formats edited files by extension (stylua / ruff / - prettier / gofmt / rustfmt / fish_indent / shfmt / taplo). Opinionated formatters (stylua, ruff, - prettier, taplo) only run where the project opts in via a config file found walking up; gofmt and - rustfmt gate on a project marker (`go.mod` / `Cargo.toml`); fish_indent and shfmt run on sight. - No-ops if the formatter isn't installed, and always exits 0. + prettier / gofmt / rustfmt / fish_indent / shfmt / taplo). Every formatter is opt-in: stylua, + ruff, prettier and taplo run only where the project has their config file (found walking up); + gofmt and rustfmt gate on a project marker (`go.mod` / `Cargo.toml`); fish_indent and shfmt gate + on an `.editorconfig` *section* that matches the file. Wired to `Edit|Write` **and** `Bash` — + under auto mode Claude edits through the shell, so the Bash branch scans the command for + path-like tokens and formats the ones modified in the last 30 s. No-ops if the formatter isn't + installed, and always exits 0. +- `hooks/git-guard.py` — PreToolUse hook on `Bash`: structurally blocks anything that commits, + rewrites history or discards working-tree changes — `git commit` / `push` / `revert` / + `cherry-pick` / `rebase` / `merge` (not `--abort`), `clean` / `reset` in any form that moves HEAD + or unstages everything (`reset -- `, `reset HEAD ` stay allowed) / `restore ` + (`--staged ` is fine, `--staged .` is not) / `checkout --` / `checkout .` / `stash drop|pop|clear` / + `branch -D|-f|-M`, and `gh pr merge` / `create` / `close` / `edit` — wherever the flags sit + (`git -C . push`, `/usr/bin/git push`, `sh -c 'git push'`), which the prefix-matched deny rules + can't. The discard group is what protects parallel sessions sharing one checkout. Exit 2 + a + one-line reason. Heredoc bodies are skipped on purpose (file contents). Regression matrix: + `bash ~/.claude/hooks/git-guard-test.sh` (a file on purpose — typed inline, the commands under + test would trip the live hook). +- `hooks/notify.py` — Notification hook (`idle_prompt|permission_prompt`): `notify-send` desktop + notification when Claude is waiting on you; no-op on a headless host. +- `hooks/drift-check.sh` — SessionStart + UserPromptSubmit hook, read-only: fails loudly when a + `~/.config/claude/*` file has broken out of the repo into a plain file, or `~/.claude/*` no longer + resolves into the dots repo. That is what Claude Code's atomic settings rewrite does through the + stow layer — on 2026-09-15 it silently reverted the live config to a stale snapshot for an hour, + dropping four hook events. At session start the warning is shown to you (exit 2, session goes + on); on each prompt any drift is fed to Claude as context so it reports it. Fix is still + `link.sh` (heals identical copies, warns otherwise). +- `hooks/local-commands.py` — UserPromptExpansion hook: **TUI-side slash commands**. When you type + `/x` and `skills/x/local.py` exists, the hook runs it with the arguments, shows its output to you + and blocks the expansion (exit 2), so no prompt reaches the model and it costs nothing. Skills + without a `local.py` expand as usual. Caveat baked into the design: the hook fires *after* the + skill body expanded, so a skill with a `local.py` must not also act via a `!` inline command. - `skills/` — custom [Agent Skills](https://code.claude.com/docs/en/skills). See **Skills** below. - `link.sh` — idempotent bootstrap that creates the `~/.claude` symlinks below. +- `../git/claude-commit-msg` + `../git/hooks/prepare-commit-msg` — the commit-message handoff. + A session pipes its suggestion into `claude-commit-msg save`, which stores it as its own file under + `$(git rev-parse --git-path CLAUDE_COMMIT_MSG.d)/` **with the paths it staged**; on the next + `git commit` / lazygit `C` the hook prefills the editor with the message(s) whose paths are part of + that commit and consumes them (`used/`). Parallel sessions therefore never overwrite each other, + and committing one session's files gets that session's message. `claude-commit-msg list` shows + what is pending. The old single `CLAUDE_COMMIT_MSG` file is still honoured. Both tracked here + (deployed to `~/.config/git/`); the hook is installed **per repo** — see setup step 4 — and + deliberately not via a global `core.hooksPath`, which would override a project's own hooks. + +The `settings.json` hook commands for the newer scripts are wrapped in `[ -f … ]`, so a host that +has pulled the repo but not yet re-stowed just skips them instead of blocking every Bash call. Paths inside `settings.json` reference `~/.claude/...` via `$HOME`/`PATH` (`/usr/bin/env python3 ~/.claude/...`), so they work regardless of the username, python location, or where the @@ -43,7 +88,13 @@ claude # 3. Reinstall the plugins from settings.json -> enabledPlugins # (typescript-lsp, frontend-design — anthropics/claude-plugins-official) via /plugin. -# Optional: install any formatters you want the hook to use (stylua, ruff, prettier, …). +# Optional: install any formatters you want the hook to use (stylua, ruff, prettier, …), +# and libnotify (notify-send) on GUI hosts for hooks/notify.py. +``` + +```fish +# 4. In every repo you commit in: install the one-shot commit-message prefill hook +ln -sf ~/.config/git/hooks/prepare-commit-msg (git rev-parse --git-path hooks)/prepare-commit-msg ``` `install.sh` deploys `~/.config/claude` (via stow) and then runs `link.sh` automatically. You only @@ -73,6 +124,13 @@ so Claude never auto-triggers them. Run them inside the repo whose branch you're - **`/commit-msg [hint]`** — reads the **staged** diff, infers the repo's commit style from recent `git log` (e.g. this repo's `[Scope] summary`), drafts a matching message, prints it, and copies it to the clipboard with `wl-copy`. Never stages or commits — draft only. +- **`/verbose [on|off|status]`** — flips the *persisted* verbose view for the current project: + `viewMode` (`verbose` / `default` — the key that actually decides the view and beats a sticky + `/focus` choice for new sessions) plus `verbose` and `showThinkingSummaries`, written to the + project's `.claude/settings.local.json` (machine-local, never committed) rather than the tracked + `settings.json`, so a toggle never shows up as a dots diff. Runs **TUI-side** — `local.py` via + `hooks/local-commands.py`, no model turn — with the SKILL.md body as the fallback on a host + where the hook isn't deployed. The *live* toggle is a keypress: `ctrl+x v` / `ctrl+o`. - **`/pr-loop [pr]`** — babysits the current branch's PR in a self-paced loop: fixes review comments that don't need your input — from **any** reviewer (Codex, Claude, humans) plus any `@claude` request — `git add`s them, drafts the commit message (`/commit-msg` style) and marks diff --git a/common/.config/claude/StatusBar.md b/common/.config/claude/StatusBar.md index 9a77547..4e0420c 100644 --- a/common/.config/claude/StatusBar.md +++ b/common/.config/claude/StatusBar.md @@ -8,7 +8,7 @@ resize), trailing (lowest-priority) segments are dropped from the right until it Wide — everything: ``` -📁 ~/.dots │ 🌿 .dots main* │ 🤖 Fable 5 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h │ 💾 81% +📁 ~/.dots │ 🌿 .dots main* │ 🤖 Fable 5 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h │ ♻️ 91% 1h │ 💾 81% ``` Narrow — trailing segments trimmed: @@ -29,9 +29,10 @@ is supplementary to the main colored value. | 📁 | **Directory** | Current working dir, with `~` for home | cyan | | 🌿 | **Git** | `repo branch` + `*` if dirty + `↑N`/`↓N` ahead/behind upstream | repo = magenta; branch = green when clean, yellow + red `*` when dirty; `↑` cyan, `↓` yellow. Omitted outside a repo | | 🤖 | **Model** | Active model display name + `· effort` level (`low`/`medium`/`high`/`xhigh`) | name = blue, effort = light gray; effort omitted for models without the param | -| 📝 | **Context** | `% of context window used` + `(Nk)` tokens | window comes from the payload's `context_window.context_window_size`, falling back to `(1M …)` in the display name, else 200k — **not** from `model.id`, which arrives with any `[1m]` suffix already stripped. Adds a red **⚠compact** at ≥80% | +| 📝 | **Context** | `% of context window used` + `(Nk)` tokens | usage comes from the payload's `context_window` (`used_percentage` + `current_usage`, Claude Code 2.1.x); the transcript's last `message.usage` is only a fallback. Window from `context_window.context_window_size`, then `(1M …)` in the display name, else 200k — **not** from `model.id`, which arrives with any `[1m]` suffix already stripped. Adds a red **⚠compact** at ≥80% | | 📊 | **5h usage** | `% of the 5-hour rolling limit used` + time until it resets | from `rate_limits.five_hour`; Pro/Max only, and absent until the first API response of a session | | 💰 | **Cost** | `$` session cost so far + `$/h` burn rate | burn rate shown once the session exceeds ~30s | +| ♻️ | **Prompt cache** | hit `%` (higher is better) + cache TTL (`5m` / `1h`), yellow `·cold` once it has expired | from the payload's `prompt_cache` (2.1.260+: `hit_ratio` 0–1, `ttl`, `warm`); computed from usage on older payloads. A `1h` TTL is what keeps a 10-min `/pr-loop` cadence warm | | 💾 | **Disk** | `% used` of the filesystem at the cwd | from `statvfs` | ## Colour legend @@ -43,6 +44,7 @@ attention. Light gray = secondary detail. Thresholds (`green < … < yellow < |---|---|---|---| | Context | `< 50%` | `50–80%` | `≥ 80%` (⚠compact) | | 5h usage | `< 50%` | `50–80%` | `≥ 80%` | +| Prompt cache (inverted) | `≥ 80%` | `50–80%` | `< 50%` | | Disk | `< 75%` | `75–90%` | `≥ 90%` | > Light gray is `\033[37m`; dimming (`\033[2m`) is disabled because it blended the gray @@ -64,7 +66,6 @@ events, so system metrics sat visibly stale between turns — the tmux status ba | 🖥️ | `cpu_segment` | `% busy` (diffed `/proc/stat` snapshot) + loadavg + cores (green `<60%` / red `≥85%`) | | 🌡️ | `temp_segment` | hottest CPU thermal zone °C (green `<60` / red `≥80`) | | ✏️ | `velocity_segment` | `+added/-removed` lines this session + lines/min | -| ♻️ | `cache_segment` | prompt-cache hit % (higher is better) | | ⚙️ | `api_segment` | share of wall-clock time spent in API/inference | | | `version_segment` | Claude Code version (`vX.Y.Z`) | | 🎨 | `style_segment` | active output-style name | diff --git a/common/.config/claude/hooks/drift-check.sh b/common/.config/claude/hooks/drift-check.sh new file mode 100755 index 0000000..59b1050 --- /dev/null +++ b/common/.config/claude/hooks/drift-check.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# SessionStart hook: refuse to let a severed dotfiles link go unnoticed. +# +# Claude Code writes settings with an atomic temp+rename. Through the stow layer that can +# replace ~/.config/claude/ (a symlink into the repo) with a plain FILE holding whatever +# snapshot the writing process had in memory -- silently freezing every later repo edit out +# of the live config. It happened on 2026-09-15 23:31: a stale copy dropped four hook events +# (git-guard among them) and nobody noticed for an hour. link.sh heals the identical case but +# only when run; this makes every session start say so instead. +# +# Read-only: it never relinks. Exit 2 on SessionStart shows stderr to the user and the session +# continues. Exit 0 when everything resolves into the repo. +set -u +src="$HOME/.config/claude" +bad="" +for f in settings.json statusline.py keybindings.json CLAUDE.md link.sh; do + p="$src/$f" + [ -e "$p" ] || continue + if [ ! -L "$p" ]; then bad="$bad $p is a plain file (should be a symlink into the dots repo)"$'\n'; fi +done +# the ~/.claude side too: link.sh's own guard covers it, but only when link.sh runs +for f in settings.json keybindings.json CLAUDE.md statusline.py; do + p="$HOME/.claude/$f" + [ -e "$p" ] || continue + case "$(readlink -f "$p")" in + *"/.dots/"*) ;; + *) bad="$bad $p resolves to $(readlink -f "$p"), not into the dots repo"$'\n' ;; + esac +done +[ -z "$bad" ] && exit 0 +printf 'DRIFT: Claude config has broken out of the dots repo; the live config is a stale copy.\n%s fix: review with `diff`, then `bash ~/.config/claude/link.sh` (heals identical copies, warns otherwise)\n' "$bad" >&2 +exit 2 diff --git a/common/.config/claude/hooks/format.py b/common/.config/claude/hooks/format.py index b0efcaa..8dc6ed0 100755 --- a/common/.config/claude/hooks/format.py +++ b/common/.config/claude/hooks/format.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """PostToolUse hook: format an edited file with the right formatter. -Reads the hook JSON from stdin, extracts the edited file path, and runs a +Reads the hook JSON from stdin, works out which file(s) were just edited, and runs a language-appropriate formatter -- but, to avoid imposing a style on a project that never asked for it, every formatter here only runs when the project opts in via a config file discoverable by walking up from the edited file: stylua, ruff, @@ -9,6 +9,15 @@ prettier and taplo on their own configs, gofmt and rustfmt on go.mod / Cargo.tom shfmt and fish_indent on .editorconfig. No marker, no formatting -- a repo that hand-formats its shell keeps its own layout. +Two entry points, both wired in settings.json > hooks > PostToolUse: + * Edit|Write -- the edited path is `tool_input.file_path`. + * Bash -- under `defaultMode: auto` Claude is steered to edit through the shell + (sed -i, heredocs, python), which the Edit|Write matcher never sees. + There is no per-file event for that (`FileChanged` only watches literal + filenames), so this scans `tool_input.command` for path-like tokens with a + handled extension and formats the ones modified in the last few seconds -- + the mtime gate keeps a parallel session's dirty files out of it. + Best-effort: always exits 0 so a format hiccup never blocks an edit. jq is not guaranteed on the host, hence python for the stdin JSON parse. """ @@ -19,6 +28,7 @@ import re import shutil import subprocess import sys +import time def find_up(start, names): @@ -178,14 +188,13 @@ HANDLERS = { } -def main(): - try: - data = json.load(sys.stdin) - except Exception: - return - ti = data.get("tool_input") or {} - tr = data.get("tool_response") or {} - path = ti.get("file_path") or tr.get("filePath") or "" +RECENT_SECS = 30 # a Bash edit is "ours" if the file changed this recently + +_EXT_ALT = "|".join(re.escape(e[1:]) for e in HANDLERS) +_PATHISH = re.compile(r"[\w.~/@+-]+\.(?:" + _EXT_ALT + r")\b") + + +def format_one(path): if not path or not os.path.isfile(path): return # Resolve symlinks before walking for markers: deployed dotfiles get edited via @@ -204,5 +213,42 @@ def main(): pass +def recent_paths_in_command(command, cwd): + """Files a shell command plausibly just wrote: path-like tokens with a handled + extension, resolved against the tool's cwd, that exist and changed within RECENT_SECS. + Unchanged mentions (the source of a `cp`, a file merely grepped) fail the mtime gate.""" + now = time.time() + seen, out = set(), [] + for tok in _PATHISH.findall(command or ""): + p = os.path.expanduser(tok) + if not os.path.isabs(p): + p = os.path.join(cwd, p) + p = os.path.normpath(p) + if p in seen: + continue + seen.add(p) + try: + if os.path.isfile(p) and now - os.path.getmtime(p) <= RECENT_SECS: + out.append(p) + except OSError: + pass + return out + + +def main(): + try: + data = json.load(sys.stdin) + except Exception: + return + ti = data.get("tool_input") or {} + tr = data.get("tool_response") or {} + if data.get("tool_name") == "Bash": + cwd = data.get("cwd") or os.getcwd() + for p in recent_paths_in_command(ti.get("command"), cwd): + format_one(p) + return + format_one(ti.get("file_path") or tr.get("filePath") or "") + + if __name__ == "__main__": main() diff --git a/common/.config/claude/hooks/git-guard-test.sh b/common/.config/claude/hooks/git-guard-test.sh new file mode 100755 index 0000000..f430e24 --- /dev/null +++ b/common/.config/claude/hooks/git-guard-test.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Regression matrix for git-guard.py: `bash ~/.claude/hooks/git-guard-test.sh` (exit 1 on mismatch). +# Kept as a file on purpose: typed inline, the very commands under test would trip the live hook. +G="$(dirname "$(readlink -f "$0")")/git-guard.py"; fail=0 +# `git reset ` is allowed only for an existing path: give the matrix one to point at. +tmp=$(mktemp -d) && cd "$tmp" && touch existing.py +trap 'rm -rf "$tmp"' EXIT +run() { printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$2")}}" | python3 "$G" 2>/dev/null; rc=$? + got=allow; [ $rc -eq 2 ] && got=block; [ "$got" = "$1" ] && mark=ok || { mark=MISMATCH; fail=1; }; printf '%-8s %-6s %-6s %s\n' "$mark" "$1" "$got" "$(printf '%s' "$2" | head -1)"; } +echo "--- must block: commits / history ---" +run block 'git push' +run block 'git -C /home/coja/.dots push origin main' +run block 'git -c user.name=x commit -m fix' +run block '/usr/bin/git push' +run block 'env GIT_DIR=.git git push' +run block 'timeout 5 git push' +run block 'sudo -u me git push' +run block 'cd /tmp && git push' +run block 'git status; git commit -am wip' +run block 'x=1; git push' +run block 'git push 2>&1 | tee log' +run block "sh -c 'git push origin HEAD'" +run block 'eval "git push"' +run block 'git -C $(pwd) push' +run block 'echo "$(git push)"' +run block 'git --git-dir=/x/.git --work-tree=/x push' +run block 'git commit -m "fix: grep pattern; not a command"' +run block 'git revert HEAD' +run block 'git cherry-pick abc123' +run block 'git rebase -i HEAD~3' +run block 'git rebase main' +run block 'git merge feature' +run block 'git merge --no-ff feature' +echo "--- must block: discards working-tree changes ---" +run block 'git reset --hard HEAD~1' +run block 'git reset HEAD~1 --hard' +run block 'git reset --soft HEAD~1' +run block 'git reset --keep HEAD~2' +run block 'git reset --merge' +run block 'git reset' +run block 'git reset -q' +run block 'git reset HEAD' +run block 'git reset HEAD~1' +run block 'git reset abc1234' +run block 'git reset 0123456789abcdef0123456789abcdef01234567' +run block 'git reset origin/main' +run block 'git reset @{u}' +run block 'git restore --staged .' +run block 'git restore --staged :/' +run block 'git clean -fd' +run block 'git -C . clean -n' +run block 'git restore file.py' +run block 'git restore --staged --worktree file.py' +run block 'git restore -W file.py' +run block 'git checkout -- .' +run block 'git checkout -- file.py' +run block 'git checkout HEAD -- file.py' +run block 'git checkout .' +run block 'git checkout -f main' +run block 'git stash drop' +run block 'git stash pop' +run block 'git stash clear' +run block 'git branch -D feature' +run block 'git branch -f main HEAD~1' +run block 'git branch -M main trunk' +echo "--- must block: gh pr write ops ---" +run block 'gh pr merge 12 --squash' +run block 'gh -R o/r pr create --fill' +run block 'gh pr edit 3 --title x' +run block 'gh pr close 4' +echo "--- must allow ---" +run allow 'git status' +run allow 'git log -1 --oneline' +run allow 'git diff --cached --stat' +run allow 'git add -A common/.config/claude' +run allow 'git rev-parse --git-path CLAUDE_COMMIT_MSG' +run allow 'git -c core.pager=cat log -3' +run allow 'git reset -- file.txt' +run allow 'git reset HEAD file.txt' +run allow 'git reset HEAD existing.py other.py' +run allow 'git reset existing.py' +run allow 'git reset -p' +run allow 'git reset -- .' +run allow 'git restore --staged existing.py' +run allow 'git restore --staged file.py' +run allow 'git restore -S file.py' +run allow 'git checkout main' +run allow 'git checkout -b feature' +run allow 'git switch main' +run allow 'git merge --abort' +run allow 'git stash' +run allow 'git stash list' +run allow 'git stash push -m wip' +run allow 'git stash show -p' +run allow 'git branch -d merged-branch' +run allow 'git branch --list' +run allow 'git branch -vv' +run allow 'git commit-tree HEAD^{tree} -m x' +run allow 'git worktree list' +run allow 'grep -rn "git push" README.md' +run allow 'grep -n "foo; git commit" file' +run allow "echo 'git push'" +run allow 'echo "run git push"' +run allow "cat > README.md <<'EOF' +Deploy with: + git status; git commit -am x; git push origin main +EOF +echo written" +run allow 'python3 - <`, + `reset HEAD ` and `reset ` are fine) | restore (--staged + alone is fine, except `--staged .` / `:/`) | checkout -- / checkout . / -f + git stash drop | pop | clear git branch -D / -f / -M + gh pr merge | create | close | edit + +i.e. anything that commits, rewrites history, or discards working-tree changes -- the last +group matters most with several sessions sharing one checkout. Mirrors pi's permission-gate. + +Exit 2 + one stderr line = the call is blocked and Claude reads the reason (stage and +stop; /commit-msg drafts the message). Mirrors pi's permission-gate.ts. + +Tokenizing is quote-aware first (shlex in punctuation mode), and only then split at the +separators `;`, `|`, `&&`, `(`, `)`: a quoted word that merely *contains* a separator and a +git verb (a grep pattern, a commit message) stays one word and is not a command. `$(...)` +and backtick bodies are scanned on their own and then collapsed, so `git -C $(pwd) push` +still parses as a push. `sh -c '...'` and `eval` bodies recurse. + +Deliberate gaps: heredoc bodies are skipped -- they are file/script *contents*, and a +README that documents pushing must stay writable -- so a script piped in through a heredoc +is not inspected. Aliases (`git ci`) are not expanded. The deny list, the auto-mode +classifier and the global CLAUDE.md still cover those; this is the belt for the prefix +rules' braces. Fails open: any parse error lets the command through (exit 0). +""" +import json +import os +import re +import shlex +import sys + +GIT_BLOCK = {"commit", "push", "clean", "revert", "cherry-pick", "rebase"} +MERGE_OK = {"--abort", "--quit", "--continue"} +STASH_BLOCK = {"drop", "pop", "clear"} +BRANCH_FORCE = {"-D", "-f", "--force", "-M"} +RESET_MODES = {"--hard", "--soft", "--merge", "--keep"} +UNSTAGE_ALL = {".", ":/", "*", ":"} +_HEX = re.compile(r"^[0-9a-f]{7,40}$") + + +def looks_like_ref(a): + return (a in ("HEAD", "ORIG_HEAD", "FETCH_HEAD", "MERGE_HEAD") or a.startswith("refs/") + or "~" in a or "^" in a or "@{" in a or bool(_HEX.match(a))) +GH_PR_BLOCK = {"merge", "create", "close", "edit"} +# global options that take a *separate* argument; the `--opt=value` spelling is one token. +GIT_OPT_ARG = {"-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace", + "--super-prefix", "--config-env"} +GH_OPT_ARG = {"-R", "--repo"} +SHELLS = {"sh", "bash", "dash", "zsh", "ksh", "fish"} + +# `<<'EOF' ... EOF` / `<<-EOF ... EOF`: drop the body, keep the command around it. +_HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1[^\n]*\n.*?\n[ \t]*\2[ \t]*(?=\n|$)", re.S) +# innermost `$(...)` / `...` command substitutions (peeled repeatedly for nesting). +_SUBST = re.compile(r"\$\(([^()]*)\)|`([^`]*)`") +# tokens that end one simple command and start the next. +_SEP = {";", ";;", "|", "||", "|&", "&", "&&", "(", ")"} + + +def _skip_opts(args, with_arg): + j = 0 + while j < len(args) and args[j].startswith("-"): + j += 2 if args[j] in with_arg else 1 + return j + + +def git_reason(args): + j = _skip_opts(args, GIT_OPT_ARG) + if j >= len(args): + return None + sub, rest = args[j], args[j + 1:] + if sub in GIT_BLOCK: + return f"git {sub}" + if sub == "reset": + opts = [a for a in rest if a.startswith("-") and a != "--"] + if RESET_MODES & set(opts): + return "git reset --hard/--soft/--merge/--keep (moves HEAD)" + if "-p" in opts or "--patch" in opts or "--" in rest: + return None # interactive, or the explicit path form + pos = [a for a in rest if not a.startswith("-")] + if not pos or pos == ["HEAD"]: + return "git reset (unstages everything, other sessions' rounds included)" + if len(pos) == 1 and (looks_like_ref(pos[0]) or not os.path.exists(pos[0])): + return "git reset (moves HEAD)" + return None # `reset HEAD ` / `reset ` + if sub == "merge" and not (MERGE_OK & set(rest)): + return "git merge (creates a commit)" + if sub == "restore": + staged = any(a in ("--staged", "-S") for a in rest) + worktree = any(a in ("--worktree", "-W") for a in rest) + if not staged or worktree: + return "git restore (discards working-tree changes)" + if UNSTAGE_ALL & {a for a in rest if not a.startswith("-")}: + return "git restore --staged . (unstages everything, other sessions' rounds included)" + if sub == "checkout" and ("--" in rest or "." in rest or "-f" in rest or "--force" in rest): + return "git checkout -- (discards working-tree changes)" + if sub == "stash" and rest and rest[0] in STASH_BLOCK: + return f"git stash {rest[0]}" + if sub == "branch" and BRANCH_FORCE & set(rest): + return "git branch -D/-f (force-deletes or force-moves a branch)" + return None + + +def gh_reason(args): + j = _skip_opts(args, GH_OPT_ARG) + if j >= len(args) or args[j] != "pr": + return None + rest = args[j + 1:] + k = _skip_opts(rest, GH_OPT_ARG) + if k < len(rest) and rest[k] in GH_PR_BLOCK: + return f"gh pr {rest[k]}" + return None + + +def tokenize(text): + """Quote-aware shell words; the separator characters come out as their own tokens + (shlex punctuation mode). Comment handling is off: a `#` mid-word must not swallow + the rest of the line.""" + lex = shlex.shlex(text, posix=True, punctuation_chars=True) + lex.whitespace_split = True + lex.commenters = "" + try: + return list(lex) + except ValueError: # unbalanced quotes: best effort + return text.split() + + +def segments(toks): + seg = [] + for t in toks: + if t in _SEP: + if seg: + yield seg + seg = [] + else: + seg.append(t) + if seg: + yield seg + + +def scan(text, depth=0): + """Reason string for the first blocked invocation in `text`, else None.""" + if depth > 4: + return None + text = _HEREDOC.sub(" ", text) + # command substitutions: scan each body on its own, then collapse it to a plain word so + # the enclosing command still parses (`git -C $(pwd) push` -> `git -C SUBST push`). + bodies = [] + + def _grab(m): + bodies.append(m.group(1) if m.group(1) is not None else m.group(2)) + return " SUBST " + + prev = None + while prev != text: + prev, text = text, _SUBST.sub(_grab, text) + for body in bodies: + r = scan(body, depth + 1) + if r: + return r + for toks in segments(tokenize(text)): + for i, tok in enumerate(toks): + base = os.path.basename(tok) + r = None + if base == "git": + r = git_reason(toks[i + 1:]) + elif base == "gh": + r = gh_reason(toks[i + 1:]) + elif base == "eval": + r = scan(" ".join(toks[i + 1:]), depth + 1) + elif base in SHELLS and "-c" in toks[i + 1:]: + k = toks.index("-c", i + 1) # `sh -c ''`: the real command is one word + if k + 1 < len(toks): + r = scan(toks[k + 1], depth + 1) + if r: + return r + return None + + +def main(): + try: + data = json.load(sys.stdin) + if data.get("tool_name", "Bash") != "Bash": + return + cmd = (data.get("tool_input") or {}).get("command") or "" + reason = scan(cmd) if cmd else None + except Exception: + return # fail open: the deny list still applies + if reason: + short = cmd.strip().splitlines()[0] + short = short if len(short) <= 80 else short[:77] + "..." + sys.stderr.write( + f"git-guard: blocked `{short}` ({reason}). The user commits, pushes and merges " + "manually (global CLAUDE.md): stage and stop, and draft the message with /commit-msg.\n" + ) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/common/.config/claude/hooks/local-commands.py b/common/.config/claude/hooks/local-commands.py new file mode 100755 index 0000000..3501519 --- /dev/null +++ b/common/.config/claude/hooks/local-commands.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""UserPromptExpansion hook: run a skill TUI-side, without a model turn. + +Custom slash commands in Claude Code are prompts -- typing `/x` expands skills/x/SKILL.md +and sends it to the model. For a command that is really just a local action (flip a +setting, print a state) that round trip is waste. This hook gives any skill a TUI-side +implementation: if `~/.claude/skills//local.py` exists, it is executed with +the command's arguments, its output is shown to you, and the expansion is blocked (exit 2), +so nothing reaches the model. Skills without a local.py expand as usual (exit 0). + +Order matters: the hook fires AFTER the skill body has been expanded, so any `!` inline +commands in that SKILL.md have already run by the time this executes. A skill with a +local.py must therefore not also apply its action via `!` -- it would happen twice. + +Input (stdin JSON): command_name, command_args (list), expanded_prompt, cwd, ... +Fails open: any error here means exit 0 and the normal expansion goes ahead. +""" +import json +import os +import subprocess +import sys + + +def main(): + try: + data = json.load(sys.stdin) + name = data.get("command_name") or "" + args = data.get("command_args") or [] + if isinstance(args, str): + args = args.split() + if not name or "/" in name or name.startswith("."): + return + script = os.path.expanduser(f"~/.claude/skills/{name}/local.py") + if not os.path.isfile(script): + return + r = subprocess.run( + [sys.executable, script, *[str(a) for a in args]], + capture_output=True, text=True, timeout=20, cwd=data.get("cwd") or None, + ) + except Exception: + return # fail open: let the skill expand normally + out = (r.stdout or "") + (r.stderr or "") + sys.stderr.write(out.rstrip("\n") + f"\n[/{name}: handled TUI-side by skills/{name}/local.py, no model turn]\n") + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/common/.config/claude/hooks/notify.py b/common/.config/claude/hooks/notify.py new file mode 100755 index 0000000..a3903a3 --- /dev/null +++ b/common/.config/claude/hooks/notify.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Notification hook: a desktop notification when Claude is waiting on you. + +settings.json wires it to `idle_prompt|permission_prompt` (the Notification matcher is the +`notification_type`). Push notifications are off in this setup, so this is the local +counterpart of pi's notify.ts. Uses notify-send where present (the Wayland/GUI hosts) and +silently no-ops on a headless box or without a session bus. Payload fields are read +defensively -- the docs do not pin the Notification input schema: `message`, `title`, +`notification_type`, `cwd`. +""" +import json +import os +import shutil +import subprocess +import sys + +TITLES = { + "idle_prompt": "Claude Code is waiting for you", + "permission_prompt": "Claude Code needs a permission", +} + + +def main(): + try: + data = json.load(sys.stdin) + except Exception: + return + exe = shutil.which("notify-send") + if not exe: + return + kind = data.get("notification_type") or "notification" + title = data.get("title") or TITLES.get(kind, "Claude Code") + body = (data.get("message") or "").strip() or kind + cwd = data.get("cwd") or "" + if cwd: + home = os.path.expanduser("~") + body += "\n" + (("~" + cwd[len(home):]) if cwd.startswith(home) else cwd) + try: + subprocess.run( + [exe, "--app-name=Claude Code", "--urgency=normal", "--expire-time=8000", + # same tag -> a newer notification replaces the previous one instead of stacking + f"--hint=string:x-canonical-private-synchronous:claude-code-{kind}", + title, body], + timeout=5, capture_output=True, + ) + except Exception: + pass + + +if __name__ == "__main__": + main() diff --git a/common/.config/claude/keybindings.json b/common/.config/claude/keybindings.json index 0852892..d867671 100644 --- a/common/.config/claude/keybindings.json +++ b/common/.config/claude/keybindings.json @@ -13,8 +13,7 @@ "bindings": { "alt+j": "scroll:lineDown", "alt+k": "scroll:lineUp", - "ctrl+shift+j": "scroll:lineDown", - "ctrl+shift+k": "scroll:lineUp" + "ctrl+x v": "app:toggleTranscript" } }, { diff --git a/common/.config/claude/settings.json b/common/.config/claude/settings.json index ca3fc8b..91f532a 100644 --- a/common/.config/claude/settings.json +++ b/common/.config/claude/settings.json @@ -13,6 +13,14 @@ "Bash(git reset --hard:*)", "Bash(git clean)", "Bash(git clean:*)", + "Bash(gh pr merge)", + "Bash(gh pr merge:*)", + "Bash(gh pr create)", + "Bash(gh pr create:*)", + "Bash(gh pr close)", + "Bash(gh pr close:*)", + "Bash(gh pr edit)", + "Bash(gh pr edit:*)", "Read(**/.env)", "Read(**/.env.*)", "Read(**/*.env)", @@ -30,15 +38,63 @@ "Read(**/.claude/.credentials.json)", "Read(**/secrets.fish)", "Read(**/*.token)", - "Read(**/.envrc)" + "Read(**/.envrc)", + "Read(**/.gnupg/**)", + "Read(**/fish_variables)" ], "defaultMode": "auto" }, - "model": "claude-fable-5[1m]", + "model": "claude-fable-5-1[1m]", "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "f=\"$HOME/.claude/hooks/drift-check.sh\"; if [ -f \"$f\" ]; then exec bash \"$f\"; fi", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "f=\"$HOME/.claude/hooks/drift-check.sh\"; if [ -f \"$f\" ]; then out=$(bash \"$f\" 2>&1); [ -n \"$out\" ] && printf \"%s\\n\" \"$out\"; fi; exit 0", + "timeout": 5 + } + ] + } + ], + "UserPromptExpansion": [ + { + "hooks": [ + { + "type": "command", + "command": "f=\"$HOME/.claude/hooks/local-commands.py\"; if [ -f \"$f\" ]; then exec /usr/bin/env python3 \"$f\"; fi", + "timeout": 25 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "f=\"$HOME/.claude/hooks/git-guard.py\"; if [ -f \"$f\" ]; then exec /usr/bin/env python3 \"$f\"; fi", + "timeout": 5 + } + ] + } + ], "PostToolUse": [ { - "matcher": "Edit|Write|MultiEdit", + "matcher": "Edit|Write", "hooks": [ { "type": "command", @@ -47,6 +103,28 @@ "statusMessage": "format" } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "/usr/bin/env python3 ~/.claude/hooks/format.py", + "timeout": 20 + } + ] + } + ], + "Notification": [ + { + "matcher": "idle_prompt|permission_prompt", + "hooks": [ + { + "type": "command", + "command": "f=\"$HOME/.claude/hooks/notify.py\"; if [ -f \"$f\" ]; then exec /usr/bin/env python3 \"$f\"; fi", + "timeout": 5 + } + ] } ] }, diff --git a/common/.config/claude/skills/commit-msg/SKILL.md b/common/.config/claude/skills/commit-msg/SKILL.md index 974869b..5d2eef4 100644 --- a/common/.config/claude/skills/commit-msg/SKILL.md +++ b/common/.config/claude/skills/commit-msg/SKILL.md @@ -2,7 +2,7 @@ description: Draft a commit message for the staged changes, matching this repo's commit style, then print it in the reply, save it to the repo's CLAUDE_COMMIT_MSG file, and copy it to the clipboard. Never stages or commits. Use once you've staged what you want to commit. argument-hint: [optional focus/scope hint] disable-model-invocation: true -allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(git rev-parse:*), Bash(wl-copy:*), Read, Glob, Write +allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(git rev-parse:*), Bash(wl-copy:*), Bash(~/.config/git/claude-commit-msg:*), Read, Glob --- # Draft a commit message for the staged changes @@ -29,14 +29,20 @@ Context for the current commit — what's staged, plus this repo's recent style: ready. This in-reply copy is the durable one: it lives in the transcript, so it survives even if the clipboard gets overwritten later (by me copying something else, or another Claude session running `wl-copy`). Always print it; don't rely on the clipboard alone. -5. **Save it to a file in the repo** so I can recover it any time: resolve the path with - `git rev-parse --git-path CLAUDE_COMMIT_MSG` (per-worktree correct), then use the Write tool to - write the raw message there — **not** `COMMIT_EDITMSG`, which git overwrites on every - `git commit` before the editor opens. My `prepare-commit-msg` hook prefills the commit editor - from `CLAUDE_COMMIT_MSG` (one-shot) on the next `git commit` / lazygit `C`. The file lives - inside the git dir so it never shows up in `git status`. -6. **Also copy it to the clipboard** as a convenience: `wl-copy < `. Then - tell me all three places it is: **printed above**, **saved to `CLAUDE_COMMIT_MSG`** (hook - prefills the next commit), and **on the clipboard** — so I never lose it to a stray copy. +5. **Hand it off** so I can recover it any time — feed the raw message to the CLI as a quoted + heredoc (so quotes, backticks and `$` in the body survive untouched): + ```sh + ~/.config/git/claude-commit-msg save <<'EOF' + + EOF + ``` + That stores it as its own file under `$(git rev-parse --git-path CLAUDE_COMMIT_MSG.d)/` **together with the staged paths + it describes**, so several sessions can hand off in parallel without overwriting each other. My + `prepare-commit-msg` hook prefills the next `git commit` / lazygit `C` with the message(s) whose + paths are part of that commit, then consumes them. Never write `CLAUDE_COMMIT_MSG` or + `COMMIT_EDITMSG` directly. The `save` output names the saved id and file path. +6. **Also copy it to the clipboard** as a convenience: `wl-copy < `. + Then tell me all three places it is: **printed above**, **saved for the hook** (with its id), + and **on the clipboard** — so I never lose it to a stray copy. 7. **Never** run `git add`, `git commit`, or `git push`, and never offer to — I stage and commit manually. Draft only. diff --git a/common/.config/claude/skills/pr-loop/SKILL.md b/common/.config/claude/skills/pr-loop/SKILL.md index 57a22a1..105ed35 100644 --- a/common/.config/claude/skills/pr-loop/SKILL.md +++ b/common/.config/claude/skills/pr-loop/SKILL.md @@ -2,7 +2,7 @@ description: Babysit the current branch's PR — address review comments that don't need my input, re-check every ~10 min, and stop when there are no open comments left or the automated reviewers (Codex, Claude) hit their limit. Prep-only — stages fixes and drafts the commit message; never commits, pushes, or merges. argument-hint: [pr number or url — optional; defaults to the current branch's PR] disable-model-invocation: true -allowed-tools: Bash(gh:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(git rev-parse:*), Bash(git fetch:*), Bash(git add:*), Bash(wl-copy:*), Read, Edit, Write, Grep, Glob, ScheduleWakeup +allowed-tools: Bash(gh:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(git rev-parse:*), Bash(git fetch:*), Bash(git add:*), Bash(wl-copy:*), Bash(~/.config/git/claude-commit-msg:*), Read, Edit, Write, Grep, Glob, ScheduleWakeup --- # Watch this PR and resolve review comments (prep-only) @@ -36,11 +36,12 @@ comments left or the automated reviewers (Codex/Claude) hit a usage limit. session stays open**. **To stop it: press `Esc` while I'm idle between cycles** (that clears the queued wake-up); closing the session also stops it. A plain message does **not** cancel a pending wake-up — use `Esc`. -- **Every token counts.** Each wake-up re-reads the whole conversation with a cold prompt cache - (the 10-min cadence outlives the 5-min cache TTL), so cost compounds with every cycle and every - extra line in context. Therefore: fetch **filtered** data only (use the `--jq` filters below — - never let raw unfiltered JSON into the conversation), summarize deltas rather than restating - unchanged state, and keep idle cycles to **one cheap API call and ≤2 lines of output**. +- **Every token counts.** Each wake-up re-sends the whole conversation. On a Claude subscription + the prompt cache holds for **1 hour**, so the 10-min cadence stays warm; on an API key it drops to + 5 min and every cycle is a cold read. Either way every extra line in context rides along on every + later cycle. Therefore: fetch **filtered** data only (use the `--jq` filters below — never let raw + unfiltered JSON into the conversation), summarize deltas rather than restating unchanged state, + and keep idle cycles to **one cheap API call and ≤2 lines of output**. Current state (auth + the current branch's PR): @@ -233,10 +234,11 @@ Current state (auth + the current branch's PR): 9. **Draft the commit message — exactly like `/commit-msg`:** from the staged diff and the repo's recent `git log` style (this repo uses `[Scope] summary`), write a message that matches. Then: - **(a) Print it** in your reply as a fenced ` ```text ` block — the durable copy. - - **(b) Write it** with the Write tool to the path from `git rev-parse --git-path CLAUDE_COMMIT_MSG` - (**not** `COMMIT_EDITMSG` — git overwrites that on `git commit`; the `prepare-commit-msg` hook - prefills the editor from `CLAUDE_COMMIT_MSG`). - - **(c) Copy it:** `wl-copy < `. + - **(b) Hand it off:** `~/.config/git/claude-commit-msg save <<'EOF'` … `EOF` (the message as a + quoted heredoc, so quotes and backticks survive) — it is stored with the paths you staged this cycle, so the `prepare-commit-msg` hook prefills it only + for the commit that contains them (other sessions' handoffs are not overwritten). Never write + `CLAUDE_COMMIT_MSG` / `COMMIT_EDITMSG` directly. + - **(c) Copy it:** `wl-copy < `. - Tell me all three locations. **Never** run `git commit`. (Skip this step when nothing new was staged this cycle.) @@ -252,9 +254,10 @@ Current state (auth + the current branch's PR): needs-input questions are posted (reactions don't bump `updatedAt`, comments do — so your own activity doesn't defeat the next cheap check), then save state to `/pr-loop-state.json`. Then schedule: - - **normal cycle:** `ScheduleWakeup(delaySeconds=600, prompt="/pr-loop", reason="recheck PR - # review comments")`; - - **idle cycle** (step 3 short-circuited): back off — `delaySeconds=1200`, I'm clearly away; + - **normal cycle:** `ScheduleWakeup(delaySeconds=600, noop=false, prompt="/pr-loop", + reason="recheck PR # review comments")`; + - **idle cycle** (step 3 short-circuited): back off — `delaySeconds=1200, noop=true` (idle + ticks then collapse into one line in my terminal instead of stacking), I'm clearly away; re-running `/pr-loop` checks immediately. End the turn with a sign-off that states when you'll run again and how to stop, e.g.: @@ -268,7 +271,9 @@ Current state (auth + the current branch's PR): - **Never** `git commit`, `git push`, `gh pr merge`, `gh pr create`, `gh pr close`, or `gh pr edit` — I do all of those. You stage, react, resolve, and draft; nothing more. The only comment you may - post is a needs-input decision question. + post is a needs-input decision question. This is enforced, not just asked: `settings.json` denies + those commands and the `git-guard.py` PreToolUse hook blocks any spelling of them, so a slip is + refused with a reason rather than executed. - Act only on the target PR. Don't touch unrelated files or other PRs. - **Keep context lean:** no raw JSON dumps in replies, no re-listing threads that haven't changed, idle cycles ≤2 lines. Every line you emit is re-read (uncached) on every later cycle. diff --git a/common/.config/claude/skills/review-branch/SKILL.md b/common/.config/claude/skills/review-branch/SKILL.md index d16fcdd..bc75341 100644 --- a/common/.config/claude/skills/review-branch/SKILL.md +++ b/common/.config/claude/skills/review-branch/SKILL.md @@ -2,7 +2,7 @@ description: Review all changes on the current branch (diff vs its base) for bugs, security issues, optimizations, and readability/scannability, then apply the readability + safe improvements after you approve. Use right before opening a PR. argument-hint: [base-branch] disable-model-invocation: true -allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(git merge-base:*), Bash(git symbolic-ref:*), Bash(git rev-parse:*), Bash(git show-ref:*), Bash(git branch:*), Read, Grep, Glob +allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(git merge-base:*), Bash(git symbolic-ref:*), Bash(git rev-parse:*), Bash(git show-ref:*), Bash(git branch:*), Read, Grep, Glob, Edit, Write --- # Review this branch @@ -59,11 +59,10 @@ Context auto-collected for the current branch vs. its base: surfaced, not a blanket dump. Only include the ones that actually fit: - `/security-review` — a deeper, whole-branch security pass; suggest whenever the 🔒 lens flagged something or the diff touches auth, crypto, input handling, secrets, or network I/O. - - `/code-review` (add `--fix` to apply, `--comment` for inline PR comments) — a second review - of the working-tree diff at a chosen effort level. + - `/code-review` (alias `/review`; add `--fix` to apply, `--comment` for inline PR comments) — + a second review of the working-tree diff, a PR number or a branch at a chosen effort level. - `/simplify` — apply reuse / simplification / efficiency cleanups beyond the safe ones applied here. - `/verify` — run the app to confirm the change behaves, if this branch changed behavior. - `/pr-description` — draft the PR text from this branch once the review is clean. - - `/review` — review the PR on GitHub after it's open. 8. **Never** run `git commit` or `git push` — I do that manually. diff --git a/common/.config/claude/skills/verbose/SKILL.md b/common/.config/claude/skills/verbose/SKILL.md new file mode 100644 index 0000000..995ae7c --- /dev/null +++ b/common/.config/claude/skills/verbose/SKILL.md @@ -0,0 +1,20 @@ +--- +description: Toggle the persisted verbose view for this project (viewMode verbose/default, plus verbose + showThinkingSummaries) by writing .claude/settings.local.json, and print the live-view keybind. Usage: /verbose [on|off|status]; no argument toggles. Runs TUI-side via the UserPromptExpansion hook; this body is only the fallback. +argument-hint: [on|off|status] +disable-model-invocation: true +allowed-tools: Bash(python3:*) +--- + +# /verbose (fallback path) + +Normally you never see this: `hooks/local-commands.py` (UserPromptExpansion) runs +`skills/verbose/local.py` and blocks the expansion, so `/verbose` costs no model turn. This body +only loads on a host where that hook isn't deployed yet. + +Run exactly this, then relay its output to me verbatim in a fenced ```text block with **at most one +line** of your own: + + python3 ~/.claude/skills/verbose/local.py $ARGUMENTS + +Don't edit any settings file yourself, don't run anything else, and don't offer follow-ups. If it +reports an error, quote it and stop. diff --git a/common/.config/claude/skills/verbose/local.py b/common/.config/claude/skills/verbose/local.py new file mode 100755 index 0000000..d1b507e --- /dev/null +++ b/common/.config/claude/skills/verbose/local.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""`/verbose`, TUI-side: flip Claude Code's *persisted* verbose view for the current project. + +Run by hooks/local-commands.py from the UserPromptExpansion hook, i.e. when you type +`/verbose [on|off|status]` -- the hook executes this and blocks the skill expansion, so no +prompt ever reaches the model and the output below is shown to you directly. (On a host +without that hook, skills/verbose/SKILL.md falls back to having the model run it.) + +Claude Code has no built-in runtime `/verbose`; the live toggle is a keypress (`ctrl+x v` +here, `ctrl+o` by default -- `app:toggleTranscript`). What can be scripted is the persisted +default: `viewMode` ("verbose" | "default" | "focus" -- this is what actually decides the view; +it also beats a sticky `/focus` choice for new sessions), plus `verbose` (full tool +input/output inline) and `showThinkingSummaries` for versions without viewMode. All accept any +settings scope, so this writes the project's +`.claude/settings.local.json` -- local to this machine, never committed -- instead of the +dots-tracked ~/.claude/settings.json, which would show a diff on every toggle. Project-local +settings win over user settings, so the user default stays `verbose: true` and a project can +be quietened individually. + +usage: local.py [on|off|toggle|status] (no argument = toggle) +""" +import json +import os +import subprocess +import sys + +KEYS = ("verbose", "showThinkingSummaries") +VIEW = "viewMode" # "verbose" | "default" | "focus"; overrides KEYS and the sticky /focus choice +LIVE = "live view: ctrl+x v (or ctrl+o) toggles the transcript; ctrl+e inside it shows everything" + + +def project_root(): + try: + r = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, timeout=2) + if r.returncode == 0 and r.stdout.strip(): + return r.stdout.strip() + except Exception: + pass + return os.getcwd() + + +def load(path): + try: + with open(path) as fh: + return json.load(fh) + except FileNotFoundError: + return {} + except Exception as e: # a broken local file must not be silently overwritten + sys.exit(f"error: cannot parse {path}: {e}") + + +def main(): + raw = " ".join(sys.argv[1:]).strip() + mode = raw.lower() or "toggle" + if mode not in ("on", "off", "toggle", "status"): + sys.exit("usage: /verbose [on|off|status]") + root = project_root() + local_path = os.path.join(root, ".claude", "settings.local.json") + user = load(os.path.expanduser("~/.claude/settings.json")) + local = load(local_path) + effective = {k: bool(local.get(k, user.get(k, False))) for k in KEYS} + view = local.get(VIEW, user.get(VIEW)) + + def state_of(view, eff): + if view == "verbose": + return "ON" + if view in ("default", "focus"): + return f"OFF ({view} forced by settings)" + if all(eff.values()): + return "ON via the verbose key (viewMode unset: a sticky /focus choice still wins)" + return "OFF" if not any(eff.values()) else "MIXED" + + if mode != "status": + current_on = state_of(view, effective).startswith("ON") + on = {"on": True, "off": False}.get(mode, not current_on) + if on: + local[VIEW] = "verbose" # forces verbose for new sessions, beating a sticky /focus + else: + local.pop(VIEW, None) # release: the sticky /focus choice (or default) applies again + for k in KEYS: + local[k] = on + os.makedirs(os.path.dirname(local_path), exist_ok=True) + with open(local_path, "w") as fh: + json.dump(local, fh, indent=2) + fh.write("\n") + effective = {k: on for k in KEYS} + view = local.get(VIEW, user.get(VIEW)) + print(f"verbose view: {'ON' if on else 'OFF'} for {root}") + print(f" written to {local_path} (viewMode applies to new sessions; the current one: ctrl+x v)") + else: + print(f"verbose view: {state_of(view, effective)} for {root}") + print(f" {VIEW}: {view or '-'} (project-local: {local.get(VIEW, '-')}, user: {user.get(VIEW, '-')})") + for k in KEYS: + print(f" {k}: {str(effective[k]).lower()} (project-local: {local.get(k, '-')}, user: {user.get(k, '-')})") + print(LIVE) + + +if __name__ == "__main__": + main() diff --git a/common/.config/claude/statusline.py b/common/.config/claude/statusline.py index 58eef9d..5b4072a 100755 --- a/common/.config/claude/statusline.py +++ b/common/.config/claude/statusline.py @@ -3,7 +3,8 @@ One line: vim mode | dir (repo-relative, hidden at the root) | git(repo/branch +dirty +ahead/behind) | model (+effort) | -context% (+compact warn) | 5h usage (+reset eta) | cost (+burn rate) | disk. If the +context% (+compact warn) | 5h usage (+reset eta) | cost (+burn rate) | +prompt-cache hit% (+ttl, cold marker) | disk. If the rendered line would overflow the terminal, trailing (lowest-priority) segments are dropped until it fits. (Dormant, re-addable in main(): ram, cpu%, temp -- retired 2026-07-26 because the @@ -89,7 +90,24 @@ def term_cols(): return 0 -# --- shared transcript usage ------------------------------------------------ +# --- context usage ------------------------------------------------------------ +_USAGE_KEYS = ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens") + + +def payload_usage(data): + """`context_window.current_usage` from the status payload, shaped like `message.usage`. + + Claude Code 2.1.x hands the status line its own token accounting, so the transcript + scan below is only a fallback for payloads that predate it (or lack a transcript). + """ + cw = data.get("context_window") + cu = cw.get("current_usage") if isinstance(cw, dict) else None + if isinstance(cu, dict) and any(isinstance(cu.get(k), (int, float)) for k in _USAGE_KEYS): + return cu + return {} + + +# --- shared transcript usage (fallback) ------------------------------------------ def _scan_usage(text): """Last `message.usage` block among these JSONL lines, or {}.""" usage = {} @@ -113,7 +131,7 @@ TRANSCRIPT_TAIL = 1 << 20 # 1 MiB def last_usage(data): """Most recent `message.usage` block from the transcript, or {}. - Reads only the tail: this runs on every status refresh and transcripts grow to + Fallback only -- see payload_usage(). Reads only the tail: this runs on every status refresh and transcripts grow to tens of MB, so re-parsing the whole file each time is unbounded work for a value that lives at the end. Falls back to a full scan if the tail holds no usage block (a long stretch of tool results can push it past the window). @@ -292,6 +310,11 @@ def context_segment(data, usage): ) window = context_window(data) pct = used / window * 100 if window else 0 + # Prefer Claude Code's own percentage when the payload carries it (2.1.x) -- it + # already accounts for the auto-compact reserve and window quirks we can't see. + cw = data.get("context_window") + if isinstance(cw, dict) and isinstance(cw.get("used_percentage"), (int, float)): + pct = float(cw["used_percentage"]) col = bucket(pct, 50, 80) out = "📝 " + color(f"{pct:.0f}%", col) + color(f" ({used / 1000:.0f}k)", "gray", dim=True) if pct >= 80: @@ -353,20 +376,31 @@ def velocity_segment(data): return "" -def cache_segment(usage): +def cache_segment(usage, data=None): + """Prompt-cache hit % (higher is better) + the cache TTL, `·cold` when the cache has + expired. Hit % from the 2.1.260+ payload `prompt_cache.hit_ratio` (a 0..1 fraction, null + while all counts are zero), else computed from the usage block; ttl/cold always from the + payload when present. A 1h TTL is what keeps a 10-minute pr-loop cadence warm.""" try: - if not usage: + pc = (data or {}).get("prompt_cache") + pc = pc if isinstance(pc, dict) else {} + pct = None + if isinstance(pc.get("hit_ratio"), (int, float)): + pct = float(pc["hit_ratio"]) * 100 + elif usage: + read = usage.get("cache_read_input_tokens", 0) + total = usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0) + read + if total > 0: + pct = read / total * 100 + if pct is None: return "" - read = usage.get("cache_read_input_tokens", 0) - total = ( - usage.get("input_tokens", 0) - + usage.get("cache_creation_input_tokens", 0) - + read - ) - if total <= 0: - return "" - pct = read / total * 100 - return "♻️ " + color(f"{pct:.0f}%", bucket(pct, 50, 80, invert=True)) + out = "♻️ " + color(f"{pct:.0f}%", bucket(pct, 50, 80, invert=True)) + ttl = pc.get("ttl") + if isinstance(ttl, str) and ttl: + out += color(f" {ttl}", "gray") + if pc.get("warm") is False: + out += color(" ·cold", "yellow") + return out except Exception: return "" @@ -532,7 +566,7 @@ def main(): or data.get("cwd") or os.getcwd() ) - usage = last_usage(data) + usage = payload_usage(data) or last_usage(data) top = git_top(cwd) # One row: work (left) then system (right), joined. If it would overflow the @@ -540,7 +574,7 @@ def main(): # Dormant helpers kept above for easy re-add: ram_segment, cpu_segment, # temp_segment (retired -- they only refresh on message events, so they sat # stale; the tmux status bar owns system metrics now), velocity_segment, - # cache_segment, api_segment, version_segment, style_segment. + # api_segment, version_segment, style_segment. work = [s for s in ( vim_segment(data), dir_segment(cwd, top), @@ -549,6 +583,7 @@ def main(): context_segment(data, usage), usage_segment(data), cost_segment(data), + cache_segment(usage, data), ) if s] system = [s for s in ( disk_segment(cwd), diff --git a/common/.config/fish/config.fish b/common/.config/fish/config.fish index a05088a..0dbabf0 100644 --- a/common/.config/fish/config.fish +++ b/common/.config/fish/config.fish @@ -5,7 +5,11 @@ set -gx EDITOR nvim set -gx PAGER less set -gx QT_FONT_DPI 96 set -gx PI_SKIP_VERSION_CHECK 1 -set -gx OPENCODE_CONFIG "$HOME/.config/opencode/openai-gpt.jsonc" +# opencode GPT/Codex OAuth overlay — only where `opencode auth login` stored an OpenAI credential, +# so hosts without the login don't get an unusable provider in the model picker. +if test -r ~/.local/share/opencode/auth.json; and grep -q '"openai"' ~/.local/share/opencode/auth.json + set -gx OPENCODE_CONFIG "$HOME/.config/opencode/openai-gpt.jsonc" +end set -gx MANPAGER "nvim +Man!" # fzf's built-in walker is the one search tool that ignores .ignore files, so point it at # fd, which honours them. fd still hides dotfiles by default everywhere else — this only @@ -48,6 +52,7 @@ if status is-interactive # Git alias gti="git" alias gs="git status -s" + alias gp="git pull" alias gca="git add -p . && git commit" alias gd="git diff --word-diff" alias gl="git log --graph --show-signature" diff --git a/common/.config/fish/functions/opencodes.fish b/common/.config/fish/functions/opencodes.fish index 1bef1da..c37a3f9 100644 --- a/common/.config/fish/functions/opencodes.fish +++ b/common/.config/fish/functions/opencodes.fish @@ -1,4 +1,25 @@ -function opencodes --description 'contained opencode' +function opencodes --description 'contained opencode (bwrap); extra args are passed to opencode' + set -l cfg ~/.config/opencode + # ~/.config/opencode is stowed as per-file symlinks into ~/.dots. Bind each link's real parent + # dir read-only too, or the sandbox sees dangling links and opencode starts with NO config. + set -l dirs + for f in $cfg/* $cfg/*/* + test -L $f; or continue + set -l d (path resolve $f | path dirname) + string match -q "$cfg*" $d; and continue + contains -- $d $dirs; or set -a dirs $d + end + set -l binds + for d in $dirs + set -a binds --ro-bind $d $d + end + # --clearenv drops everything; pass through only what the TUI and its tools need. + set -l env --setenv PATH /usr/local/bin:/usr/bin:/bin + for v in HOME TERM COLORTERM LANG LC_ALL EDITOR OPENCODE_CONFIG DUSKADIY_API_KEY + set -l val $$v + test -n "$val"; and set -a env --setenv $v $val + end + mkdir -p ~/.cache/opencode ~/.local/state/opencode ~/.local/share/opencode bwrap \ --unshare-all \ --new-session \ @@ -12,10 +33,13 @@ function opencodes --description 'contained opencode' --ro-bind /lib64 /lib64 \ --ro-bind /bin /bin \ --ro-bind /etc /etc \ - --ro-bind ~/.config/opencode ~/.config/opencode \ - --tmpfs /tmp/node-compile-cache \ + --ro-bind $cfg $cfg \ + $binds \ + --tmpfs /tmp \ + --bind ~/.cache/opencode ~/.cache/opencode \ --bind ~/.local/state/opencode ~/.local/state/opencode \ --bind ~/.local/share/opencode ~/.local/share/opencode \ - --bind "$(pwd)" "$(pwd)" \ - opencode + --bind $PWD $PWD \ + $env \ + opencode $argv end diff --git a/common/.config/git/claude-commit-msg b/common/.config/git/claude-commit-msg new file mode 100755 index 0000000..a31ad02 --- /dev/null +++ b/common/.config/git/claude-commit-msg @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""claude-commit-msg -- hand suggested commit messages from Claude sessions to `git commit`. + +Several Claude sessions work on one checkout at once, each staging its own round. The old +handoff was ONE file (`/CLAUDE_COMMIT_MSG`) that the prepare-commit-msg hook pasted +into the editor: the second session silently overwrote the first, and the hook could not +know which message described which files. Now every handoff is its own file under +`/CLAUDE_COMMIT_MSG.d/`, tagged with the paths it describes, and the hook prefills +only the message(s) whose paths are part of the commit being made: + + .msg the raw message + .paths one repo-relative path per line (the staged set at save time) + used/ consumed messages, kept for recovery + + save [--paths P ...] [--slug S] read the message on stdin; paths default to the index + list pending messages and how they match the current index + show ID | rm ID + prefill MSGFILE [SOURCE] hook entry point (prepare-commit-msg) + +Matching in `prefill`: a message whose paths are all in the commit is a FULL match (prefilled, +then consumed); overlapping only partly = PARTIAL (prefilled, kept -- the rest may come in a +later commit); no paths recorded = WILDCARD (prefilled, consumed), which is also how a legacy +single CLAUDE_COMMIT_MSG file is treated. Several matches are all prefilled, separated by a +comment line git strips, so you pick in the editor. Nothing matching = editor untouched. +Everything lives inside the git dir, so it never shows in `git status`; `--git-path` keeps it +per-worktree. Always exits 0 from `prefill`: a hook failure must never abort a commit. +""" +import os +import re +import shutil +import subprocess +import sys +import time + + +def git(*args, check=True): + r = subprocess.run(["git", *args], capture_output=True, text=True) + if check and r.returncode != 0: + sys.exit(f"claude-commit-msg: git {' '.join(args)} failed: {r.stderr.strip()}") + return r.stdout + + +def store(): + d = git("rev-parse", "--git-path", "CLAUDE_COMMIT_MSG.d").strip() + d = os.path.abspath(d) + os.makedirs(os.path.join(d, "used"), exist_ok=True) + return d + + +def staged_paths(): + out = git("diff", "--cached", "--name-only", "-z", check=False) + return [p for p in out.split("\0") if p] + + +def comment_char(): + c = git("config", "--get", "core.commentchar", check=False).strip() + return "#" if not c or c == "auto" else c[0] + + +def pending(d): + """[(id, message, paths|None)] oldest first. paths None = wildcard.""" + out = [] + for name in sorted(os.listdir(d)): + if not name.endswith(".msg"): + continue + mid = name[:-4] + try: + with open(os.path.join(d, name)) as fh: + msg = fh.read() + except OSError: + continue + paths = None + pf = os.path.join(d, mid + ".paths") + if os.path.exists(pf): + with open(pf) as fh: + paths = [ln.rstrip("\n") for ln in fh if ln.strip()] + out.append((mid, msg, paths)) + return out + + +def slugify(text): + m = re.match(r"\s*\[([^\]]+)\]", text) # this repo's `[Scope] summary` convention + base = m.group(1) if m else " ".join(text.split()[:3]) + s = re.sub(r"[^a-z0-9]+", "-", base.lower()).strip("-") + return s[:32] or "msg" + + +def cmd_save(argv): + paths, slug = None, None + i = 0 + while i < len(argv): + if argv[i] == "--paths": + paths = [] + i += 1 + while i < len(argv) and not argv[i].startswith("--"): + paths.append(argv[i]) + i += 1 + continue + if argv[i] == "--slug" and i + 1 < len(argv): + slug = argv[i + 1] + i += 2 + continue + sys.exit(f"claude-commit-msg save: unknown argument {argv[i]!r}") + msg = sys.stdin.read().rstrip() + "\n" + if not msg.strip(): + sys.exit("claude-commit-msg save: empty message on stdin") + if paths is None: + paths = staged_paths() + d = store() + mid = time.strftime("%Y%m%d-%H%M%S") + "-" + (slug or slugify(msg)) + n = 1 + while os.path.exists(os.path.join(d, f"{mid}.msg")): + n += 1 + mid = f"{mid}-{n}" + with open(os.path.join(d, mid + ".msg"), "w") as fh: + fh.write(msg) + with open(os.path.join(d, mid + ".paths"), "w") as fh: + fh.write("".join(p + "\n" for p in paths)) + print(f"saved {mid} ({len(paths)} path{'s' if len(paths) != 1 else ''}) -> {os.path.join(d, mid + '.msg')}") + if not paths: + print("warning: no paths recorded (nothing staged?) -- it will prefill on ANY commit", file=sys.stderr) + warn_if_hook_is_old() + + +def warn_if_hook_is_old(): + """A clone still running the legacy hook (or none) reads only the single file this tool + never writes -- the editor would come up empty with no error. Say so at save time.""" + hook = git("rev-parse", "--git-path", "hooks/prepare-commit-msg", check=False).strip() + try: + with open(hook, errors="ignore") as fh: + ok = "claude-commit-msg" in fh.read() + except OSError: + ok = False + if not ok: + print( + "warning: this repo's prepare-commit-msg hook is missing or predates claude-commit-msg, so this\n" + " message will NOT be prefilled here. Install the tracked hook once per repo:\n" + " ln -sf ~/.config/git/hooks/prepare-commit-msg (git rev-parse --git-path hooks)/prepare-commit-msg", + file=sys.stderr, + ) + + +def classify(paths, staged): + if paths is None or not paths: + return "wildcard" + ps = set(paths) + if ps <= staged: + return "full" + if ps & staged: + return "partial" + return "none" + + +def cmd_list(_argv): + d = store() + staged = set(staged_paths()) + items = pending(d) + legacy = git("rev-parse", "--git-path", "CLAUDE_COMMIT_MSG").strip() + if not items and not os.path.exists(legacy): + print("no pending messages") + return + for mid, msg, paths in items: + n = len(paths) if paths else 0 + print(f"{mid} [{classify(paths, staged):8}] {n} path{'s' if n != 1 else ''} {msg.splitlines()[0][:70]}") + if os.path.exists(legacy) and os.path.getsize(legacy) > 0: + with open(legacy) as fh: + first = fh.readline().rstrip() + print(f"(legacy CLAUDE_COMMIT_MSG) [wildcard] {first[:70]}") + + +def cmd_show(argv): + d = store() + for mid in argv: + with open(os.path.join(d, mid + ".msg")) as fh: + sys.stdout.write(fh.read()) + + +def cmd_rm(argv): + d = store() + for mid in argv: + hit = False + for ext in (".msg", ".paths"): + p = os.path.join(d, mid + ext) + if os.path.exists(p): + os.remove(p) + hit = True + if hit: + print(f"removed {mid}") + elif os.path.exists(os.path.join(d, "used", mid + ".msg")): + print(f"{mid} was already consumed by a commit (see used/)") + else: + print(f"{mid}: no such pending message") + + +def consume(d, mid): + for ext in (".msg", ".paths"): + p = os.path.join(d, mid + ext) + if os.path.exists(p): + shutil.move(p, os.path.join(d, "used", mid + ext)) + + +def cmd_prefill(argv): + if not argv: + return + msg_file = argv[0] + source = argv[1] if len(argv) > 1 else "" + if source not in ("", "template"): # -m/-F, merge, squash, amend: leave alone + return + d = store() + staged = set(staged_paths()) + order = {"full": 0, "partial": 1, "wildcard": 2} + picked = [] + for mid, msg, paths in pending(d): + kind = classify(paths, staged) + if kind != "none": + picked.append((order[kind], mid, msg, kind)) + legacy = git("rev-parse", "--git-path", "CLAUDE_COMMIT_MSG").strip() + legacy_msg = None + if os.path.isfile(legacy) and os.path.getsize(legacy) > 0: + with open(legacy) as fh: + legacy_msg = fh.read() + picked.append((3, "legacy", legacy_msg, "wildcard")) + if not picked: + return + picked.sort(key=lambda t: (t[0], t[1])) + cc = comment_char() + sep = f"\n{cc} ---- another pending Claude message matched this commit; keep the right one ----\n\n" + body = sep.join(m.rstrip("\n") + "\n" for _, _, m, _ in picked) + with open(msg_file) as fh: + existing = fh.read() + tmp = msg_file + ".claude-tmp" + with open(tmp, "w") as fh: + fh.write(body + "\n" + existing) + os.replace(tmp, msg_file) + for _, mid, _, kind in picked: + if mid == "legacy": + os.replace(legacy, legacy + ".last") + elif kind in ("full", "wildcard"): + consume(d, mid) + + +def main(): + cmds = {"save": cmd_save, "list": cmd_list, "show": cmd_show, "rm": cmd_rm, "prefill": cmd_prefill} + if len(sys.argv) < 2 or sys.argv[1] not in cmds: + sys.exit("usage: claude-commit-msg save [--paths P ...] [--slug S] < message | list | show ID | rm ID | prefill MSGFILE [SOURCE]") + if sys.argv[1] == "prefill": + try: + cmd_prefill(sys.argv[2:]) + except Exception as e: # never abort a commit because of the helper + print(f"claude-commit-msg prefill: {e}", file=sys.stderr) + return + cmds[sys.argv[1]](sys.argv[2:]) + + +if __name__ == "__main__": + main() diff --git a/common/.config/git/hooks/prepare-commit-msg b/common/.config/git/hooks/prepare-commit-msg new file mode 100755 index 0000000..5805450 --- /dev/null +++ b/common/.config/git/hooks/prepare-commit-msg @@ -0,0 +1,36 @@ +#!/bin/sh +# Prefill the commit editor from pending Claude commit messages (one-shot). +# +# Claude sessions save suggestions with `~/.config/git/claude-commit-msg save` -- one file per +# message under $(git rev-parse --git-path CLAUDE_COMMIT_MSG.d)/, tagged with the paths it +# describes. On the next `git commit` / lazygit `C` this pastes the message(s) whose paths are +# part of the commit above git's comment block, then consumes them (moved to used/). Several +# sessions can hand off in parallel without overwriting each other. +# +# Falls back to the legacy single file $(git rev-parse --git-path CLAUDE_COMMIT_MSG) when the +# CLI is not deployed on this host (not re-stowed yet). Never exits non-zero: a helper problem +# must not abort a commit. +msg_file=$1 +source=$2 + +# Only prefill a fresh editor session -- skip -m/-F (message), merge, squash, amend (commit). +case "$source" in + ""|template) ;; + *) exit 0 ;; +esac + +cli="$HOME/.config/git/claude-commit-msg" +if [ -f "$cli" ] && command -v python3 >/dev/null 2>&1; then + python3 "$cli" prefill "$msg_file" "$source" + exit 0 +fi + +# --- legacy fallback: the single one-shot file --- +suggestion=$(git rev-parse --git-path CLAUDE_COMMIT_MSG) +[ -s "$suggestion" ] || exit 0 + +tmp=$(mktemp) || exit 0 +{ cat "$suggestion"; printf '\n'; cat "$msg_file"; } > "$tmp" && + mv "$tmp" "$msg_file" && + mv "$suggestion" "$suggestion.last" +exit 0 diff --git a/common/.config/misc/tuis.txt b/common/.config/misc/tuis.txt index 350772a..7c5b933 100644 --- a/common/.config/misc/tuis.txt +++ b/common/.config/misc/tuis.txt @@ -30,6 +30,7 @@ nvim vim tetrigo pyradio +cliamp px7-radio kew cmatrix diff --git a/common/.config/opencode/AGENTS.md b/common/.config/opencode/AGENTS.md new file mode 100644 index 0000000..bdd9320 --- /dev/null +++ b/common/.config/opencode/AGENTS.md @@ -0,0 +1,22 @@ +# Global agent instructions + +You are a coding assistant running on small, locally-hosted models. Be precise and economical with tokens. + +## Working style +- Act with tools instead of describing what you would do. Keep prose short. +- Do the task that was asked. Don't add unrequested changes, refactors, or files. +- When done, stop. A one- or two-line summary is enough; no recaps of obvious steps. + +## Files & edits +- Read a file before you edit it. Never guess at file paths, function names, or APIs — verify first. +- Use the edit tool for changes. Make minimal, targeted diffs; never paste an entire file back to the user. +- Match the surrounding code's style, naming, and imports. + +## Shell +- Run one command at a time and check its output before the next. +- Prefer `rg` and `fd` for search. Use read-only commands when exploring. +- Never run destructive or system-changing commands (`rm -rf`, `sudo`, package installs, force-push) unless explicitly asked. + +## Honesty +- If you're unsure, say so and check rather than inventing an answer. +- Report failures plainly with the actual error; don't claim success you didn't verify. diff --git a/common/.config/opencode/commands/commit.md b/common/.config/opencode/commands/commit.md new file mode 100644 index 0000000..0b84220 --- /dev/null +++ b/common/.config/opencode/commands/commit.md @@ -0,0 +1,11 @@ +--- +description: Write a Conventional Commits message for the staged changes (does not commit) +agent: plan +--- +Inspect the staged changes with `git diff --cached` (and `git status` for context). Then write a single Conventional Commits message: + +- A `(): ` summary line, imperative mood, ≤72 chars (types: feat, fix, refactor, docs, chore, test, perf, build). +- An optional short body explaining the *why* only when it isn't obvious from the diff. +- Do NOT run `git commit`. Output only the message inside a code block so I can review it. + +Extra context from me, if any: $ARGUMENTS diff --git a/common/.config/opencode/commands/review.md b/common/.config/opencode/commands/review.md new file mode 100644 index 0000000..2d2ca42 --- /dev/null +++ b/common/.config/opencode/commands/review.md @@ -0,0 +1,14 @@ +--- +description: Review the current diff (staged + unstaged) for bugs and issues, read-only +agent: plan +--- +Review the current changes. Check both `git diff` (unstaged) and `git diff --cached` (staged). Focus on: + +- Correctness and logic bugs +- Error handling and missing edge cases +- Security issues (injection, secrets, unsafe input) +- Anything that doesn't match the surrounding code's conventions + +List concrete findings as `file:line — issue — suggested fix`, ordered by severity. Be concise; skip praise. If nothing is wrong, say so. + +Extra focus this round, if any (default: general correctness): $ARGUMENTS diff --git a/common/.config/opencode/openai-gpt.jsonc b/common/.config/opencode/openai-gpt.jsonc index 9161024..85c73b6 100644 --- a/common/.config/opencode/openai-gpt.jsonc +++ b/common/.config/opencode/openai-gpt.jsonc @@ -14,6 +14,14 @@ ], "store": false }, + "whitelist": [ + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.1-codex-max", + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1" + ], "models": { "gpt-5.2": { "name": "GPT 5.2 (OAuth)", diff --git a/common/.config/opencode/opencode.json b/common/.config/opencode/opencode.json index 5625ca4..110962b 100644 --- a/common/.config/opencode/opencode.json +++ b/common/.config/opencode/opencode.json @@ -1,10 +1,11 @@ { "$schema": "https://opencode.ai/config.json", "model": "llama.cpp/gemma-4-26B-A4B-it-UD-IQ4_XS", - "small_model": "llama.cpp/gemma-4-E4B-it-UD-Q8_K_XL", "instructions": ["CLAUDE.md", "AGENTS.md"], "share": "disabled", "autoupdate": "notify", + "tool_output": { "max_lines": 200, "max_bytes": 16384 }, + "snapshot": false, "disabled_providers": [ "gemini", "anthropic", @@ -17,6 +18,7 @@ "webfetch": "ask", "websearch": "ask", "external_directory": "ask", + "question": "allow", "read": { "*": "allow", "**/.env*": "deny", @@ -133,46 +135,51 @@ "npm": "@ai-sdk/openai-compatible", "name": "DuskaDIY", "options": { - "baseURL": "https://llm.duskadiy.com/api/v1" + "baseURL": "https://llm.duskadiy.com/api/v1", + "apiKey": "{env:DUSKADIY_API_KEY}", + "timeout": 3600000 }, "models": { - "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL": { "name": "Qwen3 Coder 30B · 32k — main agent coder (remote)", "limit": { "context": 32768, "output": 4096 } }, - "Qwen3-Coder-Next-UD-IQ3_XXS": { "name": "Qwen3 Coder Next 80B · 128k — long sessions (remote)", "limit": { "context": 131072, "output": 16384 } }, - "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS": { "name": "Qwen3.6 35B · 24k · vision — daily driver (remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, - "Qwen3.6-35B-A3B-Thinking": { "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems (remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, - "Qwen3.5-9B-UD-Q6_K_XL": { "name": "Qwen3.5 9B · 32k · vision — quick tasks (remote)", "attachment": true, "limit": { "context": 32768, "output": 8192 } }, - "Qwen3.8-27B-UD-IQ3_XXS": { "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner (remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, - "gemma-4-26B-A4B-it-UD-IQ4_XS": { "name": "Gemma 4 26B · 24k · vision — quality generalist (remote)", "attachment": true, "limit": { "context": 24576, "output": 4096 } }, - "gemma-4-E4B-it-UD-Q8_K_XL": { "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs (remote)", "attachment": true, "limit": { "context": 65536, "output": 16384 } }, - "GLM-4.7-Flash-UD-Q4_K_XL": { "name": "GLM-4.7 Flash · 24k — quality coder (remote)", "limit": { "context": 24576, "output": 8192 } }, - "gpt-oss-20b": { "name": "gpt-oss 20B · 64k — fast reasoning + tools (remote)", "limit": { "context": 65536, "output": 8192 } }, - "gpt-oss-20b-low": { "name": "gpt-oss 20B low · 64k — snappy answers (remote)", "limit": { "context": 65536, "output": 8192 } } + "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL": { "name": "Qwen3 Coder 30B · 32k — main agent coder (~30 t/s, remote)", "limit": { "context": 32768, "output": 4096 } }, + "Qwen3-Coder-Next-UD-IQ3_XXS": { "name": "Qwen3 Coder Next 80B · 128k — long sessions (~16 t/s, remote)", "limit": { "context": 131072, "output": 16384 } }, + "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS": { "name": "Qwen3.6 35B · 24k · vision — daily driver (~36 t/s, remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, + "Qwen3.6-35B-A3B-Thinking": { "reasoning": true, "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems (~39 t/s, remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, + "Qwen3.5-9B-UD-Q6_K_XL": { "reasoning": true, "name": "Qwen3.5 9B · 32k · vision — quick tasks (~32 t/s, remote)", "attachment": true, "limit": { "context": 32768, "output": 8192 } }, + "Qwen3.8-27B-UD-IQ3_XXS": { "reasoning": true, "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner, quiet desktop only (~15 t/s, remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, + "gemma-4-26B-A4B-it-UD-IQ4_XS": { "name": "Gemma 4 26B · 24k · vision — quality generalist (~34 t/s, remote)", "attachment": true, "limit": { "context": 24576, "output": 4096 } }, + "gemma-4-E4B-it-UD-Q8_K_XL": { "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs (~57 t/s, remote)", "attachment": true, "limit": { "context": 65536, "output": 16384 } }, + "GLM-4.7-Flash-UD-Q4_K_XL": { "reasoning": true, "interleaved": "reasoning_content", "name": "GLM-4.7 Flash · 24k — quality coder (~21 t/s, remote)", "limit": { "context": 24576, "output": 8192 } }, + "gpt-oss-20b": { "reasoning": true, "name": "gpt-oss 20B · 64k — fast reasoning + tools (~38 t/s, remote)", "limit": { "context": 65536, "output": 8192 } }, + "gpt-oss-20b-low": { "reasoning": true, "name": "gpt-oss 20B low · 64k — snappy answers (~37 t/s, remote)", "limit": { "context": 65536, "output": 8192 } } } }, "llama.cpp": { "npm": "@ai-sdk/openai-compatible", "name": "llamacpp", "options": { - "baseURL": "http://192.168.0.204:11343/v1" + "baseURL": "http://192.168.0.204:11343/v1", + "timeout": 3600000 }, "models": { - "Qwen3-Coder-Next-UD-IQ3_XXS": { "name": "Qwen3 Coder Next 80B · 128k — long sessions", "limit": { "context": 131072, "output": 16384 } }, - "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL": { "name": "Qwen3 Coder 30B · 32k — main agent coder", "limit": { "context": 32768, "output": 4096 } }, - "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS": { "name": "Qwen3.6 35B · 24k · vision — daily driver", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, - "Qwen3.6-35B-A3B-Thinking": { "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, - "Qwen3.5-9B-UD-Q6_K_XL": { "name": "Qwen3.5 9B · 32k · vision — quick tasks", "attachment": true, "limit": { "context": 32768, "output": 8192 } }, - "Qwen3.8-27B-UD-IQ3_XXS": { "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, - "gemma-4-26B-A4B-it-UD-IQ4_XS": { "name": "Gemma 4 26B · 24k · vision — quality generalist", "attachment": true, "limit": { "context": 24576, "output": 4096 } }, - "gemma-4-E4B-it-UD-Q8_K_XL": { "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs", "attachment": true, "limit": { "context": 65536, "output": 16384 } }, - "GLM-4.7-Flash-UD-Q4_K_XL": { "name": "GLM-4.7 Flash · 24k — quality coder", "limit": { "context": 24576, "output": 8192 } }, - "gpt-oss-20b": { "name": "gpt-oss 20B · 64k — fast reasoning + tools", "limit": { "context": 65536, "output": 8192 } }, - "gpt-oss-20b-low": { "name": "gpt-oss 20B low · 64k — snappy answers", "limit": { "context": 65536, "output": 8192 } } + "Qwen3-Coder-Next-UD-IQ3_XXS": { "name": "Qwen3 Coder Next 80B · 128k — long sessions (~16 t/s)", "limit": { "context": 131072, "output": 16384 } }, + "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL": { "name": "Qwen3 Coder 30B · 32k — main agent coder (~30 t/s)", "limit": { "context": 32768, "output": 4096 } }, + "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS": { "name": "Qwen3.6 35B · 24k · vision — daily driver (~36 t/s)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, + "Qwen3.6-35B-A3B-Thinking": { "reasoning": true, "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems (~39 t/s)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, + "Qwen3.5-9B-UD-Q6_K_XL": { "reasoning": true, "name": "Qwen3.5 9B · 32k · vision — quick tasks (~32 t/s)", "attachment": true, "limit": { "context": 32768, "output": 8192 } }, + "Qwen3.8-27B-UD-IQ3_XXS": { "reasoning": true, "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner, quiet desktop only (~15 t/s)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, + "gemma-4-26B-A4B-it-UD-IQ4_XS": { "name": "Gemma 4 26B · 24k · vision — quality generalist (~34 t/s)", "attachment": true, "limit": { "context": 24576, "output": 4096 } }, + "gemma-4-E4B-it-UD-Q8_K_XL": { "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs (~57 t/s)", "attachment": true, "limit": { "context": 65536, "output": 16384 } }, + "GLM-4.7-Flash-UD-Q4_K_XL": { "reasoning": true, "interleaved": "reasoning_content", "name": "GLM-4.7 Flash · 24k — quality coder (~21 t/s)", "limit": { "context": 24576, "output": 8192 } }, + "gpt-oss-20b": { "reasoning": true, "name": "gpt-oss 20B · 64k — fast reasoning + tools (~38 t/s)", "limit": { "context": 65536, "output": 8192 } }, + "gpt-oss-20b-low": { "reasoning": true, "name": "gpt-oss 20B low · 64k — snappy answers (~37 t/s)", "limit": { "context": 65536, "output": 8192 } } } } }, "agent": { "plan": { "mode": "primary", + "color": "info", + "steps": 30, "model": "llama.cpp/gemma-4-26B-A4B-it-UD-IQ4_XS", "permission": { "edit": "deny" @@ -180,6 +187,8 @@ }, "build": { "mode": "primary", + "color": "primary", + "steps": 30, "model": "llama.cpp/gemma-4-26B-A4B-it-UD-IQ4_XS", "permission": { "edit": { @@ -197,11 +206,108 @@ } } }, + "auto": { + "description": "Auto mode: acts without asking; only irreversible or outward-facing actions ask", + "mode": "primary", + "color": "#f9e2af", + "steps": 30, + "model": "llama.cpp/gemma-4-26B-A4B-it-UD-IQ4_XS", + "permission": { + "edit": { + "*": "allow", + "**/.env*": "deny", + "**/*.env": "deny", + "**/*.pem": "deny", + "**/*.key": "deny", + "**/id_rsa*": "deny", + "**/.ssh/**": "deny", + "**/.aws/**": "deny", + "**/.git/**": "deny", + "**/.npmrc": "deny", + "**/.netrc": "deny" + }, + "webfetch": "allow", + "websearch": "allow", + "external_directory": "ask", + "question": "allow", + "bash": { + "*": "allow", + + "git *": "allow", + "git commit*": "ask", + "git push*": "ask", + "git reset*": "ask", + "git checkout*": "ask", + "git switch*": "ask", + "git restore*": "ask", + "git rebase*": "ask", + "git merge*": "ask", + "git cherry-pick*": "ask", + "git stash*": "ask", + "git clean*": "ask", + "git branch -*": "ask", + "git tag*": "ask", + "git remote add*": "ask", + "git remote remove*": "ask", + "git remote set-url*": "ask", + + "rm *": "ask", + "rmdir *": "ask", + "kill *": "ask", + "pkill *": "ask", + "killall *": "ask", + "systemctl *": "ask", + "curl *": "ask", + "wget *": "ask", + "ssh *": "ask", + "scp *": "ask", + "rsync *": "ask", + + "npm install*": "ask", + "npm i": "ask", + "npm i *": "ask", + "npm ci*": "ask", + "pnpm install*": "ask", + "pnpm i": "ask", + "pnpm i *": "ask", + "pnpm add*": "ask", + "yarn add*": "ask", + "yarn install*": "ask", + "pip install*": "ask", + "pip3 install*": "ask", + "pipx install*": "ask", + "cargo install*": "ask", + "go install*": "ask", + "gem install*": "ask", + "yay *": "ask", + + "sudo *": "deny", + "doas *": "deny", + "dd *": "deny", + "mkfs*": "deny", + "shred *": "deny", + "chmod -R *": "deny", + "rm -rf /*": "deny", + "rm -rf ~*": "deny", + + "cat .env*": "deny", + "cat *.env": "deny", + "cat *.pem": "deny", + "cat *.key": "deny", + "cat *id_rsa*": "deny", + "cat *secret*": "deny", + "cat *credential*": "deny", + "cat ~/.ssh/*": "deny", + "cat ~/.aws/*": "deny", + "grep .env*": "deny", + "grep * .env*": "deny" + } + } + }, "code-reviewer": { "description": "Reviews code for best practices and potential issues", "prompt": "You are a code reviewer. Focus on security, performance, and maintainability.", "mode": "subagent", - "model": "llama.cpp/GLM-4.7-Flash-UD-Q4_K_XL", "permission": { "edit": "deny" } @@ -210,7 +316,7 @@ "description": "Runs and interprets test suites", "prompt": "You are a test runner. Focus on identifying failing tests and providing clear reproduction steps.", "mode": "subagent", - "model": "llama.cpp/GLM-4.7-Flash-UD-Q4_K_XL", + "hidden": true, "permission": { "edit": "deny" } @@ -219,7 +325,6 @@ "description": "Maintains project documentation and docstrings", "prompt": "You are a technical writer. Focus on clarity, accuracy, and keeping documentation in sync with code changes.", "mode": "subagent", - "model": "llama.cpp/gemma-4-26B-A4B-it-UD-IQ4_XS", "permission": { "edit": { "*": "deny", @@ -235,7 +340,7 @@ "description": "Scans for security vulnerabilities and hardcoded secrets", "prompt": "You are a security auditor. Focus on OWASP principles, secret detection, and dependency vulnerabilities.", "mode": "subagent", - "model": "llama.cpp/GLM-4.7-Flash-UD-Q4_K_XL", + "hidden": true, "permission": { "edit": "deny" } diff --git a/common/.config/opencode/themes/catppuccin-mocha.json b/common/.config/opencode/themes/catppuccin-mocha.json new file mode 100644 index 0000000..9186009 --- /dev/null +++ b/common/.config/opencode/themes/catppuccin-mocha.json @@ -0,0 +1,262 @@ +{ + "defs": { + "mocha_rosewater": "#f5e0dc", + "mocha_flamingo": "#f2cdcd", + "mocha_pink": "#f5c2e7", + "mocha_mauve": "#cba6f7", + "mocha_red": "#f38ba8", + "mocha_maroon": "#eba0ac", + "mocha_peach": "#fab387", + "mocha_yellow": "#f9e2af", + "mocha_green": "#a6e3a1", + "mocha_teal": "#94e2d5", + "mocha_sky": "#89dceb", + "mocha_sapphire": "#74c7ec", + "mocha_blue": "#89b4fa", + "mocha_lavender": "#b4befe", + "mocha_text": "#cdd6f4", + "mocha_subtext1": "#bac2de", + "mocha_subtext0": "#a6adc8", + "mocha_overlay2": "#9399b2", + "mocha_overlay1": "#7f849c", + "mocha_overlay0": "#6c7086", + "mocha_surface2": "#585b70", + "mocha_surface1": "#45475a", + "mocha_surface0": "#313244", + "mocha_base": "#1e1e2e", + "mocha_mantle": "#181825", + "mocha_crust": "#11111b", + "mocha_diffAddBg": "#1e2b22", + "mocha_diffDelBg": "#2b1e22", + "latte_rosewater": "#dc8a78", + "latte_flamingo": "#dd7878", + "latte_pink": "#ea76cb", + "latte_mauve": "#8839ef", + "latte_red": "#d20f39", + "latte_maroon": "#e64553", + "latte_peach": "#fe640b", + "latte_yellow": "#df8e1d", + "latte_green": "#40a02b", + "latte_teal": "#179299", + "latte_sky": "#04a5e5", + "latte_sapphire": "#209fb5", + "latte_blue": "#1e66f5", + "latte_lavender": "#7287fd", + "latte_text": "#4c4f69", + "latte_subtext1": "#5c5f77", + "latte_subtext0": "#6c6f85", + "latte_overlay2": "#7c7f93", + "latte_overlay1": "#8c8fa1", + "latte_overlay0": "#9ca0b0", + "latte_surface2": "#acb0be", + "latte_surface1": "#bcc0cc", + "latte_surface0": "#ccd0da", + "latte_base": "#eff1f5", + "latte_mantle": "#e6e9ef", + "latte_crust": "#dce0e8", + "latte_diffAddBg": "#e4f3e6", + "latte_diffDelBg": "#f9e1e6" + }, + "theme": { + "primary": { + "dark": "mocha_mauve", + "light": "latte_mauve" + }, + "secondary": { + "dark": "mocha_blue", + "light": "latte_blue" + }, + "accent": { + "dark": "mocha_peach", + "light": "latte_peach" + }, + "error": { + "dark": "mocha_red", + "light": "latte_red" + }, + "warning": { + "dark": "mocha_yellow", + "light": "latte_yellow" + }, + "success": { + "dark": "mocha_green", + "light": "latte_green" + }, + "info": { + "dark": "mocha_sapphire", + "light": "latte_sapphire" + }, + "text": { + "dark": "mocha_text", + "light": "latte_text" + }, + "textMuted": { + "dark": "mocha_subtext0", + "light": "latte_subtext0" + }, + "background": { + "dark": "mocha_base", + "light": "latte_base" + }, + "backgroundPanel": { + "dark": "mocha_mantle", + "light": "latte_mantle" + }, + "backgroundElement": { + "dark": "mocha_surface0", + "light": "latte_surface0" + }, + "border": { + "dark": "mocha_surface1", + "light": "latte_surface1" + }, + "borderActive": { + "dark": "mocha_mauve", + "light": "latte_mauve" + }, + "borderSubtle": { + "dark": "mocha_surface0", + "light": "latte_surface0" + }, + "diffAdded": { + "dark": "mocha_green", + "light": "latte_green" + }, + "diffRemoved": { + "dark": "mocha_red", + "light": "latte_red" + }, + "diffContext": { + "dark": "mocha_overlay0", + "light": "latte_overlay0" + }, + "diffHunkHeader": { + "dark": "mocha_overlay1", + "light": "latte_overlay1" + }, + "diffHighlightAdded": { + "dark": "mocha_green", + "light": "latte_green" + }, + "diffHighlightRemoved": { + "dark": "mocha_red", + "light": "latte_red" + }, + "diffAddedBg": { + "dark": "mocha_diffAddBg", + "light": "latte_diffAddBg" + }, + "diffRemovedBg": { + "dark": "mocha_diffDelBg", + "light": "latte_diffDelBg" + }, + "diffContextBg": { + "dark": "mocha_base", + "light": "latte_base" + }, + "diffLineNumber": { + "dark": "mocha_overlay0", + "light": "latte_overlay0" + }, + "diffAddedLineNumberBg": { + "dark": "mocha_diffAddBg", + "light": "latte_diffAddBg" + }, + "diffRemovedLineNumberBg": { + "dark": "mocha_diffDelBg", + "light": "latte_diffDelBg" + }, + "markdownText": { + "dark": "mocha_text", + "light": "latte_text" + }, + "markdownHeading": { + "dark": "mocha_mauve", + "light": "latte_mauve" + }, + "markdownLink": { + "dark": "mocha_sapphire", + "light": "latte_sapphire" + }, + "markdownLinkText": { + "dark": "mocha_blue", + "light": "latte_blue" + }, + "markdownCode": { + "dark": "mocha_green", + "light": "latte_green" + }, + "markdownBlockQuote": { + "dark": "mocha_subtext0", + "light": "latte_subtext0" + }, + "markdownEmph": { + "dark": "mocha_yellow", + "light": "latte_yellow" + }, + "markdownStrong": { + "dark": "mocha_peach", + "light": "latte_peach" + }, + "markdownHorizontalRule": { + "dark": "mocha_surface1", + "light": "latte_surface1" + }, + "markdownListItem": { + "dark": "mocha_mauve", + "light": "latte_mauve" + }, + "markdownListEnumeration": { + "dark": "mocha_mauve", + "light": "latte_mauve" + }, + "markdownImage": { + "dark": "mocha_sapphire", + "light": "latte_sapphire" + }, + "markdownImageText": { + "dark": "mocha_blue", + "light": "latte_blue" + }, + "markdownCodeBlock": { + "dark": "mocha_text", + "light": "latte_text" + }, + "syntaxComment": { + "dark": "mocha_overlay0", + "light": "latte_overlay0" + }, + "syntaxKeyword": { + "dark": "mocha_mauve", + "light": "latte_mauve" + }, + "syntaxFunction": { + "dark": "mocha_blue", + "light": "latte_blue" + }, + "syntaxVariable": { + "dark": "mocha_text", + "light": "latte_text" + }, + "syntaxString": { + "dark": "mocha_green", + "light": "latte_green" + }, + "syntaxNumber": { + "dark": "mocha_peach", + "light": "latte_peach" + }, + "syntaxType": { + "dark": "mocha_yellow", + "light": "latte_yellow" + }, + "syntaxOperator": { + "dark": "mocha_sky", + "light": "latte_sky" + }, + "syntaxPunctuation": { + "dark": "mocha_overlay2", + "light": "latte_overlay2" + } + } +} diff --git a/common/.config/opencode/tui-plugins/verbose.ts b/common/.config/opencode/tui-plugins/verbose.ts new file mode 100644 index 0000000..44f9931 --- /dev/null +++ b/common/.config/opencode/tui-plugins/verbose.ts @@ -0,0 +1,71 @@ +// opencode TUI plugin: one key toggles the whole "verbose view". +// +// The session view keeps four independent display toggles in the TUI's persisted KV store +// (~/.local/state/opencode/kv.json). Built-in commands only flip one each. This plugin reads all +// four and drives them to the same state: if every one is on, turn all off; otherwise turn all on. +// Writing the KV keys directly is what the built-in toggles do too (kv.signal is a reactive +// store), so the open session re-renders immediately and the choice survives restarts. +// +// Declared in tui.json: "plugin": [["./tui-plugins/verbose.ts", { "key": "v" }]] +// (TUI plugins are never auto-discovered, and this dir is deliberately NOT ~/.config/opencode/plugins/, +// which the server-side plugin loader scans and would reject a TUI-only module.) +// Also available as /verbose and from the command palette. +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" + +// KV keys + values as used by packages/tui/src/routes/session/index.tsx and context/thinking.ts (v1.18.29) +const KEYS = { + details: "tool_details_visibility", // boolean, default true + generic: "generic_tool_output_visibility", // boolean, default false + timestamps: "timestamps", // "show" | "hide", default "hide" + thinking: "thinking_mode", // "show" | "hide", default "hide" +} as const + +const COMMAND = "verbose.toggle" + +function current(api: TuiPluginApi) { + return { + details: api.kv.get(KEYS.details, true) === true, + generic: api.kv.get(KEYS.generic, false) === true, + timestamps: api.kv.get(KEYS.timestamps, "hide") === "show", + thinking: api.kv.get(KEYS.thinking, "hide") === "show", + } +} + +function apply(api: TuiPluginApi, on: boolean) { + api.kv.set(KEYS.details, on) + api.kv.set(KEYS.generic, on) + api.kv.set(KEYS.timestamps, on ? "show" : "hide") + api.kv.set(KEYS.thinking, on ? "show" : "hide") +} + +const tui: TuiPlugin = async (api, options) => { + const opts = (options ?? {}) as Record + const key = typeof opts.key === "string" && opts.key.length > 0 ? opts.key : "v" + + api.keymap.registerLayer({ + mode: "base", + commands: [ + { + name: COMMAND, + title: "Toggle verbose view (tool details, generic output, thinking, timestamps)", + category: "Session", + namespace: "palette", + slashName: "verbose", + run() { + const state = current(api) + const on = !Object.values(state).every(Boolean) + apply(api, on) + api.ui.toast({ + variant: "info", + message: on ? "Verbose view on" : "Verbose view off", + duration: 1500, + }) + }, + }, + ], + bindings: [{ key, cmd: COMMAND, desc: "Toggle verbose view" }], + }) +} + +const plugin: TuiPluginModule & { id: string } = { id: "coja.verbose", tui } +export default plugin diff --git a/common/.config/opencode/tui.json b/common/.config/opencode/tui.json index f05004f..c760569 100644 --- a/common/.config/opencode/tui.json +++ b/common/.config/opencode/tui.json @@ -1,4 +1,18 @@ { "$schema": "https://opencode.ai/tui.json", - "theme": "lucent-orng" + "theme": "lucent-orng", + "plugin": [["./tui-plugins/verbose.ts", { "key": "v" }]], + "leader_timeout": 1500, + "diff_style": "stacked", + "cursor": { "style": "block", "blinking": false }, + "attention": { "enabled": true, "notifications": true, "sound": false }, + "keybinds": { + "prompt_stash": "p", + "prompt_stash_pop": "o", + "session_fork": "f", + "messages_last_user": "k", + "display_thinking": "i", + "tool_details": "d", + "session_toggle_timestamps": "w" + } } diff --git a/common/.config/tmux/tmux.conf b/common/.config/tmux/tmux.conf index 47ebc43..7bd1d8f 100644 --- a/common/.config/tmux/tmux.conf +++ b/common/.config/tmux/tmux.conf @@ -130,7 +130,22 @@ bind S { # status-format[1] is tmux 3.7's default content template (status-left/windows/right). set -g status 2 set -g status-format[0] "#[fg=#6c7086,bg=default]#(yes ─ | head -n 400 | paste -sd '')" -set -g status-format[1] "#[align=left range=left #{E:status-left-style}]#[push-default]#{T;=/#{status-left-length}:status-left}#[pop-default]#[norange default]#[list=on align=#{status-justify}]#[list=left-marker]<#[list=right-marker]>#[list=on]#{W:#[range=window|#{window_index} #{E:window-status-style}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-format}#[pop-default]#[norange default]#{?loop_last_flag,,#{E:window-status-separator}},#[range=window|#{window_index} list=focus #{?#{!=:#{E:window-status-current-style},default},#{E:window-status-current-style},#{E:window-status-style}}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-current-format}#[pop-default]#[norange list=on default]#{?loop_last_flag,,#{E:window-status-separator}}}#[nolist align=right range=right #{E:status-right-style}]#[push-default]#{T;=/#{status-right-length}:status-right}#[pop-default]#[norange default]" +set -g status-format[1] "#[align=left range=left #{E:status-left-style}]#[push-default]#{T;=/#{status-left-length}:status-left}#[pop-default]#[norange default]#[list=on align=#{status-justify}]#[list=left-marker]<#[list=right-marker]>#[list=on]#{W:#[range=window|#{window_index} #{E:window-status-style}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-format}#[pop-default]#[norange default]#{?loop_last_flag,,#{E:window-status-separator}},#[range=window|#{window_index} list=focus #{?#{!=:#{E:window-status-current-style},default},#{E:window-status-current-style},#{E:window-status-style}}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-current-format}#[pop-default]#[norange list=on default]#{?loop_last_flag,,#{E:window-status-separator}}}#[range=user|newwin] #[fg=#6c7086]+#[default] #[norange]#[nolist align=right range=right #{E:status-right-style}]#[push-default]#{T;=/#{status-right-length}:status-right}#[pop-default]#[norange default]" + +# The `+` appended to the window list above sits in a `range=user|newwin` +# marker. Per tmux(1), a user range triggers the same `Status` mouse key as a +# window range and exposes its argument in #{mouse_status_range} — window ranges +# report the literal string "window" — so one binding can serve both: click a +# tab to select it (tmux's stock behaviour, kept in the else branch), click the +# `+` to open a new window. Padded with spaces on both sides so it's a wide +# enough target to hit with a thumb. +bind -n MouseDown1Status { + if -F '#{==:#{mouse_status_range},newwin}' { + new-window + } { + switch-client -t = + } +} # Round out the theme so no default green leaks into borders/messages/modes. set -g pane-border-style "fg=#313244" diff --git a/common/.pi/README.md b/common/.pi/README.md index e7ec67f..95f6a35 100644 --- a/common/.pi/README.md +++ b/common/.pi/README.md @@ -63,11 +63,16 @@ have a slash command to disable them for the current session without deleting. | `permission-gate.ts` | Prompts before dangerous `bash` commands (blocks outright with no UI). Local list covers git history rewrites, package managers, `systemctl` and irreversible disk ops on top of upstream's `rm -rf`/`sudo`/`chmod 777` | **adapted** from pi's `examples/extensions/permission-gate.ts` | delete file | | `protected-paths.ts` | Blocks `write`/`edit` to secrets and repo plumbing (`secrets.fish`, `auth.json`, `.env`, `.ssh/`, `.git/`, …) | **adapted** from pi's `examples/extensions/protected-paths.ts` | delete file | | `compaction-watch.ts` | Surfaces every compaction and its `reason`; loudly flags `overflow` (recovery rather than prevention), which means `reserveTokens` is below the model's `maxTokens` | **custom** | delete file | +| `verbose.ts` | `/verbose [on|off|status]` and **Alt+V** toggle the *persisted* verbose default (`hideThinkingBlock`, `showCacheMissNotices`) and report pi's live keys — `ctrl+o` tool output, `ctrl+t` thinking, which an extension cannot drive itself | **custom** | delete file; the live keys are built in and keep working | | `confirm-destructive.ts` | Confirms before session clear / switch / fork | **verbatim** from pi's `examples/extensions/confirm-destructive.ts` | delete file | | `session-name.ts` | `/session-name [name]` — friendly names in the session picker instead of the first message | **verbatim** from pi's `examples/extensions/session-name.ts` | delete file | | `todo.ts` | A `todo` tool the model calls (add/toggle/list/clear) plus `/todos` to view it; state lives in tool results, so forking rewinds it correctly. Distinct from plan-mode's checklist | **verbatim** from pi's `examples/extensions/todo.ts` | delete file (it also adds a tool schema to every request) | -> The bundled examples live at `/opt/pi-coding-agent/examples/extensions/` — that's +> The bundled examples live under pi's install prefix, which moves: it was +> `/opt/pi-coding-agent/` before 0.85 and is `/usr/lib/pi-coding-agent/` on current Arch +> packages. Rather than hardcode either, resolve it — +> `set -l PI_ROOT (dirname (readlink -f (which pi)))` — and read `$PI_ROOT/examples/extensions/`. +> That's > where `notify`/`questionnaire`/`plan-mode` came from. `plan-mode/` is now the only > **fork** left, so it's the only one to re-pull after a pi upgrade before re-applying > the local edits (its header documents them). `notify`/`questionnaire` carry no edits @@ -103,7 +108,8 @@ pi install npm:pi-vim > > ```fish > cd ~/.pi/agent/npm -> fnm exec --using=22 -- npm install --save-exact @earendil-works/pi-coding-agent@0.83.0 +> fnm exec --using=22 -- npm install --save-exact \ +> "@earendil-works/pi-coding-agent@"(pi --version | string trim) > ``` > > `pi install` deliberately doesn't pull peers (it would duplicate the runtime). Costs @@ -142,11 +148,11 @@ working diff). Both are **custom**. Format is YAML frontmatter + body with `keybindings.json` overrides three actions. **A user entry replaces the default keys for that action — it does not merge.** Action ids and their stock defaults are in -`/opt/pi-coding-agent/docs/keybindings.md`. +`$PI_ROOT/docs/keybindings.md` (see above for `$PI_ROOT`). ```jsonc { - "app.thinking.cycle": ["ctrl+shift+t"], // moved OFF Shift+Tab so plan-mode can bind it + "app.thinking.cycle": ["alt+t"], // moved OFF Shift+Tab so plan-mode can bind it (was ctrl+shift+t = kitty new_tab, never reached pi) "tui.editor.cursorUp": ["up", "ctrl+p"], // adds Ctrl+P as up (Emacs-style) "app.model.cycleForward": ["alt+p"] // Alt+P cycles models } diff --git a/common/.pi/agent/extensions/.upstream-baseline b/common/.pi/agent/extensions/.upstream-baseline new file mode 100644 index 0000000..bf1564e --- /dev/null +++ b/common/.pi/agent/extensions/.upstream-baseline @@ -0,0 +1,12 @@ +# Upstream example files as last reviewed against pi 0.85.1. +# Written by pi-upgrade-check -a. A file is only re-raised when UPSTREAM moves, +# so our own local edits stop generating noise on every run. +55502f82109df07e922ef3ec41af1b1a5ed33d4d6d56076654ab2bc88e2e0888 confirm-destructive.ts +70e4333e09ce00d546c116fd2e918abf7616c70b5da6e88ba8ee21a326afd483 notify.ts +e32086ba9dd9b4ba0fd308b65241d9affa0b5ecd3f90ef7ea1b9f2b30cab8cc1 permission-gate.ts +9ca66b1f3b1b9a61cc5ddc660f92cca2ea66c4264a1bdb6b00e0da5844399fe2 protected-paths.ts +3f1f719d81152ff013f5d36a97cc3976388d43f2d89bff0f19827d3443d903e6 questionnaire.ts +a0b9fd94e2cc827931150cc409238b473da635ab1a1cddce633cae5937a3ae24 session-name.ts +e46824d00217e25242c186d41837cc84ca81b23f978500323448502a9a424ee2 todo.ts +90fae53e2fada97165f7488520bd54f3cbfe5d2c7e4cfd3bd2aa07ee796e61e8 plan-mode/index.ts +12ea28d57cc55e68e8d52a2f1d16df8b2e7f2749cd694d41453028002a2bdad9 plan-mode/utils.ts diff --git a/common/.pi/agent/extensions/notify.ts b/common/.pi/agent/extensions/notify.ts index f7b1f81..9e91400 100644 --- a/common/.pi/agent/extensions/notify.ts +++ b/common/.pi/agent/extensions/notify.ts @@ -49,7 +49,9 @@ function notify(title: string, body: string): void { } export default function (pi: ExtensionAPI) { - pi.on("agent_end", async () => { + // `agent_end` fires after each low-level run; Pi may still retry, compact, + // or continue with queued follow-ups. Notify only after the full run settles. + pi.on("agent_settled", async () => { notify("Pi", "Ready for input"); }); } diff --git a/common/.pi/agent/extensions/verbose.ts b/common/.pi/agent/extensions/verbose.ts new file mode 100644 index 0000000..2ffe063 --- /dev/null +++ b/common/.pi/agent/extensions/verbose.ts @@ -0,0 +1,114 @@ +/** + * Verbose view toggle — `/verbose` and a bound key. + * + * The counterpart of the opencode TUI plugin and the Claude `/verbose` skill, adjusted for + * what pi actually allows. pi splits verbosity in two: + * + * LIVE `ctrl+o` (app.tools.expand) collapses/expands tool output and `ctrl+t` + * (app.thinking.toggle) does the same for thinking blocks. These are built-in + * keybindings. An extension cannot drive them — there is no settings API and no + * way to dispatch a built-in action — and a key maps to one action, so they + * cannot be merged into a single press the way opencode's four toggles were. + * + * PERSISTED `hideThinkingBlock` and `showCacheMissNotices` in settings.json, read at + * startup. That is what this toggles, i.e. the default you get on every new + * session, which is exactly the position the Claude skill is in. + * + * So: this sets the default and tells you the live keys. "on" shows thinking and the + * cache/compaction/recovery notices; "off" is deliberately quieter than stock pi (it hides + * thinking), matching how opencode's verbose-off state hides thinking too. + * + * ⚠️ pi writes settings.json itself (that is where `lastChangelogVersion` churn comes from), + * so a running pi may overwrite this on exit. If a toggle seems not to have stuck, re-run it + * after pi has exited. + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +type Settings = Record; + +const LIVE = "live now: ctrl+o tool output, ctrl+t thinking"; + +// Verbose-on values. Verbose-off is the inverse, not pi's defaults: stock pi already shows +// thinking (hideThinkingBlock defaults to false), so an "off" that merely restored defaults +// would be indistinguishable from "on" for the noisiest part. +const VERBOSE_ON: Settings = { hideThinkingBlock: false, showCacheMissNotices: true }; +const VERBOSE_OFF: Settings = { hideThinkingBlock: true, showCacheMissNotices: false }; + +function settingsPath(): string { + return join(getAgentDir(), "settings.json"); +} + +function read(): Settings { + // A parse failure must never lead to a write — that would replace a hand-edited config + // with whatever this extension thinks it knows. + const raw = readFileSync(settingsPath(), "utf-8"); + return JSON.parse(raw) as Settings; +} + +function isOn(s: Settings): boolean { + return s.hideThinkingBlock === false && s.showCacheMissNotices === true; +} + +function write(s: Settings): void { + // Plain writeFileSync, deliberately NOT write-temp-then-rename: this path is a stow + // symlink into the dots repo, and renaming over it would replace the link with a real + // file, silently detaching the live config from the repo. + writeFileSync(settingsPath(), `${JSON.stringify(s, null, 2)}\n`, "utf-8"); +} + +function describe(s: Settings): string { + return `hideThinkingBlock: ${s.hideThinkingBlock ?? false}, showCacheMissNotices: ${s.showCacheMissNotices ?? false}`; +} + +function toggle(ctx: ExtensionContext, mode: "on" | "off" | "toggle" | "status"): void { + let current: Settings; + try { + current = read(); + } catch (err) { + ctx.ui.notify(`verbose: cannot read settings.json — ${err}`, "error"); + return; + } + + if (mode === "status") { + ctx.ui.notify(`Verbose ${isOn(current) ? "ON" : "OFF"} — ${describe(current)}. ${LIVE}`, "info"); + return; + } + + const on = mode === "toggle" ? !isOn(current) : mode === "on"; + const next = { ...current, ...(on ? VERBOSE_ON : VERBOSE_OFF) }; + + try { + write(next); + } catch (err) { + ctx.ui.notify(`verbose: cannot write settings.json — ${err}`, "error"); + return; + } + + ctx.ui.notify( + `Verbose ${on ? "ON" : "OFF"} — applies to new sessions (settings are read at startup). ${LIVE}`, + "info", + ); +} + +export default function (pi: ExtensionAPI) { + pi.registerCommand("verbose", { + description: "Toggle the persisted verbose view (usage: /verbose [on|off|status])", + handler: async (args, ctx) => { + const raw = args.trim().toLowerCase(); + if (raw && raw !== "on" && raw !== "off" && raw !== "status") { + ctx.ui.notify("usage: /verbose [on|off|status]", "error"); + return; + } + toggle(ctx, (raw || "toggle") as "on" | "off" | "toggle" | "status"); + }, + }); + + pi.registerShortcut("alt+v", { + description: "Toggle verbose view", + handler: async (ctx) => toggle(ctx, "toggle"), + }); +} diff --git a/common/.pi/agent/keybindings.json b/common/.pi/agent/keybindings.json index f6903b9..baed800 100644 --- a/common/.pi/agent/keybindings.json +++ b/common/.pi/agent/keybindings.json @@ -1,5 +1,5 @@ { - "app.thinking.cycle": ["ctrl+shift+t"], + "app.thinking.cycle": ["alt+t"], "tui.editor.cursorUp": ["up", "ctrl+p"], "app.model.cycleForward": ["alt+p"] } diff --git a/common/.pi/agent/models.json b/common/.pi/agent/models.json index cfc5501..7558ea8 100644 --- a/common/.pi/agent/models.json +++ b/common/.pi/agent/models.json @@ -12,7 +12,7 @@ "models": [ { "id": "gemma-4-E4B-it-UD-Q8_K_XL", - "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs", + "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs (~57 t/s)", "reasoning": false, "input": ["text", "image"], "contextWindow": 65536, @@ -21,7 +21,7 @@ }, { "id": "gemma-4-26B-A4B-it-UD-IQ4_XS", - "name": "Gemma 4 26B · 24k · vision — quality generalist", + "name": "Gemma 4 26B · 24k · vision — quality generalist (~34 t/s)", "reasoning": false, "input": ["text", "image"], "contextWindow": 24576, @@ -30,7 +30,7 @@ }, { "id": "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL", - "name": "Qwen3 Coder 30B · 32k — main agent coder", + "name": "Qwen3 Coder 30B · 32k — main agent coder (~30 t/s)", "reasoning": false, "input": ["text"], "contextWindow": 32768, @@ -39,7 +39,7 @@ }, { "id": "Qwen3-Coder-Next-UD-IQ3_XXS", - "name": "Qwen3 Coder Next 80B · 128k — long sessions", + "name": "Qwen3 Coder Next 80B · 128k — long sessions (~16 t/s)", "reasoning": false, "input": ["text"], "contextWindow": 131072, @@ -48,7 +48,7 @@ }, { "id": "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS", - "name": "Qwen3.6 35B · 24k · vision — daily driver", + "name": "Qwen3.6 35B · 24k · vision — daily driver (~36 t/s)", "reasoning": false, "input": ["text", "image"], "contextWindow": 24576, @@ -57,7 +57,7 @@ }, { "id": "Qwen3.6-35B-A3B-Thinking", - "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems", + "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems (~39 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text", "image"], @@ -67,7 +67,7 @@ }, { "id": "Qwen3.5-9B-UD-Q6_K_XL", - "name": "Qwen3.5 9B · 32k · vision — quick tasks", + "name": "Qwen3.5 9B · 32k · vision — quick tasks (~32 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text", "image"], @@ -77,17 +77,17 @@ }, { "id": "Qwen3.8-27B-UD-IQ3_XXS", - "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner", + "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner, quiet desktop only (~15 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text", "image"], "contextWindow": 24576, - "maxTokens": 8192, + "maxTokens": 4096, "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } }, { "id": "GLM-4.7-Flash-UD-Q4_K_XL", - "name": "GLM-4.7 Flash · 24k — quality coder", + "name": "GLM-4.7 Flash · 24k — quality coder (~21 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text"], @@ -97,7 +97,7 @@ }, { "id": "gpt-oss-20b", - "name": "gpt-oss 20B · 64k — fast reasoning + tools", + "name": "gpt-oss 20B · 64k — fast reasoning + tools (~38 t/s)", "reasoning": true, "input": ["text"], "contextWindow": 65536, @@ -106,7 +106,7 @@ }, { "id": "gpt-oss-20b-low", - "name": "gpt-oss 20B low · 64k — snappy answers", + "name": "gpt-oss 20B low · 64k — snappy answers (~37 t/s)", "reasoning": true, "input": ["text"], "contextWindow": 65536, @@ -127,7 +127,7 @@ "models": [ { "id": "gemma-4-E4B-it-UD-Q8_K_XL", - "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs", + "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs (~57 t/s)", "reasoning": false, "input": ["text", "image"], "contextWindow": 65536, @@ -136,7 +136,7 @@ }, { "id": "gemma-4-26B-A4B-it-UD-IQ4_XS", - "name": "Gemma 4 26B · 24k · vision — quality generalist", + "name": "Gemma 4 26B · 24k · vision — quality generalist (~34 t/s)", "reasoning": false, "input": ["text", "image"], "contextWindow": 24576, @@ -145,7 +145,7 @@ }, { "id": "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL", - "name": "Qwen3 Coder 30B · 32k — main agent coder", + "name": "Qwen3 Coder 30B · 32k — main agent coder (~30 t/s)", "reasoning": false, "input": ["text"], "contextWindow": 32768, @@ -154,7 +154,7 @@ }, { "id": "Qwen3-Coder-Next-UD-IQ3_XXS", - "name": "Qwen3 Coder Next 80B · 128k — long sessions", + "name": "Qwen3 Coder Next 80B · 128k — long sessions (~16 t/s)", "reasoning": false, "input": ["text"], "contextWindow": 131072, @@ -163,7 +163,7 @@ }, { "id": "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS", - "name": "Qwen3.6 35B · 24k · vision — daily driver", + "name": "Qwen3.6 35B · 24k · vision — daily driver (~36 t/s)", "reasoning": false, "input": ["text", "image"], "contextWindow": 24576, @@ -172,7 +172,7 @@ }, { "id": "Qwen3.6-35B-A3B-Thinking", - "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems", + "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems (~39 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text", "image"], @@ -182,7 +182,7 @@ }, { "id": "Qwen3.5-9B-UD-Q6_K_XL", - "name": "Qwen3.5 9B · 32k · vision — quick tasks", + "name": "Qwen3.5 9B · 32k · vision — quick tasks (~32 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text", "image"], @@ -192,17 +192,17 @@ }, { "id": "Qwen3.8-27B-UD-IQ3_XXS", - "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner", + "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner, quiet desktop only (~15 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text", "image"], "contextWindow": 24576, - "maxTokens": 8192, + "maxTokens": 4096, "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } }, { "id": "GLM-4.7-Flash-UD-Q4_K_XL", - "name": "GLM-4.7 Flash · 24k — quality coder", + "name": "GLM-4.7 Flash · 24k — quality coder (~21 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text"], @@ -212,7 +212,7 @@ }, { "id": "gpt-oss-20b", - "name": "gpt-oss 20B · 64k — fast reasoning + tools", + "name": "gpt-oss 20B · 64k — fast reasoning + tools (~38 t/s)", "reasoning": true, "input": ["text"], "contextWindow": 65536, @@ -221,7 +221,7 @@ }, { "id": "gpt-oss-20b-low", - "name": "gpt-oss 20B low · 64k — snappy answers", + "name": "gpt-oss 20B low · 64k — snappy answers (~37 t/s)", "reasoning": true, "input": ["text"], "contextWindow": 65536, diff --git a/common/.pi/agent/settings.json b/common/.pi/agent/settings.json index bc4bdc0..ffdb8d9 100644 --- a/common/.pi/agent/settings.json +++ b/common/.pi/agent/settings.json @@ -58,7 +58,7 @@ "ex": "mode" } }, - "lastChangelogVersion": "0.83.0", + "lastChangelogVersion": "0.85.1", "packages": [ "npm:pi-vim" ] diff --git a/fl/.config/hypr/keybindings.conf b/fl/.config/hypr/keybindings.conf index 4c0d632..95b0b0b 100644 --- a/fl/.config/hypr/keybindings.conf +++ b/fl/.config/hypr/keybindings.conf @@ -42,7 +42,7 @@ bind = $mainMod, F, exec, $yt # launch yt alternative bind = $mainMod, B, exec, $browser # launch web browser bind = $mainMod, G, exec, gajim # launch messanger bind = $mainMod, K, exec, keepassxc # launch password manager -bindd = $mainMod, N, Neovim, exec, $term -e nvim +# bindd = $mainMod, N, Neovim, exec, $term -e nvim bindd = $mainMod, R, $d pyradio, exec, kitty --session ~/.config/kitty/music.conf bindd = Ctrl+Shift, Escape, Btop, exec, kitty -e btop bindd = Ctrl+Alt, Delete, Btop, exec, kitty -e btop diff --git a/gui/.config/fish/conf.d/hyde-path.fish b/gui/.config/fish/conf.d/hyde-path.fish new file mode 100644 index 0000000..875ddb5 --- /dev/null +++ b/gui/.config/fish/conf.d/hyde-path.fish @@ -0,0 +1,9 @@ +## gui package — put HyDE's script dir on the *session* PATH: the compositor and +## everything it spawns directly (raw exec binds, terminals) resolve bare hyde +## script names through it. Nothing else adds it — HyDE only self-bootstraps PATH +## inside its own wrappers (hyde-shell/app.sh), so a bare script name execed +## outside that lineage is "command not found" (TROUBLESHOOTING.md symptom 8). +## The systemd-unit side is covered by the twin +## gui/.config/environment.d/10-hyde-path.conf — both are needed. +## No ordering constraint vs hyde.fish (it never touches this dir). +fish_add_path --global ~/.local/lib/hyde diff --git a/gui/.config/fish/conf.d/waybar.fish b/gui/.config/fish/conf.d/waybar.fish deleted file mode 100644 index 029ff1e..0000000 --- a/gui/.config/fish/conf.d/waybar.fish +++ /dev/null @@ -1,8 +0,0 @@ -## gui package — waybar helpers. Lives in conf.d/ (fish sources every conf.d/*.fish), -## NOT in host.fish: each host package ships its own host.fish, which stow deploys last -## with --override — a gui/host.fish would be shadowed on every host that has one. - -# Reload the custom waybar (kill + relaunch, detached so it survives this shell). -# Same invocation as userprefs.conf exec-once / the Ctrl+Alt+W bind. Killing waybar -# also stops its cava child (cava-waybar.sh is a child of the bar). -abbr wbar 'killall waybar; setsid -f waybar -c ~/.config/waybar/layouts/custom.jsonc -s ~/.config/waybar/layouts/custom.css' diff --git a/gui/.config/fish/conf.d/zz-unhyde-prompt.fish b/gui/.config/fish/conf.d/zz-unhyde-prompt.fish index f28f6f1..fde0293 100644 --- a/gui/.config/fish/conf.d/zz-unhyde-prompt.fish +++ b/gui/.config/fish/conf.d/zz-unhyde-prompt.fish @@ -18,7 +18,7 @@ ## Nothing else from hyde.fish is undone here: its aliases lose to config.fish, ## which fish loads after conf.d, and its `df` wrapper is a function we do not ## otherwise define. Delete this file if you ever decide you want starship. -for f in fish_prompt fish_right_prompt +for f in fish_prompt fish_right_prompt fish_mode_prompt if test -r $__fish_config_dir/functions/$f.fish source $__fish_config_dir/functions/$f.fish end diff --git a/gui/.config/fish/functions/fish_mode_prompt.fish b/gui/.config/fish/functions/fish_mode_prompt.fish new file mode 100644 index 0000000..ff0255b --- /dev/null +++ b/gui/.config/fish/functions/fish_mode_prompt.fish @@ -0,0 +1,3 @@ +function fish_mode_prompt --description "Displays the current vi mode" + fish_default_mode_prompt +end diff --git a/gui/.config/hypr/keybindings.conf b/gui/.config/hypr/keybindings.conf index 5e43c82..928b300 100644 --- a/gui/.config/hypr/keybindings.conf +++ b/gui/.config/hypr/keybindings.conf @@ -117,9 +117,6 @@ bindd = $mainMod Shift, T, $d terminal emulator , exec, alacritty bindd = Control Alt, T, $d terminal emulator , exec, $TERMINAL # bindd = Control Shift, Alt, T, $d terminal emulator , exec, tilix # launch tilix terminal emulator - using for ssh bindd = $mainMod Control, T, $d terminal emulator, exec, tilix # launch tilix terminal emulator -# bindd = $mainMod, R, $d pyradio, exec, $TERMINAL pyradio #radio -bindd = $mainMod, I, $d work, exec, kitty --session ~/.config/kitty/c2.conf -bindd = $mainMod, O, $d work, exec, kitty --session ~/.config/kitty/runners.conf bindd = $mainMod, R, $d pyradio, exec, kitty --session ~/.config/kitty/music.conf bindd = $mainMod, E, $d file explorer , exec, $EXPLORER bindd = $mainMod, C, $d neovim, exec, $TERMINAL nvim #editor diff --git a/gui/.config/waybar/layouts/custom.css b/gui/.config/waybar/layouts/custom.css index 9b6e7fb..f589e65 100644 --- a/gui/.config/waybar/layouts/custom.css +++ b/gui/.config/waybar/layouts/custom.css @@ -200,13 +200,13 @@ window#waybar.empty #custom-sr_end { } window#waybar.cavabar { - background: transparent; + /* background: transparent; */ + background: alpha(@main-bg, 0.1); } window#waybar.cavabar #custom-cava { font-size: 24px; - color: alpha(@main-fg, 0.7); - background: rgba(0, 0, 0, 0.2); + color: alpha(@main-fg, 0.5); margin: 0px; padding: 0px; } diff --git a/lw/.config/aichat/config.yaml b/lw/.config/aichat/config.yaml index c7dbe0a..9c41d57 100644 --- a/lw/.config/aichat/config.yaml +++ b/lw/.config/aichat/config.yaml @@ -48,34 +48,35 @@ clients: name: local api_base: http://192.168.0.204:11343/v1 models: &lan_models + # Speeds are benched decode t/s (clean-night sweeps) — see fl/.config/llamacpp/README.md roster. - name: Qwen3-Coder-30B-Instruct-UD-Q3_K_XL - max_input_tokens: 30000 # ctx 32768 + max_input_tokens: 30000 # ctx 32768 · ~30 t/s — main agent coder - name: Qwen3-Coder-Next-UD-IQ3_XXS - max_input_tokens: 128000 # ctx 131072 + max_input_tokens: 128000 # ctx 131072 · ~16 t/s — long sessions (128k) - name: Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS - max_input_tokens: 22000 # ctx 24576 + max_input_tokens: 22000 # ctx 24576 · ~36 t/s — daily driver supports_vision: true - name: Qwen3.6-35B-A3B-Thinking - max_input_tokens: 22000 # ctx 24576 + max_input_tokens: 22000 # ctx 24576 · ~39 t/s — hard problems supports_vision: true - name: Qwen3.5-9B-UD-Q6_K_XL - max_input_tokens: 30000 # ctx 32768 + max_input_tokens: 30000 # ctx 32768 · ~32 t/s — quick tasks supports_vision: true - name: Qwen3.8-27B-UD-IQ3_XXS - max_input_tokens: 22000 # ctx 24576 + max_input_tokens: 22000 # ctx 24576 · ~15 t/s — hybrid reasoner, quiet desktop only supports_vision: true - name: gemma-4-26B-A4B-it-UD-IQ4_XS - max_input_tokens: 22000 # ctx 24576 + max_input_tokens: 22000 # ctx 24576 · ~34 t/s — quality generalist, best vision supports_vision: true - name: gemma-4-E4B-it-UD-Q8_K_XL - max_input_tokens: 62000 # ctx 65536 + max_input_tokens: 62000 # ctx 65536 · ~57 t/s — fast generalist, long docs supports_vision: true - name: GLM-4.7-Flash-UD-Q4_K_XL - max_input_tokens: 22000 # ctx 24576 + max_input_tokens: 22000 # ctx 24576 · ~21 t/s — quality coder - name: gpt-oss-20b - max_input_tokens: 62000 # ctx 65536 + max_input_tokens: 62000 # ctx 65536 · ~38 t/s — fast reasoning + tools - name: gpt-oss-20b-low - max_input_tokens: 62000 # ctx 65536 + max_input_tokens: 62000 # ctx 65536 · ~37 t/s — snappy answers # Remote access via duskadiy.com (reachable from anywhere; requires an API key — # $DUSKADIY_API_KEY, from the gitignored conf.d/secrets.fish). The LAN endpoint above is keyless. # Same server, same model ids — reuse the list above via a YAML anchor. diff --git a/lw/.config/opencode/opencode.json b/lw/.config/opencode/opencode.json index 4d86ae9..e767200 100644 --- a/lw/.config/opencode/opencode.json +++ b/lw/.config/opencode/opencode.json @@ -1,10 +1,11 @@ { "$schema": "https://opencode.ai/config.json", "model": "llama.cpp/gemma-4-26B-A4B-it-UD-IQ4_XS", - "small_model": "llama.cpp/gemma-4-E4B-it-UD-Q8_K_XL", "instructions": ["CLAUDE.md", "AGENTS.md"], "share": "disabled", "autoupdate": "notify", + "tool_output": { "max_lines": 200, "max_bytes": 16384 }, + "snapshot": false, "disabled_providers": [ "gemini", "anthropic", @@ -17,6 +18,7 @@ "webfetch": "ask", "websearch": "ask", "external_directory": "ask", + "question": "allow", "read": { "*": "allow", "**/.env*": "deny", @@ -133,7 +135,8 @@ "npm": "@ai-sdk/openai-compatible", "name": "llamacpp (lw local)", "options": { - "baseURL": "http://127.0.0.1:11343/v1" + "baseURL": "http://127.0.0.1:11343/v1", + "timeout": 3600000 }, "models": { "gemma-4-E2B-it-UD-Q4_K_XL": { "name": "Gemma 4 E2B · 4k — fastest on lw (~6 t/s)", "limit": { "context": 4096, "output": 1024 } }, @@ -150,46 +153,51 @@ "npm": "@ai-sdk/openai-compatible", "name": "DuskaDIY", "options": { - "baseURL": "https://llm.duskadiy.com/api/v1" + "baseURL": "https://llm.duskadiy.com/api/v1", + "apiKey": "{env:DUSKADIY_API_KEY}", + "timeout": 3600000 }, "models": { - "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL": { "name": "Qwen3 Coder 30B · 32k — main agent coder (remote)", "limit": { "context": 32768, "output": 4096 } }, - "Qwen3-Coder-Next-UD-IQ3_XXS": { "name": "Qwen3 Coder Next 80B · 128k — long sessions (remote)", "limit": { "context": 131072, "output": 16384 } }, - "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS": { "name": "Qwen3.6 35B · 24k · vision — daily driver (remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, - "Qwen3.6-35B-A3B-Thinking": { "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems (remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, - "Qwen3.5-9B-UD-Q6_K_XL": { "name": "Qwen3.5 9B · 32k · vision — quick tasks (remote)", "attachment": true, "limit": { "context": 32768, "output": 8192 } }, - "Qwen3.8-27B-UD-IQ3_XXS": { "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner (remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, - "gemma-4-26B-A4B-it-UD-IQ4_XS": { "name": "Gemma 4 26B · 24k · vision — quality generalist (remote)", "attachment": true, "limit": { "context": 24576, "output": 4096 } }, - "gemma-4-E4B-it-UD-Q8_K_XL": { "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs (remote)", "attachment": true, "limit": { "context": 65536, "output": 16384 } }, - "GLM-4.7-Flash-UD-Q4_K_XL": { "name": "GLM-4.7 Flash · 24k — quality coder (remote)", "limit": { "context": 24576, "output": 8192 } }, - "gpt-oss-20b": { "name": "gpt-oss 20B · 64k — fast reasoning + tools (remote)", "limit": { "context": 65536, "output": 8192 } }, - "gpt-oss-20b-low": { "name": "gpt-oss 20B low · 64k — snappy answers (remote)", "limit": { "context": 65536, "output": 8192 } } + "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL": { "name": "Qwen3 Coder 30B · 32k — main agent coder (~30 t/s, remote)", "limit": { "context": 32768, "output": 4096 } }, + "Qwen3-Coder-Next-UD-IQ3_XXS": { "name": "Qwen3 Coder Next 80B · 128k — long sessions (~16 t/s, remote)", "limit": { "context": 131072, "output": 16384 } }, + "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS": { "name": "Qwen3.6 35B · 24k · vision — daily driver (~36 t/s, remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, + "Qwen3.6-35B-A3B-Thinking": { "reasoning": true, "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems (~39 t/s, remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, + "Qwen3.5-9B-UD-Q6_K_XL": { "reasoning": true, "name": "Qwen3.5 9B · 32k · vision — quick tasks (~32 t/s, remote)", "attachment": true, "limit": { "context": 32768, "output": 8192 } }, + "Qwen3.8-27B-UD-IQ3_XXS": { "reasoning": true, "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner, quiet desktop only (~15 t/s, remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, + "gemma-4-26B-A4B-it-UD-IQ4_XS": { "name": "Gemma 4 26B · 24k · vision — quality generalist (~34 t/s, remote)", "attachment": true, "limit": { "context": 24576, "output": 4096 } }, + "gemma-4-E4B-it-UD-Q8_K_XL": { "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs (~57 t/s, remote)", "attachment": true, "limit": { "context": 65536, "output": 16384 } }, + "GLM-4.7-Flash-UD-Q4_K_XL": { "reasoning": true, "interleaved": "reasoning_content", "name": "GLM-4.7 Flash · 24k — quality coder (~21 t/s, remote)", "limit": { "context": 24576, "output": 8192 } }, + "gpt-oss-20b": { "reasoning": true, "name": "gpt-oss 20B · 64k — fast reasoning + tools (~38 t/s, remote)", "limit": { "context": 65536, "output": 8192 } }, + "gpt-oss-20b-low": { "reasoning": true, "name": "gpt-oss 20B low · 64k — snappy answers (~37 t/s, remote)", "limit": { "context": 65536, "output": 8192 } } } }, "llama.cpp": { "npm": "@ai-sdk/openai-compatible", "name": "llamacpp", "options": { - "baseURL": "http://192.168.0.204:11343/v1" + "baseURL": "http://192.168.0.204:11343/v1", + "timeout": 3600000 }, "models": { - "Qwen3-Coder-Next-UD-IQ3_XXS": { "name": "Qwen3 Coder Next 80B · 128k — long sessions", "limit": { "context": 131072, "output": 16384 } }, - "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL": { "name": "Qwen3 Coder 30B · 32k — main agent coder", "limit": { "context": 32768, "output": 4096 } }, - "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS": { "name": "Qwen3.6 35B · 24k · vision — daily driver", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, - "Qwen3.6-35B-A3B-Thinking": { "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, - "Qwen3.5-9B-UD-Q6_K_XL": { "name": "Qwen3.5 9B · 32k · vision — quick tasks", "attachment": true, "limit": { "context": 32768, "output": 8192 } }, - "Qwen3.8-27B-UD-IQ3_XXS": { "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, - "gemma-4-26B-A4B-it-UD-IQ4_XS": { "name": "Gemma 4 26B · 24k · vision — quality generalist", "attachment": true, "limit": { "context": 24576, "output": 4096 } }, - "gemma-4-E4B-it-UD-Q8_K_XL": { "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs", "attachment": true, "limit": { "context": 65536, "output": 16384 } }, - "GLM-4.7-Flash-UD-Q4_K_XL": { "name": "GLM-4.7 Flash · 24k — quality coder", "limit": { "context": 24576, "output": 8192 } }, - "gpt-oss-20b": { "name": "gpt-oss 20B · 64k — fast reasoning + tools", "limit": { "context": 65536, "output": 8192 } }, - "gpt-oss-20b-low": { "name": "gpt-oss 20B low · 64k — snappy answers", "limit": { "context": 65536, "output": 8192 } } + "Qwen3-Coder-Next-UD-IQ3_XXS": { "name": "Qwen3 Coder Next 80B · 128k — long sessions (~16 t/s)", "limit": { "context": 131072, "output": 16384 } }, + "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL": { "name": "Qwen3 Coder 30B · 32k — main agent coder (~30 t/s)", "limit": { "context": 32768, "output": 4096 } }, + "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS": { "name": "Qwen3.6 35B · 24k · vision — daily driver (~36 t/s)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, + "Qwen3.6-35B-A3B-Thinking": { "reasoning": true, "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems (~39 t/s)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, + "Qwen3.5-9B-UD-Q6_K_XL": { "reasoning": true, "name": "Qwen3.5 9B · 32k · vision — quick tasks (~32 t/s)", "attachment": true, "limit": { "context": 32768, "output": 8192 } }, + "Qwen3.8-27B-UD-IQ3_XXS": { "reasoning": true, "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner, quiet desktop only (~15 t/s)", "attachment": true, "limit": { "context": 24576, "output": 8192 } }, + "gemma-4-26B-A4B-it-UD-IQ4_XS": { "name": "Gemma 4 26B · 24k · vision — quality generalist (~34 t/s)", "attachment": true, "limit": { "context": 24576, "output": 4096 } }, + "gemma-4-E4B-it-UD-Q8_K_XL": { "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs (~57 t/s)", "attachment": true, "limit": { "context": 65536, "output": 16384 } }, + "GLM-4.7-Flash-UD-Q4_K_XL": { "reasoning": true, "interleaved": "reasoning_content", "name": "GLM-4.7 Flash · 24k — quality coder (~21 t/s)", "limit": { "context": 24576, "output": 8192 } }, + "gpt-oss-20b": { "reasoning": true, "name": "gpt-oss 20B · 64k — fast reasoning + tools (~38 t/s)", "limit": { "context": 65536, "output": 8192 } }, + "gpt-oss-20b-low": { "reasoning": true, "name": "gpt-oss 20B low · 64k — snappy answers (~37 t/s)", "limit": { "context": 65536, "output": 8192 } } } } }, "agent": { "plan": { "mode": "primary", + "color": "info", + "steps": 30, "model": "llama.cpp/gemma-4-26B-A4B-it-UD-IQ4_XS", "permission": { "edit": "deny" @@ -197,6 +205,8 @@ }, "build": { "mode": "primary", + "color": "primary", + "steps": 30, "model": "llama.cpp/gemma-4-26B-A4B-it-UD-IQ4_XS", "permission": { "edit": { @@ -214,11 +224,108 @@ } } }, + "auto": { + "description": "Auto mode: acts without asking; only irreversible or outward-facing actions ask", + "mode": "primary", + "color": "#f9e2af", + "steps": 30, + "model": "llama.cpp/gemma-4-26B-A4B-it-UD-IQ4_XS", + "permission": { + "edit": { + "*": "allow", + "**/.env*": "deny", + "**/*.env": "deny", + "**/*.pem": "deny", + "**/*.key": "deny", + "**/id_rsa*": "deny", + "**/.ssh/**": "deny", + "**/.aws/**": "deny", + "**/.git/**": "deny", + "**/.npmrc": "deny", + "**/.netrc": "deny" + }, + "webfetch": "allow", + "websearch": "allow", + "external_directory": "ask", + "question": "allow", + "bash": { + "*": "allow", + + "git *": "allow", + "git commit*": "ask", + "git push*": "ask", + "git reset*": "ask", + "git checkout*": "ask", + "git switch*": "ask", + "git restore*": "ask", + "git rebase*": "ask", + "git merge*": "ask", + "git cherry-pick*": "ask", + "git stash*": "ask", + "git clean*": "ask", + "git branch -*": "ask", + "git tag*": "ask", + "git remote add*": "ask", + "git remote remove*": "ask", + "git remote set-url*": "ask", + + "rm *": "ask", + "rmdir *": "ask", + "kill *": "ask", + "pkill *": "ask", + "killall *": "ask", + "systemctl *": "ask", + "curl *": "ask", + "wget *": "ask", + "ssh *": "ask", + "scp *": "ask", + "rsync *": "ask", + + "npm install*": "ask", + "npm i": "ask", + "npm i *": "ask", + "npm ci*": "ask", + "pnpm install*": "ask", + "pnpm i": "ask", + "pnpm i *": "ask", + "pnpm add*": "ask", + "yarn add*": "ask", + "yarn install*": "ask", + "pip install*": "ask", + "pip3 install*": "ask", + "pipx install*": "ask", + "cargo install*": "ask", + "go install*": "ask", + "gem install*": "ask", + "yay *": "ask", + + "sudo *": "deny", + "doas *": "deny", + "dd *": "deny", + "mkfs*": "deny", + "shred *": "deny", + "chmod -R *": "deny", + "rm -rf /*": "deny", + "rm -rf ~*": "deny", + + "cat .env*": "deny", + "cat *.env": "deny", + "cat *.pem": "deny", + "cat *.key": "deny", + "cat *id_rsa*": "deny", + "cat *secret*": "deny", + "cat *credential*": "deny", + "cat ~/.ssh/*": "deny", + "cat ~/.aws/*": "deny", + "grep .env*": "deny", + "grep * .env*": "deny" + } + } + }, "code-reviewer": { "description": "Reviews code for best practices and potential issues", "prompt": "You are a code reviewer. Focus on security, performance, and maintainability.", "mode": "subagent", - "model": "llama.cpp/GLM-4.7-Flash-UD-Q4_K_XL", "permission": { "edit": "deny" } @@ -227,7 +334,7 @@ "description": "Runs and interprets test suites", "prompt": "You are a test runner. Focus on identifying failing tests and providing clear reproduction steps.", "mode": "subagent", - "model": "llama.cpp/GLM-4.7-Flash-UD-Q4_K_XL", + "hidden": true, "permission": { "edit": "deny" } @@ -236,7 +343,6 @@ "description": "Maintains project documentation and docstrings", "prompt": "You are a technical writer. Focus on clarity, accuracy, and keeping documentation in sync with code changes.", "mode": "subagent", - "model": "llama.cpp/gemma-4-26B-A4B-it-UD-IQ4_XS", "permission": { "edit": { "*": "deny", @@ -252,7 +358,7 @@ "description": "Scans for security vulnerabilities and hardcoded secrets", "prompt": "You are a security auditor. Focus on OWASP principles, secret detection, and dependency vulnerabilities.", "mode": "subagent", - "model": "llama.cpp/GLM-4.7-Flash-UD-Q4_K_XL", + "hidden": true, "permission": { "edit": "deny" } diff --git a/lw/.pi/agent/models.json b/lw/.pi/agent/models.json index 751d456..f0a1de4 100644 --- a/lw/.pi/agent/models.json +++ b/lw/.pi/agent/models.json @@ -97,7 +97,7 @@ "models": [ { "id": "gemma-4-E4B-it-UD-Q8_K_XL", - "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs", + "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs (~57 t/s)", "reasoning": false, "input": ["text", "image"], "contextWindow": 65536, @@ -106,7 +106,7 @@ }, { "id": "gemma-4-26B-A4B-it-UD-IQ4_XS", - "name": "Gemma 4 26B · 24k · vision — quality generalist", + "name": "Gemma 4 26B · 24k · vision — quality generalist (~34 t/s)", "reasoning": false, "input": ["text", "image"], "contextWindow": 24576, @@ -115,7 +115,7 @@ }, { "id": "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL", - "name": "Qwen3 Coder 30B · 32k — main agent coder", + "name": "Qwen3 Coder 30B · 32k — main agent coder (~30 t/s)", "reasoning": false, "input": ["text"], "contextWindow": 32768, @@ -124,7 +124,7 @@ }, { "id": "Qwen3-Coder-Next-UD-IQ3_XXS", - "name": "Qwen3 Coder Next 80B · 128k — long sessions", + "name": "Qwen3 Coder Next 80B · 128k — long sessions (~16 t/s)", "reasoning": false, "input": ["text"], "contextWindow": 131072, @@ -133,7 +133,7 @@ }, { "id": "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS", - "name": "Qwen3.6 35B · 24k · vision — daily driver", + "name": "Qwen3.6 35B · 24k · vision — daily driver (~36 t/s)", "reasoning": false, "input": ["text", "image"], "contextWindow": 24576, @@ -142,7 +142,7 @@ }, { "id": "Qwen3.6-35B-A3B-Thinking", - "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems", + "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems (~39 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text", "image"], @@ -152,7 +152,7 @@ }, { "id": "Qwen3.5-9B-UD-Q6_K_XL", - "name": "Qwen3.5 9B · 32k · vision — quick tasks", + "name": "Qwen3.5 9B · 32k · vision — quick tasks (~32 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text", "image"], @@ -162,7 +162,7 @@ }, { "id": "Qwen3.8-27B-UD-IQ3_XXS", - "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner", + "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner, quiet desktop only (~15 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text", "image"], @@ -172,7 +172,7 @@ }, { "id": "GLM-4.7-Flash-UD-Q4_K_XL", - "name": "GLM-4.7 Flash · 24k — quality coder", + "name": "GLM-4.7 Flash · 24k — quality coder (~21 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text"], @@ -182,7 +182,7 @@ }, { "id": "gpt-oss-20b", - "name": "gpt-oss 20B · 64k — fast reasoning + tools", + "name": "gpt-oss 20B · 64k — fast reasoning + tools (~38 t/s)", "reasoning": true, "input": ["text"], "contextWindow": 65536, @@ -191,7 +191,7 @@ }, { "id": "gpt-oss-20b-low", - "name": "gpt-oss 20B low · 64k — snappy answers", + "name": "gpt-oss 20B low · 64k — snappy answers (~37 t/s)", "reasoning": true, "input": ["text"], "contextWindow": 65536, @@ -212,7 +212,7 @@ "models": [ { "id": "gemma-4-E4B-it-UD-Q8_K_XL", - "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs", + "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs (~57 t/s)", "reasoning": false, "input": ["text", "image"], "contextWindow": 65536, @@ -221,7 +221,7 @@ }, { "id": "gemma-4-26B-A4B-it-UD-IQ4_XS", - "name": "Gemma 4 26B · 24k · vision — quality generalist", + "name": "Gemma 4 26B · 24k · vision — quality generalist (~34 t/s)", "reasoning": false, "input": ["text", "image"], "contextWindow": 24576, @@ -230,7 +230,7 @@ }, { "id": "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL", - "name": "Qwen3 Coder 30B · 32k — main agent coder", + "name": "Qwen3 Coder 30B · 32k — main agent coder (~30 t/s)", "reasoning": false, "input": ["text"], "contextWindow": 32768, @@ -239,7 +239,7 @@ }, { "id": "Qwen3-Coder-Next-UD-IQ3_XXS", - "name": "Qwen3 Coder Next 80B · 128k — long sessions", + "name": "Qwen3 Coder Next 80B · 128k — long sessions (~16 t/s)", "reasoning": false, "input": ["text"], "contextWindow": 131072, @@ -248,7 +248,7 @@ }, { "id": "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS", - "name": "Qwen3.6 35B · 24k · vision — daily driver", + "name": "Qwen3.6 35B · 24k · vision — daily driver (~36 t/s)", "reasoning": false, "input": ["text", "image"], "contextWindow": 24576, @@ -257,7 +257,7 @@ }, { "id": "Qwen3.6-35B-A3B-Thinking", - "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems", + "name": "Qwen3.6 35B Thinking · 24k · vision — hard problems (~39 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text", "image"], @@ -267,7 +267,7 @@ }, { "id": "Qwen3.5-9B-UD-Q6_K_XL", - "name": "Qwen3.5 9B · 32k · vision — quick tasks", + "name": "Qwen3.5 9B · 32k · vision — quick tasks (~32 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text", "image"], @@ -277,7 +277,7 @@ }, { "id": "Qwen3.8-27B-UD-IQ3_XXS", - "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner", + "name": "Qwen3.8 27B · 24k · vision — hybrid reasoner, quiet desktop only (~15 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text", "image"], @@ -287,7 +287,7 @@ }, { "id": "GLM-4.7-Flash-UD-Q4_K_XL", - "name": "GLM-4.7 Flash · 24k — quality coder", + "name": "GLM-4.7 Flash · 24k — quality coder (~21 t/s)", "reasoning": true, "compat": { "thinkingFormat": "qwen-chat-template" }, "input": ["text"], @@ -297,7 +297,7 @@ }, { "id": "gpt-oss-20b", - "name": "gpt-oss 20B · 64k — fast reasoning + tools", + "name": "gpt-oss 20B · 64k — fast reasoning + tools (~38 t/s)", "reasoning": true, "input": ["text"], "contextWindow": 65536, @@ -306,7 +306,7 @@ }, { "id": "gpt-oss-20b-low", - "name": "gpt-oss 20B low · 64k — snappy answers", + "name": "gpt-oss 20B low · 64k — snappy answers (~37 t/s)", "reasoning": true, "input": ["text"], "contextWindow": 65536, diff --git a/lw/.pi/agent/settings.json b/lw/.pi/agent/settings.json index 9784038..14de621 100644 --- a/lw/.pi/agent/settings.json +++ b/lw/.pi/agent/settings.json @@ -4,6 +4,7 @@ "defaultThinkingLevel": "off", "theme": "catppuccin-mocha", "quietStartup": false, + "collapseChangelog": true, "defaultProjectTrust": "ask", "enableInstallTelemetry": false, "enabledModels": [ @@ -33,5 +34,33 @@ "npm" ], "showHardwareCursor": true, - "lastChangelogVersion": "0.83.0" + "piVim": { + "clipboardMirror": "yank", + "exCommand": { + "piDispatch": true, + "copyInputToClipboard": false + }, + "modeColors": { + "insert": "success", + "normal": "accent", + "visual": "warning", + "ex": "bashMode" + }, + "borderSync": { + "insert": "host", + "normal": "host", + "visual": "host", + "ex": "host" + }, + "labelSync": { + "insert": "mode", + "normal": "mode", + "visual": "mode", + "ex": "mode" + } + }, + "lastChangelogVersion": "0.83.0", + "packages": [ + "npm:pi-vim" + ] } \ No newline at end of file diff --git a/wm/.config/aichat/config.yaml b/wm/.config/aichat/config.yaml new file mode 100644 index 0000000..59e7c43 --- /dev/null +++ b/wm/.config/aichat/config.yaml @@ -0,0 +1,64 @@ +# WM HOST OVERLAY — full-file copy of common/.config/aichat/config.yaml with the default model +# flipped to the duskadiy client: wm is not on the home LAN, so the `local` endpoint +# (192.168.0.204) is unreachable here (kept below for VPN use). Stowed with --override so it +# shadows the common file on wm only. When the common config changes, mirror the change here. +# see https://github.com/sigoden/aichat/blob/main/config.example.yaml +keybindings: vi +editor: nvim +model: duskadiy:Qwen3-Coder-30B-Instruct-UD-Q3_K_XL + +# Sessions: persist REPL sessions and keep more history before summarizing. +# The default compress_threshold (4000) summarizes far too early for 24k+ windows. +save_session: true +compress_threshold: 16000 + +# REPL prompts show live context usage (needs max_input_tokens, set per model below) +left_prompt: '{color.green}{?session {session}{?role /}}{role}{color.cyan}{?rag @{rag}}{color.reset}> ' +right_prompt: '{color.purple}{?session {consume_tokens}/{max_input_tokens} }{color.reset}' + +# NOTE: temperature/top_p are intentionally unset — the LAN server applies tuned +# per-model sampling via --jinja (e.g. GLM 0.6/0.95); a global value would clobber it. + +# max_input_tokens = real ctx-size (from the router) minus output headroom. +clients: +# LAN access (fast; only reachable on the home network) +- type: openai-compatible + name: local + api_base: http://192.168.0.204:11343/v1 + models: &lan_models + # Speeds are benched decode t/s (clean-night sweeps) — see fl/.config/llamacpp/README.md roster. + - name: Qwen3-Coder-30B-Instruct-UD-Q3_K_XL + max_input_tokens: 30000 # ctx 32768 · ~30 t/s — main agent coder + - name: Qwen3-Coder-Next-UD-IQ3_XXS + max_input_tokens: 128000 # ctx 131072 · ~16 t/s — long sessions (128k) + - name: Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS + max_input_tokens: 22000 # ctx 24576 · ~36 t/s — daily driver + supports_vision: true + - name: Qwen3.6-35B-A3B-Thinking + max_input_tokens: 22000 # ctx 24576 · ~39 t/s — hard problems + supports_vision: true + - name: Qwen3.5-9B-UD-Q6_K_XL + max_input_tokens: 30000 # ctx 32768 · ~32 t/s — quick tasks + supports_vision: true + - name: Qwen3.8-27B-UD-IQ3_XXS + max_input_tokens: 22000 # ctx 24576 · ~15 t/s — hybrid reasoner, quiet desktop only + supports_vision: true + - name: gemma-4-26B-A4B-it-UD-IQ4_XS + max_input_tokens: 22000 # ctx 24576 · ~34 t/s — quality generalist, best vision + supports_vision: true + - name: gemma-4-E4B-it-UD-Q8_K_XL + max_input_tokens: 62000 # ctx 65536 · ~57 t/s — fast generalist, long docs + supports_vision: true + - name: GLM-4.7-Flash-UD-Q4_K_XL + max_input_tokens: 22000 # ctx 24576 · ~21 t/s — quality coder + - name: gpt-oss-20b + max_input_tokens: 62000 # ctx 65536 · ~38 t/s — fast reasoning + tools + - name: gpt-oss-20b-low + max_input_tokens: 62000 # ctx 65536 · ~37 t/s — snappy answers +# Remote access via duskadiy.com (reachable from anywhere; requires an API key — +# $DUSKADIY_API_KEY, from the gitignored conf.d/secrets.fish). The LAN endpoint above is keyless. +# Same server, same model ids — reuse the list above via a YAML anchor. +- type: openai-compatible + name: duskadiy + api_base: https://llm.duskadiy.com/api/v1 + models: *lan_models