OSC 7501, Explained: The Program Status Protocol and How to Emit It From Your Own Code

Orr Yakobi

Orr Yakobi

Posted on Oct 13, 2026
SHARE

OSC 7501 is a terminal escape sequence, published by Mitchell Hashimoto on 6 October 2026, that lets any program tell the terminal whether it is idle, working, done, blocked on the user, or failed, and why. OSC stands for Operating System Command: the family of escape sequences a program writes to its terminal to say something about itself, rather than to draw text.

At SWARECO we read the spec at revision 0.4, dated 10 October 2026, wrote a Ruby encoder and a Sidekiq middleware for it, and tested the output against the spec's grammar and byte limits on 14 October 2026. This post covers the wire format key by key, the limits and record lifetime, who supports it today, the worked example with our measured byte counts, and what the protocol does not solve.

Key Takeaways

  • OSC 7501 is one escape sequence: OSC 7501 ; key=value:key=value ST. The only required key is state, and its value must be one of idle, working, done, blocked, error or clear.
  • The limits are hard: 4096 bytes for the whole sequence, 2048 decoded bytes for msg, 192 for title, 32 for app, and an id path of at most 128 bytes and 8 levels. A report that breaks any of them is discarded whole.
  • Support landed fast: libghostty merged it on 6 October 2026 and Claude Code shipped it in version 2.1.295 on 8 October 2026. Hashimoto's post lists proof-of-concept implementations in Terraform, Claude Code, Codex and Homebrew, each under a dozen lines.
  • Our Sidekiq middleware produces reports of 117 to 133 bytes for a typical job and 3,191 bytes with every field at its limit. When standard output is a pipe rather than a terminal it writes nothing, which is the case for any worker started by a process supervisor.
  • The protocol reaches one terminal and nothing else. It is a developer-experience feature for attended work, not a replacement for a pager, a chat alert or a monitoring dashboard.

What OSC 7501 is

OSC 7501 is the Program Status Protocol: a single escape sequence a program writes to its terminal. OSC sequences begin with ESC ] (bytes 0x1B 0x5D), and this one is OSC 7501 ; pairs ST, where the pairs are key=value items separated by colons and ST is either ESC \ or the BEL byte 0x07.

The only required key is state, and the spec accepts exactly six values: idle, working, done, blocked, error and clear. Terminals that do not know the sequence drop it, so sending it everywhere is safe. The spec's own example, a Terraform plan waiting on an approval, looks like this:

ESC ] 7501 ; state=blocked:kind=permission:app=terraform:msg=QXBwbHkgMyB0byBhZGQsIDEgdG8gY2hhbmdlLCAwIHRvIGRlc3Ryb3k/ ESC \

The base64 msg decodes to Apply 3 to add, 1 to change, 0 to destroy? A program can report several records at once by giving each an id, so a deploy tool can hold a working root record with one child per region.

Why a terminal protocol for program status exists

Long-running programs alternate between working, waiting on the user and finishing: builds, deploys, package upgrades, data processing jobs and AI coding agents all do it. The person running them wants to know when they are needed, and until now the terminal had no standard way to be told.

The approaches that existed were guesses. A terminal could watch the foreground process or wait for output to go quiet; tools that watch several coding-agent sessions scraped the screen, read window titles, or used program-specific out-of-band APIs. Local sockets do not survive SSH or a container boundary without extra bridging, while the pseudo-terminal does, which is why Hashimoto put the signal on the pty.

We have our own version of the problem. SWARECO runs Sidekiq-heavy background processing across about eight codebases, and today the question "is this job idle, working or stuck" is answered by reading logs or a dashboard, never by the terminal the job is running in. (Two of the harder cases are in our post on two Sidekiq race conditions behind one pause button.)

The wire format, key by key

KeyRequiredAllowed valuesNotes
staterequiredidle, working, done, blocked, error, clearAn unrecognised value makes the terminal discard the whole report.
idoptionalA path of segments, each 1 to 32 characters from letters, digits, underscore, dot, plus and hyphen, separated by /Absent means the root record. Nesting is expressed through the path. 128 bytes in total, 8 levels deep.
kindoptionalpermission, question, authOnly meaningful with blocked.
progressoptionalInteger 0 to 100Only meaningful with working or blocked. Absent means indeterminate.
appoptional1 to 32 characters, same alphabet as idA stable machine-readable program name such as cargo or claude-code. Inherited from the nearest ancestor record if absent.
titleoptionalbase64 UTF-8A short human-readable label. 256 bytes encoded, 192 decoded.
msgoptionalbase64 UTF-8, padding optionalOne human-readable line. 2732 bytes encoded, 2048 decoded.

The grammar rules are short:

  • Keys are lowercase letters only, at most 16 bytes.
  • Values use letters, digits and the characters _ . , + / = -.
  • Unknown keys are skipped. A repeated key uses its last value. A malformed pair is skipped.
  • A whole report is discarded if it breaks a limit, if its base64 does not decode, or if the decoded text contains a control character.

Limits, record lifetime and feature detection

