<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/.gitignore, branch master</title>
<subtitle>an opinionated terminal multiplexer in C with programmable pane chrome</subtitle>
<id>https://git.sl0p.foo/slosh.git/atom/.gitignore?h=master</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/.gitignore?h=master'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-08-30T10:49:24Z</updated>
<entry>
<title>web: sloshcast</title>
<updated>2026-08-30T10:49:24Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-30T10:49:24Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=a3c5c69d089efd3d778e8b4ef859a6432cd3d07f'/>
<id>urn:sha1:a3c5c69d089efd3d778e8b4ef859a6432cd3d07f</id>
<content type='text'>
</content>
</entry>
<entry>
<title>gitignore: DESIGN.md and .fastfeedback are local, not the project</title>
<updated>2026-08-28T13:38:41Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-28T13:38:41Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=a7a20bc026488928b6b65f44924b31615b898263'/>
<id>urn:sha1:a7a20bc026488928b6b65f44924b31615b898263</id>
<content type='text'>
DESIGN.md was a scratch design doc and .fastfeedback a per-repo tool cache;
neither belongs in the tree. Both stay on disk, ignored. Everything that
pointed at DESIGN.md now points at docs/design.md, and the decision counts
quoted from it (which had already drifted: the README said twenty-one, the
docs said twenty-two) are gone with it.
</content>
</entry>
<entry>
<title>windows: a native port, on ConPTY and AF_UNIX</title>
<updated>2026-08-27T22:23:19Z</updated>
<author>
<name>Peter Geissler</name>
<email>peter@haxx.in</email>
</author>
<published>2026-08-27T22:11:24Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=d1b5a64c3855b07074b3c958739df4e16f6084a5'/>
<id>urn:sha1:d1b5a64c3855b07074b3c958739df4e16f6084a5</id>
<content type='text'>
slosh builds and runs on Windows 10/11, x86-64 and ARM64, as one exe
with no runtime dependency beyond the OS: panes, splits, tabs,
detachable sessions, the config file and its live reload, the control
socket, the mouse.

The port is a compatibility layer rather than a fork. compat/win holds
stub &lt;poll.h&gt;, &lt;sys/socket.h&gt;, &lt;termios.h&gt; and friends that redirect to
include/compat_win.h, which is force-included ahead of every translation
unit -- so the existing sources keep their POSIX includes and their
POSIX call sites, and the platform differences are answered in one
place.

Two decisions carry most of it.

Everything the event loop waits on is a socket. On POSIX a pty master,
an inotify descriptor and a unix socket are all file descriptors, so one
poll() covers them; Windows has no such union, and WSAPoll waits on
SOCKETs and nothing else. Rather than rewrite server.c's loop around
WaitForMultipleObjects -- which caps at 64 objects and cannot express
POLLOUT -- each non-socket source gets a pump thread that copies it into
one end of a loopback socketpair. The loop is then the POSIX one
unchanged, and only what sits behind the descriptor differs.

Socket descriptors are tagged rather than guessed: a wrapped SOCKET
comes back as SL_SOCK_BASE + slot, so read, write, close and poll can
tell a socket from a CRT descriptor with certainty instead of inferring
it from the numeric range. That is why socket() and accept() are shimmed
too -- an untagged descriptor escaping into the loop is the one failure
mode this scheme has, and it is worth closing completely.

Three things bit hard enough to be worth naming.

A pseudoconsole does not stop the child inheriting your stdio.
CreateProcess copies the parent's standard handles into the child when
STARTF_USESTDHANDLES is absent, and those win. With slosh's own stdout
redirected -- a pipe under ssh, a file under --script, a log under the
daemon -- the shell wrote its banner into slosh's output instead of into
the pane, and the pane had a correct title (ConPTY emits that itself)
over an empty body. The three handles are cleared for the duration of
the call, leaving the child nothing to inherit.

