apex · updated October 11, 2026 · 159904621b04

Program Status Protocol (OSC 7501)

Relevant source files

The Program Status Protocol lets a program running in an apex terminal say what it is doing: at rest, working (and how far along), finished, waiting on the user (and for what), or failed. The program writes an OSC 7501 escape sequence. apex reads it and keeps the result as records in the terminal's replicated state. It also derives a window status from the records, and that status is what the user sees: a pill after the window's label, a row under its name in the sidebar, a turning handle while it works, and a notification when the status asks for the user.

The protocol's reading, limits and record life rules live in one dependency-light module, apex-core's status.rs. The rest of the path runs through the terminal plumbing described in Terminals. The term shard it writes to is described in Entries, State and Apply, and the daemon that hosts the terminal is in The Daemon (apexd). Consumers include the agent tool (Coding Agents), the SDK (Writing Tools) and the client (Sessions, Tabs and Window Chrome).

OSC 7501 ; state=working:app=brew:msg=<base64> ST

Overview of the data path

Two things are kept, at two levels. The terminal carries every record its programs have reported (Term::status). The window carries one WindowStatus, the summary of the most pressing record, in Window::status. A tool, the apex win status command or the OSC 9;4 progress path can also set a window's status. So WindowStatus is the one thing every consumer reads, whoever said it. The window's notification is derived from that status too, not from the records (see below).

Diagram

The program's bytes never reach the screen parser. scan in term_loop.rs cuts the sequence out of the stream and reports it as a label. The daemon leads the term shard, so it appends TermOp::Status and TermOp::Progress directly. The window's status is in the window shard, which it does not lead, so it goes by proposal (Proposal::SetStatus, see Proposals).

Sources: crates/apex-core/src/status.rs:1-9, crates/apex-server/src/term_loop.rs:96-115, crates/apex-server/src/lib.rs:653-773, crates/apex-server/src/lib.rs:1975-1999

The wire format and its parser

A report body (after 7501;) is a list of key=value pairs separated by :. status::parse turns it into a Report, which is either Set(Record) or Clear(Option<String>).

Key Meaning Rules in parse
state idle, working, done, blocked, error, or clear Required. Anything else discards the report. clear yields Report::Clear.
id Names a record, with / giving hierarchy (e.g. build/test). Optional. Absent means the root, stored as the empty string. At most 128 bytes, at most 8 segments, each non-empty, at most 32 bytes, and made of alphanumerics or _.+-.
kind permission, question or auth Kept only when the state is blocked.
progress 0 to 100 Kept only for working or blocked. Digits only (+5 is rejected) and at most 100.
app The program's name At most 32 bytes, non-empty, id characters. A bad one is treated as absent.
title, msg Free text, base64 The encoded form is capped at 256 or 2732 bytes, the decoded form at 192 or 2048 bytes. No control characters.

The state is State { Idle, Working, Done, Blocked, Error }, the kind is Kind { Permission, Question, Auth }, and the record is Record { id, state, kind, progress, app, title, msg }. Both enums have name() and from_name().

Two failure policies sit side by side, and the tests pin both:

Base64 is decoded by a small local routine. It accepts padded or unpadded standard base64 and rejects anything with non-zero leftover bits.

encode is the inverse of parse: it writes a record (or a clear of an id, or of everything) as a body. The apex status command uses it, and a unit test checks that what encode writes parse reads back as it was.

The query form is a body of just ? (whitespace ignored), recognised by is_query. Programs use it to learn whether the terminal speaks the protocol (see below).

Sources: crates/apex-core/src/status.rs:15-68, crates/apex-core/src/status.rs:93-168, crates/apex-core/src/status.rs:170-251, crates/apex-core/src/status.rs:395-402

Records and their life

Records is a terminal's list of records, ordered by last report. Its methods encode the life rules:

