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 tabs |
what 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; its pid is the program in it |
snapshot |
the composited screen, as JSON, format:"text", or format:"bytes", the emitter's own output for this frame (a second call is the delta) |
capture |
one pane's visible text: id (0 = focused). Reads the pane's own terminal, so it works for a pane in a tab that is not on screen, and changes nothing — no select-tab, no focus, no effect on a selection |
deadline |
when the session wants its next frame, in ms, or -1 |
send |
bytes as if typed, decoded like input ("data":"\\x01\\\\") |
raw |
bytes straight into a pane's pty: id (0 = focused). Naming the pane is the point — focusing one first moves the view of whoever is watching |
resize |
cols rows, and optionally cell_w cell_h |
split |
dir:"cols"\|"rows", id for which pane to split |
focus |
id |
close rerun |
id, or 0 for the focused pane |
clear-shaders |
id, 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-tab |
tabs, by id or index |
move-pane |
id of the pane, tab id to move it into (0 for a tab of its own, with an optional name), dir:"cols"\|"rows", beside a pane in that tab to land next to (default: whatever it has focused), focus:false to leave focus — and the view — where it is. The pane keeps running: same pty, same scrollback |
float |
bare: 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-float |
a fresh floating shell over the current tab, centred, in the focused pane's directory; answers id |
set-name |
target:"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-layout |
see layouts. apply-layout takes focus:false to build the tabs without going to them (a layout that names an active tab still wins); the panes it builds are sized either way. 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 |
workspaces |
the 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-workspace |
name or path, and suspended; answers tab purpose path created tabs honoured. Already open means focused, with created:false |
close-workspace |
name or purpose; answers closed, how many tabs went |
save-workspace |
write 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 |
notify |
put a line in the session's status area |
graphics |
the 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 |
clipboard |
what the session has copied |
reload |
re-read the config; answers {"ok":true,"warning":...} if it had a complaint |
splash |
replay the attach greeting; fx and motion pick the colour effect and the assembly by index, for a deterministic one |
edit-config |
open the config in a pane |
theme |
without 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 |
alive |
is it running, and how many panes and tabs |
quit |
end the session |
Your own pane, not the focused one
A program in a pane is told which pane it is in: $SLOSH_PANE. Everything
else follows from having it.
$S '{"cmd":"capture","id":'$SLOSH_PANE'}' # read yourself
$S '{"cmd":"panes"}' | jq --argjson me $SLOSH_PANE '.panes[] | select(.id==$me) | .tab_id'
Do not use focused for this. Exactly one pane in the session is focused,
it is in the tab currently on screen, and it belongs to whoever is looking —
which is not you. A tool that read focus as "where I live" put its panes in
whatever tab the human had wandered into, and in a session with several such
tools running it was reliably somebody else's. focused answers "what is the
human doing"; $SLOSH_PANE answers "who am I".
So background work is three calls, none of which move anybody's view:
my_tab=$($S '{"cmd":"panes"}' | jq --argjson me $SLOSH_PANE '.panes[]|select(.id==$me)|.tab_id')
# 1. build a pane that was *given* a command, without going to it
$S '{"cmd":"apply-layout","focus":false,"kdl":"layout { tab name=\"work\" { pane purpose=\"task:build\" command=\"make -j8\" } }"}'
id=$($S '{"cmd":"panes"}' | jq '.panes[]|select(.purpose=="task:build")|.id')
# 2. move it in beside yourself, still without taking focus
$S "{\"cmd\":\"move-pane\",\"id\":$id,\"tab\":$my_tab,\"beside\":$SLOSH_PANE,\"focus\":false}"
# 3. read it wherever it is
$S "{\"cmd\":\"capture\",\"id\":$id}"
If you cannot read the environment — a scrubbed env, a helper with no
controlling tty, a process several forks down — panes reports each pane's
pid, so walking your own parents until one of them matches finds your pane
without asking anybody about focus.
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.
...or say what it is doing in somebody else's protocol
OSC 5577 only works for programs that know slosh exists. OSC 7501, the program status protocol, is the same idea written down for everybody — by the author of ghostty, and implemented by it and by Rex — so a program that already speaks it needs to know nothing about us:
status() {
printf '\033]7501;state=%s:msg=%s\033\\' "$1" "$(printf '%s' "$2" | base64 | tr -d '\n')"
}
status working "Syncing photos"
rsync -a ~/Photos backup:/photos && status done "Photos synced" || status error "rsync failed"
The body is key=value pairs separated by :. state is the only required
key, and is one of idle, working, done, blocked, error, or clear to
remove the record. The rest: msg and title (base64 UTF-8), app (a stable
name like cargo), progress (0–100), kind (permission, question or
auth, with blocked), and id to report more than one thing at a time.
It lands in the same place 5577's status does — the pane's frame, a row in the
sidebar, status and busy in panes — because every reader of a pane wants
"what is this doing", not "which protocol said so". What the structure buys on
top of that is reported alongside: program_state, program_kind,
program_app and progress.
$ slosh cmd '{"cmd":"panes"}' | jq -c '.panes[] | select(.program_state == "blocked")'
{"id":4,...,"status":"EU West: Approve deploy to production?","busy":true,
"program_state":"blocked","program_kind":"permission","program_app":"deploy","progress":-1}
The two protocols share that one slot, last writer wins: a 5577
status/busy/clear drops the 7501 records, because a program_state
describing text that is no longer on screen is worse than none. Neither
protocol replaces the other — 7501 is deliberately one-way (a program reports,
the terminal decides what to show), so buttons, click and shader have
nowhere to live in it; and 5577 has no idea what done or blocked mean.
A program with several things going at once gives each an id, which may be
a path: build/test is a child of build. Clearing a record clears everything
under it, a record with no app inherits one from its nearest ancestor that has
one, and when several records are true at the same time the pane shows the one
nearest to needing a person: blocked, then error, done, working, idle,
ties going to whichever was written last. So a deploy that is working at the
root while eu-west waits for approval shows the approval.
Records die without anybody saying so, because there is no heartbeat in this
protocol and a status nobody retracted is the failure mode it exists to fix. A
working or blocked record is dropped when the program exits or a new shell
prompt begins (OSC 133 ; A); done and error survive both, which is how a
result is still there when you come back to the pane; idle stays until it is
replaced; and a full reset (ESC c) removes all of them.
To find out whether any of this is listened to, ask: printf
'\033]7501;?\033\\' is answered with the same bytes, \033]7501;?\033\\, and
by nothing at all in a terminal that does not implement it. That reply is a
request — the one shape 5577 forbids — so it is rate-limited to one answer per
250 ms per pane: a program that asks once is answered at once, and a pane that
echoes our answer back into our own scanner stops there instead of trading
megabytes with us.
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.
