April 2026

Socat - unix socket notification forwarding

Needed a way to get Claude Code notifications out of Docker containers to the host desktop. Used socat with unix sockets. Here's how socat works, building up piece by piece:

How socat syntax works

The man page says socat [options] <address> <address>. The confusing bit is that fork, reuseaddr etc are NOT command-line options (those go before the addresses, like -d). They are address options — appended to an address type with commas:

ADDRESS-TYPE:argument,option1,option2

The man page lists each address type (e.g. UNIX-LISTEN) with its "Useful options" — that's where you find which address options apply. fork and reuseaddr are listed under the LISTEN/CHILD option groups.

1. Basic socat — connect two things

socat connects two "addresses" bidirectionally. Simplest example — act like cat:

socat STDIN STDOUT
# type something, it echoes back

2. Unix sockets — listener side

Create a unix socket and listen on it. - is shorthand for STDIN/STDOUT:

# terminal 1: listen
socat UNIX-LISTEN:/tmp/test.sock -

# terminal 2: connect and send
echo "hello" | socat - UNIX-CONNECT:/tmp/test.sock
# "hello" appears in terminal 1

After one message the listener exits. That's because UNIX-LISTEN accepts one connection then closes.

3. fork — keep listening

fork spawns a child process per connection so the listener stays alive:

socat UNIX-LISTEN:/tmp/test.sock,fork -
# now multiple clients can connect, listener stays up

4. reuseaddr — restart without "address already in use"

When socat creates a UNIX-LISTEN socket, it creates a file on disk (e.g. /tmp/test.sock). When it exits cleanly it removes the file. But if it crashes or gets killed, the stale .sock file stays behind. Next time you start the listener, socat tries to bind() to that path and gets Address already in use because the file already exists.

Try it yourself:

# terminal 1: start listener
socat UNIX-LISTEN:/tmp/test.sock,fork -

# terminal 2: kill it hard (simulating a crash)
kill -9 $(pgrep -f "socat UNIX-LISTEN:/tmp/test.sock")

# terminal 1: try to restart — fails
socat UNIX-LISTEN:/tmp/test.sock,fork -
# ERROR: Address already in use

# check: the stale socket file is still there
ls -la /tmp/test.sock

# with reuseaddr it just works (removes stale file before binding)
socat UNIX-LISTEN:/tmp/test.sock,fork,reuseaddr -

This is why the systemd service also has ExecStartPre = rm -f /tmp/notify-forward.sock — belt and braces.

5. SYSTEM: — run a command per connection

Instead of printing to STDOUT, run a shell command. The incoming data is available on stdin of that command:

socat UNIX-LISTEN:/tmp/test.sock,fork,reuseaddr SYSTEM:'read line; echo "got: $line"'

Send a test: echo "hello" | socat - UNIX-CONNECT:/tmp/test.sock — the listener shell runs read line and can do whatever with it.

6. The actual notification listener

Putting it all together — receive title|body format, split on |, send desktop notification:

socat UNIX-LISTEN:/tmp/notify-forward.sock,fork,reuseaddr SYSTEM:'
    read line
    title="${line%%|*}"    # everything before first |
    body="${line#*|}"      # everything after first |
    notify-send "$title" "$body"
'

7. -d flag — debug output

-d is a command-line option (goes before addresses). Adds connection logging. Stack for more verbosity:

socat -d UNIX-LISTEN:/tmp/test.sock,fork,reuseaddr SYSTEM:'read line; echo "$line"'
# -d = notices, -d -d = info, -d -d -d = debug

Lazy window

lol i NEVER new this - C-w then w is "switch window" so if focus is lost to :Lazy window just do that to go back to it 🤣 - see here

Notification history

I also forget I have :Telescope fidget which has a nice viewer with telescope

Git history search — diffview, fugitive & terminal