The limits, from revision 0.4 of the spec: the whole sequence is at most 4096 bytes; a key is at most 16 bytes; msg is 2732 bytes encoded and 2048 decoded; title is 256 encoded and 192 decoded; app is 32 bytes; id is 128 bytes in total, 32 per segment, 8 levels deep. A terminal holds up to 256 records and must support at least 64. When a new record would exceed the cap, the least recently updated record is removed.

Record lifetime is defined precisely. Each report replaces its record completely, so a key missing from a report is missing from the record afterwards. clear removes the addressed record and every record beneath it; with no id it removes every record. idle lasts until replaced or cleared. done and error survive both process exit and the return to the shell, which is the point: the record is left behind for the user to find. At process exit or the return to the shell, the terminal must drop working and blocked records and may drop idle ones. There is no heartbeat, and terminals must not require re-sending to keep a record alive. A full reset (RIS) removes every record; a soft reset (DECSTR) does not.

Two behaviours are asked of programs: when the user interrupts or cancels, report idle; and a program that exits as soon as it finishes should report done or error right before exiting.

Feature detection is a query. The program sends OSC 7501 ; ? ST, and a supporting terminal replies:

OSC 7501 ; ? : states=idle,working,done,blocked,error : kinds=permission,question,auth ST

The states and kinds pairs are optional in the reply; without them the terminal accepts exactly the five states and three kinds. Revision 0.4 removed the terminfo capability, so the query is the only detection method.

On security, the spec requires terminals to refuse reports whose decoded msg or title contain control characters and never to treat them as markup. The only bytes a terminal ever writes back are the feature-detection reply, so records cannot be read by another program. Terminals should rate-limit external effects such as notifications and sounds.

Who supports it today

libghostty merged support in pull request 14560 on 6 October 2026, milestone 1.4.0. In that implementation the embedder keeps one record per id and applies the lifetime rules, a full reset reports a clear of every record, and the terminal only answers the support query while the feature is enabled. The Ghostty app itself does nothing with the reports for now. Rex, Hashimoto's own terminal, supports it.

Claude Code added support in version 2.1.295 on 8 October 2026, two days after the spec was published. Its changelog entry reads: "Added Program Status Protocol (OSC 7501) support: terminals that implement it can show whether Claude Code is working, waiting on you, or done." Hashimoto's post also lists proof-of-concept implementations in Terraform, Claude Code, Codex and Homebrew, each under a dozen lines, via plugins or forks.

A worked example: emitting OSC 7501 from a Sidekiq job

The spec's own example is a shell function. It wraps rsync, reports working before the copy and done or error after it, and base64-encodes the message with base64 | tr -d '\n' so no newline reaches the terminal:

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"

On 14 October 2026 we wrote the same thing for Ruby: a small module with one method that builds the sequence, validates state, kind, id, app and the byte limits, base64-encodes title and msg, and writes nothing unless standard output is a TTY. On top of it sits a Sidekiq server middleware that reports working when a job starts, done when it finishes and error when it raises, using the job id as the record id, the worker class as the title and sidekiq as the app.

# frozen_string_literal: true
require "base64"

# Emits OSC 7501 (Program Status Protocol, rev 0.4) reports to a terminal.
# Pure Ruby, no gems. Safe to call when stdout is not a terminal: it does nothing.
module ProgramStatus
  STATES = %w[idle working done blocked error clear].freeze
  KINDS  = %w[permission question auth].freeze
  SEGMENT = /\A[A-Za-z0-9_.+-]{1,32}\z/
  ESC = "\e"
  OSC = "#{ESC}]"
  ST  = "#{ESC}\\"
  MAX_SEQ = 4096
  MAX_MSG_DECODED = 2048
  MAX_TITLE_DECODED = 192

  module_function

  def enabled?(io = $stdout)
    io.respond_to?(:tty?) && io.tty? && ENV["PROGRAM_STATUS"] != "0"
  end

  def sequence(state:, id: nil, kind: nil, progress: nil, app: nil, title: nil, msg: nil)
    raise ArgumentError, "state #{state.inspect}" unless STATES.include?(state.to_s)
    raise ArgumentError, "kind #{kind.inspect}" if kind && !KINDS.include?(kind.to_s)
    pairs = ["state=#{state}"]
    if id
      segs = id.to_s.split("/", -1)
      ok = id.to_s.bytesize <= 128 && segs.size <= 8 && segs.all? { |g| g.match?(SEGMENT) }
      raise ArgumentError, "id #{id.inspect} breaks the 128-byte / 32-per-segment / 8-level limits" unless ok
      pairs << "id=#{id}"
    end
    pairs << "kind=#{kind}" if kind
    pairs << "progress=#{Integer(progress).clamp(0, 100)}" if progress
    if app
      raise ArgumentError, "app #{app.inspect}" unless app.to_s.match?(SEGMENT)
      pairs << "app=#{app}"
    end
    pairs << "title=#{b64(title.to_s[0, MAX_TITLE_DECODED])}" if title
    pairs << "msg=#{b64(msg.to_s.delete("\n")[0, MAX_MSG_DECODED])}" if msg
    seq = "#{OSC}7501;#{pairs.join(':')}#{ST}"
    raise ArgumentError, "sequence is #{seq.bytesize} bytes, limit #{MAX_SEQ}" if seq.bytesize > MAX_SEQ
    seq
  end

  def report(io = $stdout, **kw)
    return unless enabled?(io)
    io.write(sequence(**kw))
    io.flush
  end

  def b64(text)
    Base64.strict_encode64(text.encode("UTF-8"))
  end