Method Effect
apply(Report::Set) Removes any record with the same id, pushes the new one at the end (replacement is whole, so a key left out is gone). Returns false if the record equals the old one, so a repeat is not a change. Past MAX_RECORDS (256), the least lately reported record is dropped.
apply(Report::Clear(Some(id))) Removes id and everything beneath it (within matches id or id/…). An empty id matches all.
apply(Report::Clear(None)) Removes every record.
settle() Removes Working and Blocked records; Done, Error and Idle stay.
reset() Removes everything (a full terminal reset).
sorted() A copy sorted by id, the form that goes into the replicated state.

Every method returns whether anything changed, and the caller republishes only if it did.

Five free functions read a list of records:

Function Result
app_of(records, id) The record's own app, else its nearest ancestor's, walking up a/b/c → a/b → a → the root.
top(records) The most pressing record: rank blocked, error, done, working, idle. Of records alike, the root.
summary(records) (State, Option<u8>): the top record's state, and the progress of a working record, preferring the root's.
word(state, kind) A state in a word where there is no message: "Working", "Done", "Failed", "Needs permission", "Has a question", "Needs signing in", "Blocked". None for idle.
said(record) What a record says beside the label: the first line of its msg, else of its title.

Sources: crates/apex-core/src/status.rs:253-312, crates/apex-core/src/status.rs:314-369

How the terminal loop reads reports

The pty reader in term_loop.rs runs scan over each chunk before feeding the screen parser. scan recognises OSC sequences ending in BEL or ESC \ and diverts the ones it knows. These are the title (;), the directory (7;), program status (7501;) and prompt marks (133;). Each becomes a Label carrying the offset where it fell. Status keeps the raw body bytes (Label::Status(Vec<u8>)). A sequence split across reads is carried over, up to MAX_LABEL (4096) bytes. ESC c (RIS) yields Label::Reset, and the reset still goes on to the terminal.

Back in the loop, labels are handled in the order the program emitted them:

TermHost::spawn builds that callback. For a status label it calls apex_core::status::parse(&body). A good report becomes TermEvent::Status(report), and None (a discarded report) returns without an event. A Label::Reset becomes TermEvent::Reset.

Sources: crates/apex-server/src/term_loop.rs:23, crates/apex-server/src/term_loop.rs:55-66, crates/apex-server/src/term_loop.rs:96-124, crates/apex-server/src/term_loop.rs:342-364, crates/apex-server/src/term.rs:310-320, crates/apex-server/tests/server.rs:655-670

How records enter replicated state

Each TermHost carries the live Records plus bookkeeping:

Field Meaning
status_heard Has any program spoken 7501?
status_marked The (working, progress) pair last given to the terminal's progress bar.
status_said The window status last set from the records: (state, kind, progress, message).

The server's event handler in Server::pump updates the records in response to term events and sets a status_changed flag:

Event Effect on h.status
TermEvent::Status(report) Sets status_heard = true, then apply(report).
TermEvent::Reset Sets status_heard = false, then reset().
TermEvent::Exit settle(): what was working or waiting ends with the program.
OSC 133 prompt mark A settle(): the shell prompts again, so the program is over.

If anything changed, status_marks runs. It does three things:

  1. Publishes the records. It appends TermOp::Status { records } to Shard::Term(id) with h.status.sorted(). The record set is replaced whole in each entry. State::apply does t.status = records.clone(), and the field is Term::status, #[serde(default)] so older snapshots still load.
  2. Marks the terminal's bar. When the summary says Working (with its progress) and that differs from status_marked, it appends TermOp::Progress.
  3. Sets the window's status. The most pressing record (top) gives a tuple of state, kind, progress (a working record's, root first) and status::said. When that differs from status_said, the host proposes Proposal::SetStatus { window, status }. The status is built by term_status with by: SERVER and since: now_secs(). A terminal with no records any more sets the status to None.

status_marks does not raise notifications. That is the job of Server::status_flags, below.