A ConPTY never reports EOF. The output pipe belongs to the
pseudoconsole, not to the child, so it stays open after the child exits
and pane_pump()'s read() -- the entire mechanism by which a pane learns
its program is gone -- never returns 0. A pane whose shell had been told
`exit` sat there looking alive forever. So the exit is waited for on the
process handle, and ClosePseudoConsole is what then produces the EOF.
That third thread is also why the per-pty state is reference counted: a
pump blocked on a dead pipe must not be waited for, or pty_close would
hang the session on the one program that misbehaves, so whoever finishes
last frees.

cmd.exe does not use CommandLineToArgvW's quoting. A literal quote is
\" to an ordinary program and a backslash-then-end-of-quoted-section to
cmd, so building `cmd /c notepad "C:\...\config.kdl"` the first way
handed the editor \"C:\...\config.kdl\", backslashes and all, and
notepad answered "Not a valid file name" about a path that existed.
cmd /c now gets the rest of the line verbatim -- the caller composed a
complete command with its own quoting, exactly as it does for sh -c.

Paths are mixed, and only some programs mind. slosh joins with '/' while
the environment supplies '\', so the config landed at
C:\Users\you/.config/slosh/config.kdl. The CRT opens that without
complaint, which is why --check reported it fine, but a GUI file dialog
rejects it outright. Anything handed to another program now goes through
sl_path_native() first; the internal representation is left alone.

The rest is small. Panes run %ComSpec%, sessions live in
%LOCALAPPDATA%\slosh rather than $XDG_RUNTIME_DIR because a session
socket must not follow a roaming profile to another host, there is no
SIGWINCH so a resize is noticed by comparing the console size on a
bounded poll, no fork so the daemon is a fresh detached process that
reconstructs its arguments on the command line, and no process group on
a pty so the foreground program is the youngest descendant of the pane's
shell. C-a e prefers whichever console editor is actually installed --
notepad is a GUI program, so a pane running it draws nothing and looks
broken -- and says so when it has to fall back.

make -f Makefile.windows ARCH=x86_64 (or aarch64) cross-compiles from a
mac or Linux, and builds natively on the box with zig on PATH.
</content>
</entry>
<entry>
<title>macos: the pipeline is the project's, the identity is yours</title>
<updated>2026-08-26T22:08:52Z</updated>
<author>
<name>Peter Geissler</name>
<email>peter@haxx.in</email>
</author>
<published>2026-08-26T22:08:52Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=aa2ec9b65dbee07886556b0d8ae353cb462964b9'/>
<id>urn:sha1:aa2ec9b65dbee07886556b0d8ae353cb462964b9</id>
<content type='text'>
Makefile.macos was kept out of git because it named a team, a person and
a mac. But almost all of it is mechanism -- build, sign, zip, notarize,
staple, unlock a headless keychain -- and that is the project's, not the
releaser's. Keeping the whole file out to protect four values meant the
one machine that had it was the only place a release could be cut from,
and that nobody could read how signing worked without being handed it.

So the split is inverted. Makefile.macos, the builder doc and the scripts
are tracked; macos-release.mk holds TEAM_ID, SIGNER and BUILDER and is
the only thing gitignored. macos-release.mk.example is the template.

The identity strings are now assembled from SIGNER and TEAM_ID rather
than written out, which means an unconfigured checkout composes
"Developer ID Application:  ()" -- plausible, wrong, and diagnosed only
by codesign a minute into a build. require-identity and require-builder
catch it before anything runs and name the file to create; `help` prints
"(unset)" rather than the empty shape.

macos-remote-build now pushes macos-release.mk instead of Makefile.macos.
That is the better arrangement anyway: the builder gets its pipeline from
git at the commit being released, so a release is signed by the flow
belonging to it and not by whatever the laptop was carrying.

