protocols: relay OSC 7501 program status as experimental support #67

Closed
opened 2026-10-09 09:33:18 +09:00 by natsukium · 0 comments
Owner

OSC 7501, the Program Status Protocol, lets a program tell the terminal it is idle, working, blocked on the user, done, or failed, and why. Claude Code, Codex and Terraform already emit it, and libghostty-vt parses it (ghostty-org/ghostty#14560). felis has no reliable way today to tell that a TUI agent in a session is waiting on the user: sessions send --wait relies on OSC 133 D, which a TUI never emits.

felis should support it as experimental, following the notification relay posture (docs/explanation/protocols/notifications.md): decode the reports, keep the records in the daemon, expose them on IPC, draw nothing (no window flash, no attention hint). "Experimental" is a documentation label only, naming the spec revision implemented (0.3 at the time of writing). It does not relax the IPC/CLI contract. Instead the shape below is built so that following a spec revision, or withdrawing support, needs no breaking change.

Layering

  • felis-vt decodes. One table owns the known vocabulary (keys, states, kinds) and the limits. Admitting a new kind is a table change. Admitting a new state also needs a lifetime decision in the daemon.
  • felis-grid dispatches OSC 7501, writes the fixed OSC 7501 ; ? ST reply, and emits typed events for reports and RIS. It stores no records (same split as libghostty).
  • felis-daemon: the session task owns the per-session record set and applies the replace / clear / LRU / lifetime rules.

Decoder contract

All checks finish before any record changes:

  1. Whitespace around keys and values is trimmed before validation.
  2. A malformed pair is skipped (except id). An unknown key is ignored (dropped, not passed through). For a repeated key, the last one wins.
  3. An unrecognized kind, an out-of-range or misplaced progress, a misplaced kind, or an app outside its charset is treated as absent.
  4. A report with a missing or unrecognized state, or a present id that fails the grammar, is ignored. It never falls back to the root record.
  5. A report is rejected whole on any spec limit, base64 that does not decode, or a control character in the decoded msg/title. The 4096-byte sequence cap is the decoder's job, since OSC_BUFFER_LIMIT is 8192.

Records and lifetime

  • One spec "terminal" is one felis session. Records survive detach. LRU eviction applies past 256 records. RIS clears all records; DECSTR and alt-screen switches do not touch them.
  • A primary (non-k=s) OSC 133 A, on whichever screen is active, drops working and blocked records. The same happens on PTY EOF once the final output is parsed (the session itself lingers in post-exit grace). Idle, done and error are kept.
  • Done and error stay until replaced, cleared, RIS, or session reap. Deciding whether the user has seen them is the consumer's job.
  • ? is always answered, with the same posture as the static OSC 99 p=? reply. OSC 9;4 stays consumed-without-effect and is not mapped to the root record.

Public shape

Record (protobuf and JSON), every field optional: {id, state, kind, progress, app, title, msg}.

  • id "" is the root record. app is the raw stored value; ancestor inheritance is left to the consumer. msg/title are decoded UTF-8. Bidi disarming is the job of whoever displays them.
  • state and kind are open strings, not enums. Today's values are an additive vocabulary (the error.kind rule): consumers treat an unknown value as unknown. The decoder still admits only spec values, so following a spec revision changes the decoder table only, with no IPC change.

Snapshot: sessions info / list --format json gain an optional program_status: [record…].

Stream: a dedicated Stream-class verb (working name felis sessions status-subscribe [--session <id>]) plus a bridge op. It is separate from notifications subscribe and has its own broadcast ring. Items carry full state per session, not deltas, the same shape cmux settled on:

{"v":1,"event":"subscribed","filter":S?,"sessions":[S…]}
{"v":1,"event":"status","session":S,"records":[…],"changed":{"cause":C,"ids":[…]}}
{"v":1,"event":"gone","session":S}
  • records is authoritative. changed is a hint for consumers that react to transitions. cause (report, clear, prompt, exit, evict, reset, resync) and event are additive vocabularies, and a later event never changes what records means.
  • Only a session's own task publishes its items, so per-session order is the hub's order and no sequence numbers are needed.
  • Subscribe: under the pool lock, the daemon resolves the filter, takes a hub receiver, and captures the in-scope session task handles (Listing::Held included). It then writes the ack: a Subscribed { filter: ResolvedId?, sessions } frame, so no_match / ambiguous are typed. Finally it asks each captured task for a cause: resync item. A task that is already shutting down answers with gone.
  • Consumer contract: subscribed starts a new state epoch and the consumer discards its previous map. The initial picture is complete once a status or gone has arrived for every id in subscribed.sessions.
  • Lag: the standard lag item, then the clean terminal, and the consumer re-subscribes. The daemon does no resnapshot or synthetic gone.
  • gone is the session's last item, published by supervise after the session task returns, whether normally or by panic. Every roster exit ends that task.
  • Terminals:
    • Clean end on daemon stop, after lag, or after the filtered session's gone. count counts the status and gone items, excluding the ack and lag. Exit 0.
    • Failing: no_match / ambiguous (1), daemon_unreachable / daemon_lost (2), unsupported.

Wire: a new message family under a minor bump, send-gated by the effective minor. The same minor adds StreamErrorReason::Unsupported, mapped to unsupported.

Retirement path:

  • Stop replying to ? and stop decoding.
  • Stop emitting program_status.
  • Keep the verb and bridge op, failing with unsupported.
  • Keep the family decodable within the major. Reserve its field numbers only at a protocol-major transition.

Tests

  • Decoder outcomes 0–4.
  • Expiry on the prompt (k=s excluded) and on PTY EOF.
  • Gone after kill, panic, and forced stop.
  • A report racing the drain.
  • A held session reporting before subscribe.
  • Filtered subscribe against a session that exits during subscribe.
  • Re-subscribe after lag.

Docs

  • Support-matrix row.
  • Reference and explanation twins under docs/*/protocols/.
  • cli.md and ipc.md.
  • The product skill skills/felis.

Not in this issue

  • A wait primitive (--until done|blocked|error or similar) for agents waiting on TUI agents.
  • The terminfo Pst capability, once the spec stabilizes. Ghostty also holds it back for now.
  • Carrying records across an in-place upgrade. Until then they are dropped on handoff, and that is documented.
  • Passing unknown keys through (cmux asks for it). Rejected for now because it widens the public shape beyond the spec.
OSC 7501, the [Program Status Protocol](https://www.superlogical.com/rex/docs/build/program-status), lets a program tell the terminal it is idle, working, blocked on the user, done, or failed, and why. Claude Code, Codex and Terraform already emit it, and libghostty-vt parses it ([ghostty-org/ghostty#14560](https://github.com/ghostty-org/ghostty/pull/14560)). felis has no reliable way today to tell that a TUI agent in a session is waiting on the user: `sessions send --wait` relies on OSC 133 `D`, which a TUI never emits. felis should support it as **experimental**, following the notification relay posture (`docs/explanation/protocols/notifications.md`): decode the reports, keep the records in the daemon, expose them on IPC, draw nothing (no window flash, no attention hint). "Experimental" is a documentation label only, naming the spec revision implemented (0.3 at the time of writing). It does not relax the IPC/CLI contract. Instead the shape below is built so that following a spec revision, or withdrawing support, needs no breaking change. ## Layering - **felis-vt** decodes. One table owns the known vocabulary (keys, states, kinds) and the limits. Admitting a new `kind` is a table change. Admitting a new `state` also needs a lifetime decision in the daemon. - **felis-grid** dispatches OSC 7501, writes the fixed `OSC 7501 ; ? ST` reply, and emits typed events for reports and RIS. It stores no records (same split as libghostty). - **felis-daemon**: the session task owns the per-session record set and applies the replace / clear / LRU / lifetime rules. ## Decoder contract All checks finish before any record changes: 0. Whitespace around keys and values is trimmed before validation. 1. A malformed pair is skipped (except `id`). An unknown key is ignored (dropped, not passed through). For a repeated key, the last one wins. 2. An unrecognized `kind`, an out-of-range or misplaced `progress`, a misplaced `kind`, or an `app` outside its charset is treated as absent. 3. A report with a missing or unrecognized `state`, or a present `id` that fails the grammar, is ignored. It never falls back to the root record. 4. A report is rejected whole on any spec limit, base64 that does not decode, or a control character in the decoded `msg`/`title`. The 4096-byte sequence cap is the decoder's job, since `OSC_BUFFER_LIMIT` is 8192. ## Records and lifetime - One spec "terminal" is one felis session. Records survive detach. LRU eviction applies past 256 records. RIS clears all records; DECSTR and alt-screen switches do not touch them. - A primary (non-`k=s`) OSC 133 `A`, on whichever screen is active, drops working and blocked records. The same happens on PTY EOF once the final output is parsed (the session itself lingers in post-exit grace). Idle, done and error are kept. - Done and error stay until replaced, cleared, RIS, or session reap. Deciding whether the user has seen them is the consumer's job. - `?` is always answered, with the same posture as the static OSC 99 `p=?` reply. OSC 9;4 stays consumed-without-effect and is not mapped to the root record. ## Public shape Record (protobuf and JSON), every field optional: `{id, state, kind, progress, app, title, msg}`. - `id` `""` is the root record. `app` is the raw stored value; ancestor inheritance is left to the consumer. `msg`/`title` are decoded UTF-8. Bidi disarming is the job of whoever displays them. - `state` and `kind` are **open strings**, not enums. Today's values are an additive vocabulary (the `error.kind` rule): consumers treat an unknown value as unknown. The decoder still admits only spec values, so following a spec revision changes the decoder table only, with no IPC change. **Snapshot:** `sessions info` / `list --format json` gain an optional `program_status: [record…]`. **Stream:** a dedicated Stream-class verb (working name `felis sessions status-subscribe [--session <id>]`) plus a bridge op. It is separate from `notifications subscribe` and has its own broadcast ring. Items carry **full state per session, not deltas**, the same shape cmux settled on: ``` {"v":1,"event":"subscribed","filter":S?,"sessions":[S…]} {"v":1,"event":"status","session":S,"records":[…],"changed":{"cause":C,"ids":[…]}} {"v":1,"event":"gone","session":S} ``` - `records` is authoritative. `changed` is a hint for consumers that react to transitions. `cause` (`report`, `clear`, `prompt`, `exit`, `evict`, `reset`, `resync`) and `event` are additive vocabularies, and a later event never changes what `records` means. - Only a session's own task publishes its items, so per-session order is the hub's order and no sequence numbers are needed. - **Subscribe:** under the pool lock, the daemon resolves the filter, takes a hub receiver, and captures the in-scope session task handles (`Listing::Held` included). It then writes the ack: a `Subscribed { filter: ResolvedId?, sessions }` frame, so `no_match` / `ambiguous` are typed. Finally it asks each captured task for a `cause: resync` item. A task that is already shutting down answers with `gone`. - **Consumer contract:** `subscribed` starts a new state epoch and the consumer discards its previous map. The initial picture is complete once a `status` or `gone` has arrived for every id in `subscribed.sessions`. - **Lag:** the standard `lag` item, then the clean terminal, and the consumer re-subscribes. The daemon does no resnapshot or synthetic `gone`. - `gone` is the session's last item, published by `supervise` after the session task returns, whether normally or by panic. Every roster exit ends that task. - **Terminals:** - Clean `end` on daemon stop, after `lag`, or after the filtered session's `gone`. `count` counts the `status` and `gone` items, excluding the ack and `lag`. Exit `0`. - Failing: `no_match` / `ambiguous` (`1`), `daemon_unreachable` / `daemon_lost` (`2`), `unsupported`. **Wire:** a new message family under a minor bump, send-gated by the effective minor. The same minor adds `StreamErrorReason::Unsupported`, mapped to `unsupported`. **Retirement path:** - Stop replying to `?` and stop decoding. - Stop emitting `program_status`. - Keep the verb and bridge op, failing with `unsupported`. - Keep the family decodable within the major. Reserve its field numbers only at a protocol-major transition. ## Tests - Decoder outcomes 0–4. - Expiry on the prompt (`k=s` excluded) and on PTY EOF. - Gone after kill, panic, and forced stop. - A report racing the drain. - A held session reporting before subscribe. - Filtered subscribe against a session that exits during subscribe. - Re-subscribe after lag. ## Docs - Support-matrix row. - Reference and explanation twins under `docs/*/protocols/`. - `cli.md` and `ipc.md`. - The product skill `skills/felis`. ## Not in this issue - A wait primitive (`--until done|blocked|error` or similar) for agents waiting on TUI agents. - The terminfo `Pst` capability, once the spec stabilizes. Ghostty also holds it back for now. - Carrying records across an in-place upgrade. Until then they are dropped on handoff, and that is documented. - Passing unknown keys through (cmux asks for it). Rejected for now because it widens the public shape beyond the spec.
Sign in to join this conversation.
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
felis-terminal/felis#67
No description provided.