aboutsummaryrefslogtreecommitdiffstats

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.