end

# Sidekiq server middleware: one record per job, under the process's root record.
# config/initializers/sidekiq.rb:
#   Sidekiq.configure_server do |config|
#     config.server_middleware { |chain| chain.add ProgramStatusMiddleware }
#   end
class ProgramStatusMiddleware
  APP = "sidekiq"

  def call(worker, job, queue)
    jid = job["jid"].to_s[0, 32]
    ProgramStatus.report(state: "working", id: jid, app: APP,
                         title: worker.class.name, msg: "#{queue}: #{worker.class.name} started")
    yield
    ProgramStatus.report(state: "done", id: jid, app: APP,
                         title: worker.class.name, msg: "#{queue}: #{worker.class.name} finished")
  rescue => e
    ProgramStatus.report(state: "error", id: jid, app: APP,
                         title: worker.class.name, msg: "#{queue}: #{worker.class.name} #{e.class}: #{e.message}")
    raise
  end
end

Running it gives the numbers in the table below. The largest report the encoder will emit, with every field at its limit, stays inside the 4096-byte cap. When standard output was a pipe rather than a terminal, the module wrote nothing, which matters because a Sidekiq process started by a supervisor has no terminal.

ReportFieldsBytes
workingid, app, title, msg126
blockedkind=auth, app, msg117
errorid, app, msg133
clearid36
largest legal reportevery field at its limit3,191

This is a tested encoder, not something SWARECO has deployed to a client's production workers.

What it does not solve

The protocol reaches the terminal the program is attached to and nothing else, so it is not a replacement for a pager, a chat alert or a monitoring dashboard. A worker started under a process supervisor without a terminal has nowhere to send it. The spec defines a terminal as one window, tab, split or pane attached to one pseudo-terminal and gives no guidance for multiplexers.

Records cannot be read back by any program, so a dashboard cannot be built on top of them. The terminal decides what to do with a report and may show nothing at all, as the Ghostty app currently does. And the spec is at revision 0.4 and days old, so details may still change.

Why coding agents were the first adopters

A coding agent such as Claude Code spends long stretches working, then stops to ask for a permission or an answer. That is exactly the blocked state with kind=permission or kind=question. Before OSC 7501, the tools that watch several agent sessions had to scrape the screen or read window titles to know which session needed a person, which is why Claude Code shipped support two days after the spec was published.

Our own note from using it: SWARECO runs Claude Code both interactively and headless on a schedule. A headless run writes to a pipe, not a terminal, so OSC 7501 helps the interactive sessions where a person is waiting and does nothing for the scheduled runs, which still need their own reporting. That is why we see the protocol as a developer-experience feature for attended work, not an observability feature.

Conclusion

One escape sequence, six states, a handful of keys, hard byte limits, feature detection by query, and support already in libghostty and Claude Code. If you own a long-running tool, add it, starting with the shell function above. SWARECO builds and runs engineering teams that work this way, and the encoder in this post is the one we tested.

FAQs

1. What is OSC 7501?

The Program Status Protocol: a terminal escape sequence, OSC 7501 ; state=...:key=value ST, that lets a program tell its terminal whether it is idle, working, done, blocked on the user, or failed. Mitchell Hashimoto published it on 6 October 2026.

2. Which states does OSC 7501 allow?

Exactly six: idle, working, done, blocked, error and clear. Any other value makes the terminal discard the whole report.

3. How do I detect whether a terminal supports OSC 7501?

Send OSC 7501 ; ? ST. A supporting terminal replies with OSC 7501 ; ? followed by optional states and kinds lists. Revision 0.4 removed the terminfo capability, so the query is the only method.

4. How big can a report be?

4096 bytes for the whole sequence. The msg is at most 2048 decoded bytes, title 192, app 32, and the id path 128 bytes across at most 8 levels.

5. Which terminals and programs support it?

libghostty merged support on 6 October 2026 and Rex supports it. Claude Code added it in version 2.1.295 on 8 October 2026. Hashimoto's post lists proof-of-concept implementations in Terraform, Codex and Homebrew.

6. Does it work for a background worker?

Only if the worker's standard output is a terminal. A Sidekiq process started by a supervisor has no terminal, so our middleware writes nothing in that case, and you still need logs or a dashboard for unattended jobs.

Other Articles

We build the engineering. You build the business.

If you are trying to figure out whether SWARECO is the right fit for what you are building, the best way to find out is to talk. Tell us what you have. We will be direct about what we can do and how we would approach it.