MACOS-BUILDER.md was in the same position -- local, and referenced by a
file that is now tracked -- so it becomes docs/macos-release.md with the
certificate name and notary key id replaced by their shapes.
</content>
</entry>
<entry>
<title>macos: sign and notarize on a mac that is not this one</title>
<updated>2026-08-26T21:30:58Z</updated>
<author>
<name>Peter Geissler</name>
<email>peter@haxx.in</email>
</author>
<published>2026-08-26T21:30:21Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=4bd545a46891b6ac5bb7599911d370978b84c234'/>
<id>urn:sha1:4bd545a46891b6ac5bb7599911d370978b84c234</id>
<content type='text'>
An unsigned mac binary is "damaged and can't be opened", so a release has
to be signed with a Developer ID and notarized by Apple, and that needs a
mac holding a private key. That mac is not the machine you develop on --
under `make all` it never is -- so `make macos-dist` builds nothing here:
it syncs the tree to the builder over ssh, has it build, sign, notarize
and staple, verifies the result there and fetches the zip back. The near
end needs only ssh, rsync and a hasher, so it works from Linux.

contrib/macos-builder-setup turns a bare mac into that builder and is
re-runnable, with --check to say what is missing. The two things that
make an unattended builder different from a laptop are handled rather
than worked around: a keychain of its own, unlocked from a 0600 password
file because a headless mac has no unlocked login keychain and codesign
dies with "User interaction is not allowed"; and exactly one copy of the
identity in the search list, because two make codesign ambiguous.

