# Program Status Protocol (OSC 7501)

> A proposal for a program to tell its terminal what it is doing: idle, working, waiting on the user, finished, or failed, and why.

- **Sequence:** `OSC 7501 ; key=value : key=value … ST`
- **Specification:** [Program Status Protocol (OSC 7501), revision 0.4](https://www.superlogical.com/rex/docs/build/program-status)

The *Program Status Protocol* lets a program tell its terminal what state
it is in: at rest, working, waiting on the user, finished, or failed, with
a line saying why. A build, a deployment, a package upgrade or a coding
agent alternates between working on its own and needing the user, while the
user is usually looking at something else. With this protocol the program
reports its state and the terminal keeps a current picture of it. How that
picture is shown is left entirely to the terminal: the proposal avoids
words like "notification" or "attention" that would tie it to a particular
kind of display.

This summarizes revision 0.4 of the proposal, dated 2026-10-10, as it is
published in the
[Rex documentation](https://www.superlogical.com/rex/docs/build/program-status).
Its author is Mitchell Hashimoto. Revision 0.1, the initial draft of
2026-09-28, was published as
[a gist](https://gist.github.com/mitchellh/7acae3abd8355c1c00287d67e96c913a/f838e7364cf4fa56e74814df517cac8c716d82d1);
0.2, of 2026-10-06, applies the RFC 2119 keywords in more places; 0.3, of
2026-10-07, says that a missing `progress` means the program is busy
without a percentage to give, and that a program may send reports without
asking first whether they are supported; and 0.4 lets the answer to that
question list the states and kinds a terminal accepts, says why control
characters are refused and what `app` is for, and drops the terminfo
capability. Rex, a terminal from
Superlogical, implements it, showing records as indicators in its session
picker and tab headers, but the proposal says the protocol has nothing in
it specific to Rex.

libghostty-vt implements it as of
[0f171f6](https://github.com/ghostty-org/ghostty/tree/0f171f650a1cbbd6df4a2573b6b0c7de57cc6d90).
It checks each report as the proposal requires and hands the ones that pass
to the host through `GHOSTTY_TERMINAL_OPT_PROGRAM_STATUS`, keeping no
records of its own; a full reset hands over a `clear` with an empty id. It
answers `OSC 7501 ; ? ST` only while the host has set that callback, so the
terminal on these pages, which does not set it, stays silent.

## The sequence

```
OSC 7501 ; key=value : key=value … ST     a report
OSC 7501 ; ? ST                           asking whether it is supported
```

A report is a list of `key=value` pairs separated by `:`. Keys are
lower-case letters; values use only letters, digits and `_ . , + / = -`,
which leaves out `:` and `;`, so nothing ever needs escaping, and
whitespace around a key or a value is removed. Free text,
in `msg` and `title`, is base64 of UTF-8, with the padding optional, and
must not decode to any control character, not even a newline or a tab,
since each is meant to be a single line. A malformed pair is skipped and
the rest of the report still applies; an unknown key is ignored, which is
how the protocol is meant to grow; and if a key repeats, the last one wins.
A report that breaks a size limit, has base64 that does not decode, or
decodes to a control character is thrown away whole.

## Records

A terminal, meaning one window, tab or pane on one pseudo-terminal, keeps a
set of *records*, one for each `id`. A report with no `id` is about the
root record, which is all a program reporting only its own state needs.
**Each report replaces its record completely**: a key it leaves out is gone
from the record afterwards, so a program repeats `app` or `title` in every
report it wants them on.

An `id` is a path of segments separated by `/`, such as `build/test`, which
is a child of `build` whether or not `build` exists. The relationship is
used for clearing a record and everything beneath it, and for a child to
take `app` from its parent, and a terminal may use it for grouping. Root
and child records exist side by side: a program can be idle at its prompt
while one of its workers is blocked. Records belong to the terminal, not
the screen, so switching to the [alternate screen](https://control-codes.page/modes/altscreen/index.html.md) does
not touch them. [RIS](https://control-codes.page/esc/ris/index.html.md) removes them all; [DECSTR](https://control-codes.page/csi/decstr/index.html.md)
does not.

## States

`state` is required, and a report without it, or with a state the terminal
does not know, is ignored, so that a state added later never reads as
`idle` to an older terminal.

| `state` | Meaning | Kept until |
| --- | --- | --- |
| `idle` | At rest, waiting for the user's next instruction | replaced or cleared |
| `working` | Running; may give `progress` | replaced or cleared, the process exits, or the next shell prompt |
| `blocked` | Cannot go on until the user acts: `kind` says how, `msg` says why; may give `progress` | as `working` |
| `done` | Finished, with a result the user has not seen yet | replaced or cleared; survives the process exiting and the next prompt |
| `error` | Failed and stopped | as `done` |
| `clear` | Not a state: removes the record and everything beneath it, or with no `id`, every record | |

A program the user interrupts reports `idle`, and one that exits as soon as
it finishes should report `done` or `error` first, so that something is
left for the user to find. Nothing has to be resent to keep a record alive:
when the process exits, or a new prompt begins ([OSC 133](https://control-codes.page/osc/prompt/index.html.md)
`A`), the terminal must drop `working` and `blocked` records and may drop
`idle` ones, while `done` and `error` stay until the terminal decides to
stop showing them, for instance once the user comes back and presses a key.

## Keys

| Key | Value | Meaning |
| --- | --- | --- |
| `state` | one of the states above | Required |
| `id` | a path of up to 8 segments of up to 32 characters | The record; none means the root |
| `kind` | `permission`, `question` or `auth` | With `blocked` only: approval, a typed answer, or a login or credential; any other value counts as none |
| `progress` | `0` to `100` | With `working` or `blocked`; none means indeterminate, busy with no percentage to give, and anything out of range counts as none |
| `app` | up to 32 letters, digits and `_ . + -` | A stable name for the program, such as `cargo` or `terraform`, for grouping, filtering or choosing an icon, and not a label, which is what `title` is; a record without one takes it from its nearest ancestor that has one, and any other value counts as none |
| `title` | base64 | A short label for the record, for a program that reports several; the root record's label is normally the window title |
| `msg` | base64 | One line on what the record is doing, waiting for, or has finished; the terminal may shorten it and must not read meaning into it |

For example, `make` building documentation, then stopping to ask whether
to overwrite a file, then finishing:

```
OSC 7501 ; state=working:app=make:msg=QnVpbGRpbmcgZG9jcw== ST                              "Building docs"
OSC 7501 ; state=blocked:kind=question:app=make:msg=T3ZlcndyaXRlIGNvbmZpZy50b21sPw== ST    "Overwrite config.toml?"
OSC 7501 ; state=done:app=make:msg=RG9jcyBidWlsdA== ST                                     "Docs built"
```

## Asking whether it is supported

Asking is optional. A program may send reports without asking first, for
instance when it cannot read what the terminal sends back: a terminal that
behaves properly already ignores an OSC it does not know, so a report sent
to one that does not implement the protocol does no harm. A program that
does want to know sends `OSC 7501 ; ? ST`, and a terminal that implements the
protocol must answer, starting with `?`:

```
OSC 7501 ; ? ST                                                         asked by the program
OSC 7501 ; ? : states=idle,working,done,blocked,error : kinds=permission,question,auth ST
```

The terminal may add `states`, the states it accepts, not counting
`clear`, and `kinds`, the kinds it accepts. An answer without `states`
means exactly the five states above, and one without `kinds` exactly the
three kinds, and those two lists are fixed for good: this is how later
revisions add states and kinds, with a program that wants a new one
checking the list and falling back to one that is there. A program ignores
any pair in the answer that it does not understand. No answer, within
whatever time the program chooses, means unsupported; a program that would
rather not wait sends [DA1](https://control-codes.page/csi/da/index.html.md), `CSI c`, straight after the query,
and since every terminal answers that, its reply arriving first means
unsupported.

## Security and limits

Everything in a report is untrusted. A terminal must refuse a `msg` or
`title` that decodes to a control character, must not treat either as
markup, and should neutralize text direction overrides and other invisible
formatting when it shows them outside the grid. The only thing a terminal
ever writes back is the answer to `?`, which holds nothing taken from a
report: ids, titles and messages are never sent back, and records cannot be read. Anything shown from a record
should say which terminal it came from, so that a program cannot pass
itself off as one running elsewhere. A program can change state as fast as
it can write, so a terminal should rate-limit anything a record causes
outside the terminal, such as a desktop notification or a sound.

Every limit is a hard cap on what one report can make a terminal store or
do, a terminal may set lower ones, and a report over any of them is
discarded whole, after every pair has
been checked and before any record is touched:

| Item | Limit |
| --- | --- |
| The whole sequence | 4096 bytes; the largest legal report is under 3300 |
| A key | 16 bytes |
| `msg` | 2732 bytes encoded, 2048 decoded |
| `title` | 256 bytes encoded, 192 decoded |
| `app` | 32 bytes |
| `id` | 128 bytes, 32 to a segment, 8 segments deep |
| Records per terminal | 256, and at least 64; past the cap, the least recently updated record makes way |

## Other sequences

The proposal sets itself against the sequences that cover parts of the same
ground:

- [OSC 9 ; 4](https://control-codes.page/osc/progress/index.html.md), progress, says only busy, a percentage, or
  an error, with no message or identity and no way to say the program is
  waiting on the user; and it shares its number with iTerm2's
  [OSC 9](https://control-codes.page/osc/notify/index.html.md) notifications. A program may send both, and a
  terminal may map OSC 9 ; 4 to the root record; but since a mapped report
  would replace the root record whole, wiping out the `kind` and `msg` of a
  real one, it should stop mapping once it has seen an OSC 7501 report,
  until the next full reset.
- Notifications, [OSC 9](https://control-codes.page/osc/notify/index.html.md), [OSC 99](https://control-codes.page/osc/kitty-notify/index.html.md) and
  [OSC 777](https://control-codes.page/osc/rxvt/index.html.md), are one-time events that leave the terminal with
  no record of what is still true, and always imply interrupting the user.
  A terminal may raise one when a record changes.
- [OSC 133](https://control-codes.page/osc/prompt/index.html.md) comes from the shell, not the program, so it can
  say a command is running or finished, but not what it is doing or whether
  it is stuck.
- [OSC 0](https://control-codes.page/osc/icon-and-title/index.html.md) and [OSC 2](https://control-codes.page/osc/title/index.html.md), the title, is one
  free-form string that programs overload with spinners and symbols.
- [OSC 21337](https://control-codes.page/osc/iterm2-status/index.html.md), iTerm2's session status, and Orca's
  OSC 9999 are earlier attempts at the same idea, which the proposal finds
  too concerned with presentation, colors and indicators, or too reliant on
  JSON.

---

This is the Markdown version of <https://control-codes.page/proposals/osc-7501/>. On that page every validation case runs live in libghostty-vt, the terminal emulation core of Ghostty, compiled to WebAssembly.