CommandDescriptionArgs
:DiffviewFileHistory %browse all commits touching current file in diffview% = vim's current buffer filename
:DiffviewFileHistory % -G"FileChangedShellPost"diffview filtered to commits where diff matches regex in current file-G"pattern" = git log regex diff search, % = current file
:DiffviewFileHistory -G"FileChangedShellPost"same but across entire repo (no %)-G"pattern" = regex search across all files
git log -p -G"FileChangedShellPost" -- path/to/fileterminal equivalent — log with patches filtered by regex for a file-p = show patch, -G = regex diff search, -- = path delimiter
:Git log -p -- %fugitive scrollable log with full diffs for current file-p = show patch, % = current file
:Git log -p -G"FileChangedShellPost" -- %fugitive log filtered by regex search for current file-p = patch, -G = regex search, % = current file

-S"string" is an alternative to -G — finds commits where the count of that string changed (added/removed) rather than matching the diff line

Telescope live grep with directory/filetype filtering

Uses live_grep_args extension. The key shortcut <C-f> auto-quotes the search term and appends --iglob so you can type a path/glob filter.

Search in a directory: <leader>g → type vertical → <C-f> → type configs/nvim/**

  • result: "vertical" --iglob configs/nvim/** — searches for "vertical" only in files under configs/nvim/

Search by filetype: <leader>g → type zc → <C-f> → type **/*.lua

  • result: "zc" --iglob **/*.lua — searches for "zc" only in .lua files across the whole repo

--iglob is case-insensitive glob. You can chain multiple: "term" --iglob configs/** --iglob !*.json (exclude json files)

note: <C-i> does NOT work for this because terminals send <C-i> as <Tab> — that's why we use <C-f> instead

Keybrowser — browsable keybinding pickers

Built a custom telescope-based keybinding browser in lua/custom/keybrowser.lua. All under <space>k:

  • <space>kk — nvim keymaps (your configured mappings, via telescope builtin)
  • <space>kv — vim built-in keys (parsed from $VIMRUNTIME/doc/index.txt, enter opens :help)
  • <space>kz — zsh keybindings (live-queried from bindkey -M across all keymaps: vicmd, viins, etc)
  • <space>kt — telescope keybindings (queried from require("telescope.config").values.mappings at runtime)

All pickers support a "mode filter" search: type two words and the first word filters the first column exactly, the second is a lua pattern on the rest. e.g. n p in the vim picker shows all normal-mode keys containing p, vicmd kill in zsh shows vicmd bindings matching "kill".

The zsh picker also translates raw terminal codes to friendly names — ^[OA → Up (SS3), ^M → Enter (CR), ^[x → Alt-x, etc.

Discovery that prompted this: C-t in telescope opens the selected entry in a new tab. I kept forgetting this existed. Now <space>kt → search "tab" and there it is.

Intuitive Zellij Stacking

Didnt realise this was a feature... ToggleGroupMarking (for me Alt Shift p) opens group mode where we can select multiple panes and then s to stack!

And then can use the alt [ to change layout (which will unstack) .. and alt t [ to move a pane to another tab!

Vim variable scopes cheat sheet

ScopeWhatVim getVim setLua getLua set
g:global variables:echo g:foo:let g:foo = 1vim.g.foovim.g.foo = 1
b:buffer-local variables:echo b:foo:let b:foo = 1vim.b.foo or vim.b[bufnr].foovim.b.foo = 1
w:window-local variables:echo w:foo:let w:foo = 1vim.w.foo or vim.w[winid].foovim.w.foo = 1
t:tab-local variables:echo t:foo:let t:foo = 1vim.t.foo or vim.t[tabnr].foovim.t.foo = 1
v:vim predefined variables:echo v:count(read-only mostly)vim.v.count(read-only mostly)

Options (settings like number, expandtab) use vim.opt / vim.o / vim.bo / vim.wo:

ScopeWhatVim getVim setLua getLua set
global option:set foo:echo &foo:set foo=1vim.o.foo or vim.opt.foo:get()vim.o.foo = 1 or vim.opt.foo = 1
buffer option:setlocal foo (buffer):echo &l:foo:setlocal foo=1vim.bo.foo or vim.bo[bufnr].foovim.bo.foo = 1
window option:setlocal foo (window):echo &l:foo:setlocal foo=1vim.wo.foo or vim.wo[winid].foovim.wo.foo = 1

Examples:

TypeExamplePurpose
vim.g.mapleadervim.g.mapleader = ' 'global var, set leader key
vim.g.clipboardvim.g.clipboard = {...}global var, clipboard provider config
vim.b.disable_autoformatvim.b.disable_autoformat = truebuffer var, custom flag for conform.nvim
vim.opt.numbervim.opt.number = trueoption, show line numbers
vim.bo.filetypevim.bo.filetypebuffer option, get current filetype
vim.wo.wrapvim.wo.wrap = falsewindow option, disable line wrap
vim.v.eventvim.v.event.regcontentsvim predefined, yank event data

vim.opt vs vim.o: vim.opt returns a special object with :get(), :append(), :prepend(), :remove(). vim.o is simpler direct access. For setting simple values they're interchangeable.

Terminal escape sequences cheat sheet

Escape sequences control terminal behavior. Two main types: CSI (Control Sequence Introducer) for cursor/display and OSC (Operating System Command) for terminal features.

TypeCommandBreakdownWhat it does
CSIprintf '\e[2J'\e[ + 2 + Jclear entire screen (J=erase, 2=all)
CSIprintf '\e[10;5H'\e[ + 10;5 + Hmove cursor to row 10, col 5 (H=position)
CSIprintf '\e[31mRed\e[0m'\e[ + 31 + mset text red (m=SGR, 31=red fg)
CSIprintf '\e[0m'\e[ + 0 + mreset all attributes (0=reset)
CSIprintf '\e[2 q'\e[ + 2 + qset cursor to block (q=cursor shape)
CSIprintf '\e[5 q'\e[ + 5 + qset cursor to bar/beam
CSIprintf '\e[?25l'\e[ + ?25 + lhide cursor (l=low/off)
CSIprintf '\e[?25h'\e[ + ?25 + hshow cursor (h=high/on)
OSCprintf '\e]0;My Title\a'\e] + 0; + title + \aset window title
OSCprintf '\e]9;%s\e\\' "msg"\e] + 9; + msg + \e\\desktop notification (works over SSH)
OSCprintf '\e]52;c;%s\a' "$(echo -n 'hello' | base64)"\e] + 52;c; + BASE64 + \acopy to clipboard

Key:

  • \e or \033 = ESC (0x1B)
  • \a or \007 = BEL (terminates OSC)
  • CSI ends with a letter (J, H, m, q, l, h)
  • OSC ends with BEL (\a) or ST (\e\\)

SGR (Select Graphic Rendition) codes for \e[...m:

CodeEffectCodeEffect
0reset all1bold
30-37fg colors40-47bg colors
38;5;N256-color fg48;5;N256-color bg
38;2;R;G;Btruecolor fg48;2;R;G;Btruecolor bg

Example combining: \e[1;31m = bold + red, \e[38;2;255;100;0mOrange\e[0m = truecolor orange then reset

OSC 52 clipboard breakdown:

printf '\e]52;c;%s\a' "$(echo -n 'hello' | base64)"
        │ │  │ │       │                   │
        │ │  │ │       │                   └─ base64 encode (required by spec)
        │ │  │ │       └─ substituted into %s
        │ │  │ └───────── \a = BEL, "end of message"
        │ │  └─────────── c = clipboard target (c=clipboard, p=primary, s=select)
        │ └────────────── 52 = clipboard operation (xterm assigned number)
        └──────────────── \e] = OSC introducer ("hey terminal, incoming command")

Why base64? The clipboard content might contain special characters that would break the escape sequence. Base64 ensures it's safe ASCII.

Works over SSH because the escape sequence travels through the terminal stream back to your local terminal (kitty/wezterm/etc) which handles the actual clipboard.

Ref: https://terminalguide.namepad.de/seq/osc-52/

April 2026

Yazi as XDG file picker — research summary

Tool: xdg-desktop-portal-termfilechooser (in nixpkgs as v1.2.1)

Repo: https://github.com/hunkyburrito/xdg-desktop-portal-termfilechooser

How it works: implements org.freedesktop.impl.portal.FileChooser, so it intercepts the same D-Bus calls currently routed to xdg-desktop-portal-gtk. Spawns kitty running yazi --chooser-file=/tmp/...; portal reads the temp file when yazi exits.

To enable (3 small files):

  1. modules/nixos-core-desktop.nix:
    xdg.portal.extraPortals = [
      pkgs.xdg-desktop-portal-gtk
      pkgs.xdg-desktop-portal-termfilechooser
    ];
  2. ~/.config/xdg-desktop-portal/portals.conf:
    [preferred]
    default=gtk
    org.freedesktop.impl.portal.FileChooser=termfilechooser
  3. ~/.config/xdg-desktop-portal-termfilechooser/config:
    [filechooser]
    cmd=yazi-wrapper.sh
    default_dir=$HOME
    env=TERMCMD='kitty --title "File Chooser"'
    env=PATH="$PATH:/run/current-system/sw/bin"

Test: GDK_DEBUG=portals zenity --file-selection

Gotchas:

  • smart-enter.yazi plugin (currently in keymap) breaks directory selection — Enter navigates instead of selects. Workaround: use YAZI_CONFIG_HOME=... in wrapper for an alternate config without smart-enter.
  • Qt apps may bypass the portal entirely (open issue #46).
  • Two closed NixOS issues (#56, #67) document systemd integration friction — reboot recommended after install.
  • Save dialogs work but UX is unusual: yazi creates a help-file at the destination path with instructions.

Recommendation: try it AFTER the GTK portal click-offset fix (GDK_SCALE=1 + removing wlr.enable) lands. If that resolves the GTK issue you may not need this. But config is ~10 lines and trivially reversible.

nvim-treesitter: archived, 0.12 broken, pinned for now

On April 3, 2026 the entire nvim-treesitter/nvim-treesitter repo went Public archive on GitHub. Both master and main branches are frozen — no new commits will land anywhere in the repo. No official successor was named.

Why 0.12 breaks the current pin

Neovim 0.12 changed the treesitter query match API to support quantifiers (neovim#31293):

  • 0.11: match[capture_id] → single TSNode | nil
  • 0.12: match[capture_id] → TSNode[] (list, always)

The pinned master commit (42fc28ba) registers 7 custom directives in query_predicates.lua that do match[capture_id]:range(). On 0.12 that crashes with attempt to call method 'range' (a nil value) because the value is now a list, not a node. Markdown's injection query (set-lang-from-info-string!) is the most obvious trigger but it affects anything with injections.

The master branch will never be fixed (archived). So: either stay on 0.11, or migrate off master entirely.

Two frozen branches, two behaviours

branchlast commitworks with
master42fc28ba (May 2025)nvim ≤ 0.11
main4916d659 (Apr 2026)nvim ≥ 0.12 (0.11 dropped explicitly: feat!: drop support for Nvim 0.11)

Both are now snapshots — no future fixes, no new parsers. main happens to be the 0.12-compatible snapshot.

The main branch was a full rewrite with a different API shape:

  • setup: require('nvim-treesitter').setup() (no .configs)
  • install: explicit .install({...}) call (no ensure_installed)
  • highlight/indent: not auto-enabled — you write a FileType autocmd calling vim.treesitter.start()
  • helpers (ts_utils, configs, etc.): gone — use vim.treesitter.* built-ins (available since 0.10)

Current decision: stay pinned

  • Host nvim is 0.11.7 (NixOS nixos-25.11), master-branch pin works fine.
  • Dev container (PR #6) pins to nvim 0.11.7 via ARG NVIM_VERSION (Arch ships 0.12 by default — that's what caused the original container break).
  • Experiment branch that migrated config to main branch was tested working on 0.12.1 (PR #7, now closed) but not worth merging yet — would commit to a frozen snapshot.

When to revisit

When NixOS bumps nvim past 0.11 (likely nixos-26.x), the pin stops helping. Options at that point, best to worst:

  1. Neovim built-in treesitter + tiny parser installer. 0.11+ has vim.treesitter.start, get_node, parser loading from runtime/parser/*.so built-in. Highlighting/indent/folding don't need the plugin. You just need parsers installed somewhere on runtimepath. A small helper like tree-sitter-manager.nvim (actively maintained, lightweight, 0.12+) is probably the minimum replacement.
  2. rocks-treesitter.nvim — rocks.nvim module, pre-built parsers hosted on rocks-binaries (no local compile). Relevant if/when you adopt rocks.nvim.
  3. Resurrect the main-branch migration from the closed PR #7 — works but targets an archived repo, so only a stopgap.

Community discussion: Lobsters, HN.