No identity is in the repo. Who signs, which team, which mac -- that is
Makefile.macos, which stays out of git along with dist/, and macos-dist
tells you so when it is not there.
</content>
</entry>
<entry>
<title>placeholder webbie</title>
<updated>2026-08-25T11:36:17Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-25T11:36:17Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=1368b7febcf5a1ef2e155a6a5759b6241484e15c'/>
<id>urn:sha1:1368b7febcf5a1ef2e155a6a5759b6241484e15c</id>
<content type='text'>
</content>
</entry>
<entry>
<title>vendor: an unanchored build/ ignore was eating libghostty-vt's build tooling</title>
<updated>2026-08-24T08:01:26Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-24T08:01:26Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=75774b64ecfdbbfc6df78e34fb489ed21e401386'/>
<id>urn:sha1:75774b64ecfdbbfc6df78e34fb489ed21e401386</id>
<content type='text'>
The build/ pattern matched every directory of that name, not just our
build output at the root -- so vendor/libghostty-vt/src/build/ (the
library's own build.zig helpers) never made it into git, and make
vendor broke on fresh clones. Anchor the ignore to /build/ and restore
the 284 files it had silently dropped.
</content>
</entry>
<entry>
<title>sl0ppty: make tools, and a ruff version that cannot drift</title>
<updated>2026-08-17T12:49:59Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-17T12:49:59Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=1a0d8d41e1acffd95572a493afe37b762883f4c9'/>
<id>urn:sha1:1a0d8d41e1acffd95572a493afe37b762883f4c9</id>
<content type='text'>
`ruff` is not a system package on most machines, and `pip install --user ruff` is
refused outright on a distro python (PEP 668) -- so the last two commits shipped a
formatter nobody could run without knowing to build a venv by hand. `make tools`
builds it:

    $ make tools
      ruff 0.16.3  in .venv
      survives make clean; rm -rf .venv to start over

clang-format stays a system package, because everywhere it is one. `make tools`
says so if it is missing rather than pretending it handled it.

**The venv is looked in before `$PATH`, and the order is the point.** `ruff.toml`
now pins the version with ruff's own `required-version`, so a ruff of the wrong one
refuses to run and names both versions. Preferring `$PATH` would then strand
somebody who has 0.14 from their package manager and 0.16.3 sitting in the `.venv`
the repo just built for them -- refused by the tool, with the fix already on disk
and not being looked at. Tested with exactly that: 0.14.0 on `$PATH`, `make
fmt-check` still clean.

The pin exists because a formatter whose output moves between releases turns
"formatted" into "formatted by whoever committed last", and the diff lands on the
next person to touch the file. Refusing loudly is better than reformatting quietly.
Bumping it is one line in `ruff.toml`, one in the Makefile, and a reformat commit,
which is the honest cost of a new version.

The hook resolves ruff the same way, off `git rev-parse --show-toplevel` so it works
from a subdirectory, and its "not installed" message now says `make tools` instead
of naming three package managers and hoping.

Verified from the state a fresh clone is in: with no ruff anywhere, `make fmt` says
`ruff not found. run: make tools` and stops; after `make tools` (2.6s) both `fmt`
and `fmt-check` work with nothing on `$PATH`; the hook formats a staged Python file
using the venv; the venv survives `make clean`; and `.venv/` is ignored.

`make fmt` over the whole tree after all this changed nothing, which is the useful
part: the pinned version agrees with what is already committed.

One near-miss worth recording: the `.gitignore` line for `.venv/` had to be written
twice. A `git reset --hard` undoing a throwaway probe commit took the working tree
with it, including that edit -- which is what `--hard` does, and why the state was
checked afterwards rather than assumed.
</content>
</entry>
<entry>
<title>sl0ppty: shaders can be loaded from shared libraries</title>
<updated>2026-08-14T20:06:57Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-14T20:06:57Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=2ae507a8a331799875e07c327730a1a4ca0363f4'/>
<id>urn:sha1:2ae507a8a331799875e07c327730a1a4ca0363f4</id>
<content type='text'>
Adding an effect meant editing shader.c and rebuilding, which is fine for us
and useless to anyone else. Now any `*.so` in the shader directory that
exports a plugin table is loaded at startup, and what it declares is named in
the config exactly like a built-in -- same syntax, same three parameters,
same pass. Default location is a `shaders` directory beside the config file,
so dropping the .so next to config.kdl is the whole installation.

The interesting part is what a plugin can see, which is one header.
shader_abi.h holds cell_t, color_t, shade_ctx_t, shader_t and nothing else:
no libghostty-vt, no pty, no layout, no session. A shader is therefore
unable to reach anything but the cell it was handed, not by rule but because
nothing else is declared where it can find it. cell_t and color_t moved
there from sl0ppty.h for the same reason -- that is the one place they are a
contract with code compiled outside this tree rather than an internal type.

Loading is deliberately narrow:

- refused unless the library reports our ABI version *and* matching struct
  sizes. The version says which contract; the sizes catch someone editing
  their copy of the header, which a version number alone calls fine and a
  running session calls garbled colours;
- refused if it redefines an existing name, because a config naming `dim`
  means the `dim` it was written against;
- all-or-nothing per file, and per file: a bad library is a line on stderr
  and everything else still loads (D9 fail-open);
- nothing is ever unloaded. A shade_fn may be sitting in a live config or on
  a pane, so dlclose() is a jump into an unmapped page. A reload picks up
  newly *added* plugins; replacing one needs a new session.

This is the single exception to D9's "a plugin is a subprocess", and it is
argued in D15 rather than drifted into: a shader is called per cell, per
pass, per frame, which no subprocess can be in the path of, and the measured
alternative -- interpreting bytecode per cell -- is 56ns against 8ns for the
compiled function. On trust, `shell` and a layout's `command=` already let a
file in the config directory run anything as you, so a .so beside them is
not a new boundary. The docs say so plainly rather than implying a sandbox.

contrib/shader-plugin is a working skeleton: Makefile, two commented effects
(one positional, one animated), and the three rules a shader has to keep. It
is built and loaded by the test suite, so a broken example is a failing test
rather than someone else's afternoon.
</content>
</entry>
<entry>
<title>chore: stop tracking __pycache__</title>
<updated>2026-08-13T20:26:05Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T20:26:05Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=b4d0320b5798a2c3e0a744133baf95a5a3db59e2'/>
<id>urn:sha1:b4d0320b5798a2c3e0a744133baf95a5a3db59e2</id>
<content type='text'>
</content>
</entry>
</feed>
