sloshDriving it slosh.foo

Scripting#

Three things make slosh easy to drive from other programs: a socket that does everything the keyboard does, an escape sequence a pane can use to draw its own chrome, and a headless mode that is the whole program without a terminal.

The control socket#

One JSON object per line, over the session's socket. A detached session answers exactly as an attached one does.

$ slosh -s work cmd '{"cmd":"new-tab","name":"api"}'
{"ok":true,"id":2}
$ slosh -s work cmd '{"cmd":"panes"}'
{"ok":true,"panes":[{"id":1,"title":"nvim","alive":true,...}]}

Panes and tabs are addressed by id, so a background tab is scriptable.

verb
panes tabswhat exists, with ids, rects, titles, purposes, state. A pane's tab_id is what move-pane and select-tab want; its tab is where that tab sits in the strip
snapshotthe composited screen, as JSON, format:"text", or format:"bytes", the emitter's own output for this frame (a second call is the delta)
deadlinewhen the session wants its next frame, in ms, or -1
sendbytes as if typed, decoded like input ("data":"\\x01\\\\")
rawbytes straight into the focused pane's pty
resizecols rows, and optionally cell_w cell_h
split`dir:"cols"\"rows", id` for which pane to split
focusid
close rerunid, or 0 for the focused pane
clear-shadersid, or 0 for the focused pane; answers `cleared:0\1`. The way back from a pane that painted itself unreadable
new-tab select-tab close-tab move-tabtabs, by id or index
move-paneid of the pane, tab id to move it into (0 for a tab of its own, with an optional name), `dir:"cols"\"rows"`. The pane keeps running: same pty, same scrollback
floatbare: toggle a pane floating (id, or 0 for the focused one). With any of x y w h it places: floats first when tiled, re-places when floating, never un-floats; omitted fields keep their value
new-floata fresh floating shell over the current tab, centred, in the focused pane's directory; answers id
set-nametarget:"tab" (the default, because that is what this verb has always meant) or "pane", id, name. A pane accepts 0 for the focused one. A pane's name wins over the title the program sets, so this is how a program that keeps announcing something stale gets overruled; an empty name clears it and hands the label back. Refusals say no such pane or no such tab, so a mistyped target is visible in the reply
set-purpose`target:"pane"\"tab", id (or 0 for the focused pane, and the tab you are in), purpose. An empty purpose` clears the slot and unlocks it, handing the label back to the program
dump-layout apply-layoutsee layouts. dump-layout takes tab (0 for every tab), relative_to to write every cwd= under that directory instead of absolute, and suspend (as-is none commands all); it answers kdl panes suspended, and an unknown tab is an error rather than an empty document
workspacesthe projects on disk and which of them are open: roots says whether any are configured at all, and each entry has name path purpose layout (a file path, or "") mtime tab (0 when closed). See workspaces
open-workspacename or path, and suspended; answers tab purpose path created tabs honoured. Already open means focused, with created:false
close-workspacename or purpose; answers closed, how many tabs went
save-workspacewrite this tab as the project's layout: tab (0 for the current one), path for a tab that is not a workspace yet, suspend, and force to overwrite a layout the project already has; answers path purpose panes suspended replaced
notifyput a line in the session's status area
graphicsthe kitty placements on screen, or the bytes sent for them. The bytes are rendered for you, not the client, so anything stateful in them (image transmissions, deletions) is re-sent to the client on its next frame
clipboardwhat the session has copied
reloadre-read the config; answers {"ok":true,"warning":...} if it had a complaint
splashreplay the attach greeting; fx and motion pick the colour effect and the assembly by index, for a deterministic one
edit-configopen the config in a pane
themewithout name, what is installed and what is worn (theme themes dirs); with one, switch now, and save:true writes the theme_name line into the config. See themes
aliveis it running, and how many panes and tabs
quitend the session

Driving a project#

Everything a program needs in a project it has never seen: open it, ask what is in it, act on the purposes the project's own layout declared:

$ slosh -s work cmd '{"cmd":"open-workspace","name":"api"}'
{"ok":true,"tab":3,"purpose":"project:api.5c1f0a3b","path":"/home/you/dev/api","created":true,"tabs":1,"honoured":0}
$ slosh -s work cmd '{"cmd":"open-workspace","name":"api"}'
{"ok":true,"tab":3,"purpose":"project:api.5c1f0a3b","path":"/home/you/dev/api","created":false,"tabs":0,"honoured":0}

The second call focuses what is there and says created:false. Opening is idempotent, so a script drives it in a loop without asking first. "Have I opened this already" is the question a script gets wrong after a crash or a re-attach.