Proposal::SetStatus is applied by Node::set_window_status, which appends WindowOp::ProgramStatus { status } to the window's shard. The node trims the message, drops an empty one, and keeps the old since when the state, kind and message are the same as before (only progress moved), so a status's age does not reset while a bar creeps forward. The Proposal::Status variant is unrelated: it records an exec's success or failure (ExecStatusOp). There is no separate "working" proposal. A window is working when its status is.

Notifications follow the status

Server::status_flags(log, view) makes a window's notification follow its status, whoever set it: a terminal's records, a tool's set_status or apex win status. The daemon calls it after each round of events and proposals (and also when it is not the leader of the window shard, so the notifications follow the statuses "as the leader left them"). It keeps asking, a map from window to the state that last asked.

Because the notification is raised in the name of the status's author, a tool's goes with the tool. The server test walks it: after Blocked/Auth the window is notified; once the test lowers it with log.unnotify, the status stays Blocked; when Done arrives the window is notified again; after the program exits the window is still notified because the result is not yet seen.

The window's status

pub struct WindowStatus {
    pub state: status::State,
    pub kind: Option<status::Kind>,
    pub progress: Option<u8>,
    pub msg: Option<String>,
    pub by: AttachmentId,
    pub since: u64,
}

by says who said it. Node::window_status returns the status only while its author is still around: the session's own (SERVER) always, a tool's only while that attachment is in meta.attachments. A tool's status therefore goes when the tool does, and the program's stays for the session. WindowStatus::text gives the message, else the state's word; an idle status with no message says nothing. Node::window_working and window_progress are read from the status, not from a separate flag.

Interaction with OSC 9;4

Terminals also understand the older OSC 9;4 progress report (TermEvent::Working). Once a program has spoken 7501 (status_heard), the 9;4 handler ignores reports, since a report of that kind would undo one of this. Otherwise a 9;4 change appends TermOp::Progress and sets (or clears, through terms_status) a Working window status. terms_status only takes a status away when it is SERVER's. A full reset (TermEvent::Reset) clears status_heard, and 9;4 is heard again. When a terminal exits with 9;4 work still marked, the same path takes the status away.

sequenceDiagram
    participant Prog as Program
    participant Loop as term_loop
    participant Srv as Server (daemon)
    participant Shard as term and window shards
    participant UI as UI / tools
    Prog->>Loop: ESC ] 7501 ; state=working:progress=40 ESC \
    Loop->>Srv: TermEvent::Status(Set)
    Srv->>Srv: Records::apply, status_marks
    Srv->>Shard: TermOp::Status, TermOp::Progress
    Srv->>Shard: SetStatus (Working, 40, by SERVER)
    Shard-->>UI: replayed entries
    Prog->>Loop: state=blocked:kind=auth
    Srv->>Shard: TermOp::Status, SetStatus (Blocked)
    Srv->>Srv: status_flags sees an asking status
    Srv->>Shard: notify window, Notice
    Prog->>Loop: process exits
    Srv->>Srv: settle() drops working and blocked
    Srv->>Shard: TermOp::Status, SetStatus (Done stays), TermOp::Exit

The test a_program_in_a_terminal_says_what_it_is_doing in server.rs walks this sequence with a brew script. A Working record at 40% sets the record, the window's working mark and progress, the terminal's progress, and a window status whose text is the message ("Installing"), with since > 0. A Blocked/Auth record clears working, notifies, and the status becomes "Password" with kind Auth. The test's pump_until helper calls status_flags after each event, as the daemon does. After exit only Done remains and the status reads "Done" (no message, so the state's word).

Sources: crates/apex-server/src/term.rs:239-251, crates/apex-server/src/lib.rs:576-608, crates/apex-server/src/lib.rs:653-773, crates/apex-server/src/lib.rs:1975-2022, crates/apex-server/src/daemon.rs:1543-1557, crates/apex-core/src/entry.rs:216, crates/apex-core/src/entry.rs:333-335, crates/apex-core/src/state.rs:74-110, crates/apex-core/src/state.rs:395-397, crates/apex-core/src/state.rs:675, crates/apex-core/src/state.rs:813, crates/apex-core/src/node.rs:710-727, crates/apex-core/src/node.rs:1176-1185, crates/apex-core/src/node.rs:1233-1239, crates/apex-server/src/proposal.rs:74, crates/apex-server/src/proposal.rs:264-267, crates/apex-server/tests/server.rs:25-47, crates/apex-server/tests/server.rs:593-653

