Any program in a tuios pane can say what it is doing: a build, a deploy script, a package manager or a coding agent. It writes one escape sequence, and tuios shows the state in the rail, the Inbox and the pane's title. The program needs no hook, no socket and no plugin.
The sequence is the Program Status Protocol, OSC 7501, revision 0.2 of 2026-10-06. Read the specification at superlogical.com/rex/docs/build/program-status. The Rex terminal reads it too. libghostty-vt, the library under Ghostty, parses it since ghostty-org/ghostty#14560, merged on 2026-10-06. The Ghostty app does not show it yet. The libghostty-vt that tuios pins predates that change, so on the libghostty-vt backend tuios parses OSC 7501 itself, as on the pure Go one.
# Terraform waits for an approval. The message is base64 of
# "Apply 3 to add, 1 to change, 0 to destroy?".
printf '\e]7501;state=blocked:kind=permission:app=terraform:msg=QXBwbHkgMyB0byBhZGQsIDEgdG8gY2hhbmdlLCAwIHRvIGRlc3Ryb3k/\e\\'Use tuios status. It writes the sequence and does the base64 for you. It
needs no daemon, so a script can use it in any terminal.
tuios status working --app build --msg 'Compiling' --progress 40
tuios status blocked --kind permission --app deploy --msg 'Approve deploy to production?'
tuios status done --app build --msg 'Built 12 crates'
tuios status error --app build --msg 'Linker failed'
tuios status --clear| Flag | What it does |
|---|---|
STATE |
idle, working, done, blocked or error |
--kind |
What a blocked program waits for: permission, question or auth |
--progress N |
Progress from 0 to 100, with working or blocked |
--app NAME |
The program's name, such as cargo or terraform |
--title TEXT |
A short label for the record |
--msg TEXT |
One line that says what the program does or waits for |
--id ID |
The record to address, such as build or build/test |
--clear |
Remove the record and the records under it. Without --id, remove all records |
--stdout |
Write the sequence to standard output, not to the terminal |
The sequence goes to the controlling terminal (/dev/tty), so it is not lost
when the script's output goes to a file or a pipe. tuios status refuses a
report that a terminal must discard, and says why.
Each report replaces its record. A key that a report does not send is gone from
the record. Send --app and --title in every report that must keep them.
A script that cannot call tuios can use this function:
status() {
printf '\e]7501;state=%s:msg=%s\e\\' "$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"#!/bin/sh
tuios status working --app ci --msg 'Running the pipeline'
tuios status working --app ci --id build --title Build --msg 'Compiling' --progress 10
cargo build --release || { tuios status error --app ci --msg 'Build failed'; exit 1; }
tuios status done --app ci --id build --title Build --msg 'Built'
tuios status working --app ci --id test --title Tests --msg 'Testing'
cargo test || { tuios status error --app ci --msg 'Tests failed'; exit 1; }
tuios status --clear --id build
tuios status --clear --id test
tuios status done --app ci --msg 'Pipeline passed'A program that exits right after its work reports done or error first. The
record then stays for you to find.
A program sends OSC 7501 ; ? ST. A terminal that reads the protocol answers
with the same sequence. tuios answers from the pane's emulator, with the
terminator the question used. Send a primary device attributes query (CSI c)
after the question. If that answer comes first, the terminal does not read the
protocol.
A report makes the pane an agent pane, for any program. The agent state comes from the most urgent record of the pane:
| OSC 7501 state | tuios agent state | blocked_by |
|---|---|---|
working |
working |
|
blocked, kind=permission |
needs_input |
approval |
blocked, kind=question |
needs_input |
question |
blocked, kind=auth |
needs_input |
auth |
blocked, no kind |
needs_input |
empty |
done |
done, finished and unread |
|
error |
errored |
|
idle |
idle |
- The rail. The agent row shows the
appwhere a harness name goes, the progress beside the name (build · 40%), and the title and message on the second line. Theprogresstoken of[appearance.sidebar.agent_row]places the progress. - The Inbox. A blocked record opens an approval or a question item, an
errorrecord an error item, and adonerecord a finished item. The item names the pane by its title and by its id, such asbuild [3f2a9c1e], because the program can set the title and cannot set the id. The detail of the item lists every record of the pane. Forkind=auththe Inbox offers no answer: you type a login in the pane that asks for it. - The verbs.
get-agent-stateandlist-agentsreportsource: "program"and the records asprogram_status. - Hooks and alerts. An agent state from a record raises the same hooks, alerts and push notifications as any other agent state. An alert names the pane by its id too. The dock shows every alert. For a pane whose state comes from its own report, the alerts that leave tuios (a notification, a bell, a sound, a client hook) go out at most once in 30 seconds. Inside that time, an alert for the state last sent is dropped as a repeat, and an alert for another state is held: when the 30 seconds end, the newest one held goes out if the pane is still in that state. A hook's alerts are not limited this way.
- A harness hook in the same pane wins. The source
programranks just below a hook's report. When a hook reports for the pane, the records still show, and the pane's state is the hook's. When the records of a pane end, tuios clears the state they set, and the foreground detector and the screen rules look at the pane again. A state that a weaker source held before the program reported is not replayed, since it may no longer be true.
A program can keep records for its parts. The id is a path: build/test is
under build, and build is under the root record, which has no id.
- The pane shows the most urgent record.
blockedcomes first, thenerror,done,workingandidle. Among records in the same state, the root record comes first, then the record that changed last. - A record without
apptakes theappof its nearest parent that has one. state=clearwith anidremoves that record and every record under it. Without anid, it removes every record of the pane.- A pane holds at most 256 records. A new record past that removes the record that changed least recently.
There is no heartbeat. A program does not have to send its state again.
| Event | working, blocked, idle |
done, error |
|---|---|---|
| The shell starts a prompt (OSC 133 A) | removed | kept |
| The program that reported from the foreground exits | removed, within 2 seconds | kept |
| A background job, or the pane's own process, that reported exits | kept | kept |
| You type in the pane | kept | removed |
| A full reset (RIS) | removed | removed |
| A soft reset (DECSTR), or a switch to the alternate screen | kept | kept |
A shell without OSC 133 marks does not tell tuios that a prompt started, so tuios watches process groups instead. When the bytes of a working, blocked or idle report come off the pane's terminal, tuios reads which process group holds the terminal's foreground, and keeps it with the record. The agent detector looks every 2 seconds, and when no process is left in a record's group, that program has exited and its records go. Each record keeps its own group, so a foreground program that exits ends only its own records.
A report from a background job (job &), or from a pane whose own process is
the program (tuios new-window -- cargo watch), does not come from a
foreground group, so its records stay until the program changes them or a
prompt starts. For a pane on another machine, the group has ended when that
machine says the pane's shell holds the foreground again.
One case is not caught: a program that reports and exits in the same instant,
such as printf ...; exit, or tuios status working typed at a prompt, can be
gone before tuios reads the foreground. Its record is then taken for a
background job's and stays until the next prompt (OSC 133 A), the next report
for it, or a clear. A shell that marks its prompts with OSC 133 has no such
gap.
With the agent features off ([agents] enabled = false) the detector does not
run, so the exit rule does not run either. A prompt, typing and a reset still
end records.
Keys that tuios send-text or tuios send-keys types do not count as your
typing for done and error. Keys from an attached client do.
tuios reads OSC 9;4 progress as an agent state on a pane known to run an agent. When a pane sends an OSC 7501 report, tuios stops reading OSC 9;4 for the agent state of that pane, until the next full reset. A progress report would otherwise wipe out the kind and message of the program's own report.
tuios is a program too. When it runs in Rex or another terminal that reads the protocol, it reports the agent states of its panes there. The terminal can then show that a pane in tuios waits for you while tuios is in another tab.
- At start, each local or ssh client asks its terminal
OSC 7501 ; ?. It reports only when the terminal answers. - Each pane of the attached session has one record, with the id
<session>/<pane>. The title is the pane's name. Theappis the agent's harness, or the pane's ownapp. The message is the pane's agent message. - A pane with no agent state, or one in the
unknownstate, has no record. A finished turn that you saw is reported asidle. - tuios sends only the records that changed, at most once in 250 milliseconds. It keeps at most 64 records on the terminal.
- tuios never passes a pane's own sequence through. What the terminal gets is tuios's own report, built from the pane's agent state.
- When a client exits or detaches, it sends
state=clearfor each record it left. A local client then also resets the terminal. An ssh client's terminal gets the clears over the connection, when the connection is still up.
To turn this off:
[agents]
host_program_status = "off" # "auto" is the defaultA browser client of tuios-web does not ask.
tuios applies the limits of the specification. A report that breaks one is discarded whole, and nothing from it is applied.
| Item | Limit |
|---|---|
| The whole sequence, OSC through ST | 4096 bytes |
| A key | 16 bytes |
msg |
2732 bytes encoded, 2048 bytes decoded |
title |
256 bytes encoded, 192 bytes decoded |
app |
32 bytes |
id |
128 bytes, 8 levels, 32 bytes for each level |
| Records in one pane | 256 |
The parser follows the grammar of the specification:
- Values use only
A-Z a-z 0-9 _ . , + / = -. ASCII spaces and tabs around a key or a value are removed. Other blank characters are not, so they make the pair malformed. - Inside the sequence, a C0 control other than BEL and ESC is ignored. CAN and SUB cancel the sequence. An ESC that does not start ST ends the sequence there. Both emulators do the same.
- A pair without
=, with an empty key, or with a character outside the set is skipped. The rest of the report still applies. - An unknown key is ignored. When a key repeats, the last value wins.
- A report without
state, or with a state tuios does not know, is ignored. - A report with an
idthat does not match the grammar is ignored. This includes anidwith a character outside the value set, such asid=a borid=x;y: the report does not fall back to the root record. - A
kindwith a state other thanblocked, or an unknownkind, is ignored. So is aprogressoutside 0 to 100, or with a state other thanworkingorblocked, and anappwith a character outside its set.
Everything in a report comes from a program that already controls the pane. tuios treats it as untrusted text.
- A report whose
msgortitledoes not decode, is not UTF-8, or holds a control character is discarded whole. Base64 is read strictly: padding is optional, but padding that is present must be right, and the unused bits at the end must be zero. Control characters are U+0000 to U+001F, U+007F and U+0080 to U+009F. - tuios never reads markup in a report.
<b>is shown as<b>. - tuios removes bidi overrides, zero width characters and other invisible format characters before it shows the text in the rail, the Inbox or a verb.
- tuios writes back only the fixed answer to
OSC 7501 ; ?. No id, title or message is ever sent back, and a program cannot read the records. - Every place that shows a record names the pane it came from. Alerts and Inbox items add the pane's id, which a program cannot set, to the title, which it can.
- A pane whose state comes from OSC 7501 raises a notification, a bell, a sound or a client hook at most once in 30 seconds, and a change of state in that time goes out when it ends. Push notifications have their own limits for each pane. tuios reports to a host terminal at most once in 250 milliseconds.
appis only a label. tuios does not look it up as a harness, so a program cannot get the answers that a harness's approvals allow.
- Idle records end at a shell prompt and when the program exits. The specification allows this. A program that returned to the shell is no longer at rest in the pane.
- Done and error records end when you type in the pane.
- A blocked record without a kind gives an empty
blocked_by. tuios does not guess the kind from the message, because the specification forbids reading meaning intomsg. - Text that is not UTF-8 discards the report, as a control character does.
- An
applonger than 32 bytes discards the report, because it breaks a limit. Anappwith a character outside its set is treated as absent. - When a key repeats, only the last value is checked. An earlier bad value
that a later one replaces does not discard the report. The exception is
id: anyidpair with a character outside the value set discards the report, so a malformed id never lands on the root record. - The process exit in a pane whose own process is a shell is the end of the process group that held the foreground when the report came in (see How long a record stays).
- OSC 9;4 is not mapped to the root record. tuios keeps its own reading of OSC 9;4 as an agent state, and stops it once a pane sends OSC 7501.
- The answer to the query ends with the terminator the query used, as xterm does for its own queries.