Then read the tab it handed back:

$ slosh -s work cmd '{"cmd":"panes"}' \
    | jq -c '.panes[] | select(.tab_id == 3) | {id, purpose, suspended}'
{"id":7,"purpose":"agent:main","suspended":false}
{"id":8,"purpose":"service:web","suspended":true}
$ slosh -s work cmd '{"cmd":"rerun","id":8}'    # start the dev server

The project's own layout file decided that service:web is the dev server and that it starts asleep; nothing in the session, the config or the calling program had to know that.

workspaces reports each project's layout file mtime, so a tool that kept the mtime it opened a workspace with can tell the file has moved on since, without the session storing a byte on its behalf. Re-applying the changed layout is deliberately not offered: the panes it would replace have processes in them.

A pane can draw its own chrome#

By printing an escape sequence. No plugin, no config:

printf '\033]5577;1;status;building 3/7\033\\'
printf '\033]5577;1;busy;1\033\\'     # ...and it is still going
printf '\033]5577;1;busy;0\033\\'     # ...and now it is not
printf '\033]5577;1;buttons;approve:Approve;cancel:Cancel\033\\'
# clicking [Approve] arrives on the program's stdin as:
#   \033]5577;1;click;approve\033\\

The status text appears in the pane's frame; the buttons are real targets in it. A program that wants to be asked something can ask in place rather than printing a prompt and hoping.

With the tab bar on a side, the status is also a row under the pane's tab — visible from every other tab, and clicking it jumps to the pane that said it. A status is not decoration on your own frame; it is how a pane reports progress to somebody working elsewhere, which is a reason to keep it current and clear it when the work is done.

busy is the one thing a line of text cannot say about itself: make test and make test are the same words whether it is running or finished. It is a flag of its own rather than a field of status, because a status is the whole payload after the verb — status;a;b;c is the text a;b;c — so anything added to that line would change what every sender already means. In a sidebar a busy status gets the spinner (busy_mark, coloured tab_status_spinner) and tab_status_busy, which is how "working" and "done" tell themselves apart from another tab without reading the words. Anything but 0, false, no or off turns it on; clear turns it off along with the text, and so does the program exiting — a status left behind by something that is gone describes the past, whatever it last claimed.

purpose is the other verb: printf '\033]5577;1;purpose;logs\033\\'. A purpose declared by a layout or the control API wins and cannot be overwritten this way.

shader is the third, and the only one the session can refuse: it sets the shader passes for the pane that asked, as a document in the config's own syntax, so an entry's where= decides which rect it lands on and the reply counts what went where. It needs in_band_shaders true because a program restyling your session is a hazard before it is a convenience. shader; with no rect named clears both of that pane's chains, which is never refused; clear-shaders above is the same thing from outside, for a program that will not do it itself. shader-load;<path> hands over a shaders { } file instead of a chain and answers with how much of it ran (ok;1 chrome, 0 content), so a script can apply a preset without knowing how to read one. See shaders.

Anything the session sends back to a program ends its verb in -reply (hello-reply, shader-reply) and no request verb may. A pane that echoes what it is sent (cat, a shell with echo on, a REPL waiting for a line) would otherwise be answered into a loop, which is exactly what hello used to do: 4 MB of hellos in a second and a half.

Driving it from an agent#

Everything above is what an agent needs, and none of it says which parts matter. .agents/skills/driving-slosh/SKILL.md is the same socket written as instructions: how a program in a pane finds out which session it is in (SLOSH_SESSION, SLOSH_BIN), why work belongs in a pane that was given a command rather than typed into somebody's shell, that alive and exit_code are how you wait rather than reading the screen for a marker your own echo matches, and that purpose is the handle to find things by because titles change underneath you.

It follows the .agents/skills/<name>/SKILL.md convention, so an agent working in a checkout picks it up without being told. To use it elsewhere, copy or symlink the directory into wherever your agent looks for skills. tests/test_skill.py checks every verb, variable and panes field it names against the program, because a stale skill is worse than a missing one: an agent acts on it without a human reading it first.

Headless#

slosh --script is the whole program without a terminal: commands on stdin, answers on stdout. It is how the test suite works (drive these events, assert this screen), which is also why the suite is 1,500-odd real end-to-end checks that finish in about thirteen seconds.

$ printf '%s\n' '{"cmd":"split","dir":"cols"}' '{"cmd":"snapshot","format":"text"}' \
    | slosh --script --cols 80 --rows 24 -- /bin/sh

The bare-verb form (snapshot text, send \x01\\, resize 100 30) is a human-friendly alias for the same code, so a script and a test cannot drift from what the API does.