Who says and who consumes

The apex status and apex win status commands

apex status [-id=ID] STATE [kind=K] [progress=N] [MSG] | clear makes a program of any shell script. status_cmd parses the arguments with status_args, builds a Record, encodes it and writes ESC ] 7501 ; BODY BEL to the terminal it runs in, so the report takes the same path as a real program's. apex win status WIN STATE ... instead proposes Proposal::SetStatus directly, with by: SERVER, so the status stands after the command exits until it is cleared (apex win status WIN clear). apex win list shows the status (state, kind, progress, msg) per window, in its -json form too.

Tools: Tool::set_status and Tool::term_status

apex-tool has two halves.

The agent tool defers to the agent

apex tool agent (Coding Agents) infers an agent's state from hook logs. It speaks the protocol's vocabulary through status_of, which maps its states onto a Status: working (also "Starting") with the plan's percentage and the running call, blocked (permission with the asked call, else question), done until the user has seen it and idle after, error with the reason, and nothing once ended. sync_status applies that to each agent's window with set_status.

The tool no longer raises notifications itself: apex does that from the status through status_flags, in the tool's name. sync_flags now only follows what happened to each such notification: whether it was raised and then taken (dismissed), which sync_status reads to turn a done status idle once the user has seen it.

Both sync_status and sync_flags filter out any window for which term_status(w) is non-empty. When an agent speaks 7501 itself (Claude Code from 2.1.295) the tool leaves the window's status, handle and notification for apex to manage from the records. When the records go away the tool takes its own status back up. The apex command's help says the same.

The pane test an_agent_saying_what_it_is_doing_is_left_to_apex starts a terminal that prints state=working:app=claude and feeds the tool hook events (SessionStart, UserPromptSubmit, Stop). It checks two things. The tool raises no notification of its own, and the window's status is (SERVER, Working), apex's from the program, not the tool's.

win does not read it yet

apex tool win (win and Language Servers) runs its own pty and uses scan, so the sequences are cut out of the text. But Label::Mark(..) | Label::Status(_) | Label::Reset are ignored: the code notes that the programs' status "is not yet heard here". So a program in a win window can emit 7501 without effect, and the sequence does not appear in the text.

The client

The client only reads Node::window_status; it is a follower and never interprets records. Four places draw it:

What is shown is the terminal's to decide: the core only keeps the records and says "what is shown is the terminal's to decide" in its module comment. See Sessions, Tabs and Window Chrome.

Sources: crates/apex-cli/src/main.rs:604-626, crates/apex-cli/src/main.rs:1462-1471, crates/apex-cli/src/main.rs:1927-1959, crates/apex-tool/src/lib.rs:83-111, crates/apex-tool/src/lib.rs:717-728, crates/apex-tool/src/lib.rs:971-993, crates/apex-tool-agent/src/win.rs:181-203, crates/apex-tool-agent/src/win.rs:494-562, crates/apex-tool-agent/tests/pane.rs:604-664, crates/apex-tool-win/src/lib.rs:518-531, crates/apex-client/src/sidebar.rs:514-537, crates/apex-client/src/sidebar.rs:673-695, crates/apex-client/src/text_element.rs:161-183, crates/apex-client/src/app.rs:2013-2056, crates/apex-client/src/app.rs:3099

Edge cases and design notes

Sources: crates/apex-core/src/status.rs:253-348, crates/apex-core/src/node.rs:710-727, crates/apex-server/src/lib.rs:576-608, crates/apex-server/src/lib.rs:2007-2014, crates/apex-server/src/term_loop.rs:342-349

Previous: TerminalsNext: The I/O Plane and Pages