Skip to content

notify: roost's notifications came from "Script Editor" - #152

Merged
navbytes merged 1 commit into
mainfrom
fix/notify-via-host-terminal
Aug 21, 2026
Merged

notify: roost's notifications came from "Script Editor"#152
navbytes merged 1 commit into
mainfrom
fix/notify-via-host-terminal

Conversation

@navbytes

Copy link
Copy Markdown
Owner

Reported: every desktop notification roost raises on macOS is attributed
to an app the user never opened.

It is not a labelling slip that a flag fixes. display notification
posts under the bundle running the script, and for /usr/bin/osascript
that bundle is Script Editor. with title "roost" sets the
notification's title line, not the app it comes from. Short of
shipping roost as an .app, a CLI cannot post a macOS notification
under its own name at all.

So it stops trying. roost is a program inside a terminal, and the
terminal is the thing the OS already trusts to raise a notification with
a name and an icon — so roost asks it to, with a bell and an OSC 9.
That is not a new mechanism: it is exactly what infra::pty already
re-emits for a pane's own notification, and exactly what SPEC-parity P2
names as the contract ("re-emitted to the host terminal so native
desktop notifications fire"). Same sequence, same sanitizer
(sanitize_for_host + HOST_NOTIFY_CAP), same rate limit. The emitter
simply stopped being a subprocess.

The fork was wrong a second way, which is probably how it got noticed.
On any host that honours OSC 9 — Ghostty, iTerm2, WezTerm, kitty — a
pane's notification already produced two banners: the relay,
correctly attributed, and the Script Editor one beside it.

What it costs: a host that ignores OSC 9 (Apple Terminal, Alacritty) no
longer gets a desktop banner, only the bell it already had. That is the
same deal every other CLI in that terminal offers, and the deal the spec
already described. What it gains, besides the name: notifications that
survive ssh, which osascript never did, and the deletion of ~15 ms of
CPU and ~10 MB per notification plus a thread to reap the child.

The bell deliberately leads and is its own byte rather than the
sequence's terminator: a terminal that does not implement OSC 9 swallows
the whole string, terminator included, so a body-terminating BEL alone
would have silenced the audible fallback on exactly the hosts that have
nothing else.

Gated by five tests replacing the two AppleScript-injection ones, which
tested an escaping problem that no longer exists. The body is still
attacker-controlled (an agent's own text, or a workspace.json title)
and this is still a raw write to the operator's real stdout, so the
escape it has to manage is now the OSC string's:
a_hostile_body_cannot_break_out_of_the_osc_sequence feeds it
safe\x07\x1b]0;pwned\x07\x1b[2Jtail and pins exactly two BELs and
exactly one ESC in the output.

SPEC-parity P2 gets a dated amendment, and it records what this change
deliberately does not do: for a pane's own OSC 9, the relay and
roost's own nudge now both put an OSC 9 on the same stream — one
verbatim, one display_name-prefixed and skipped for the focused pane.
They used to be different surfaces, so the overlap was invisible.
Collapsing them changes which body rides and whether the focused pane is
exempt, and that is a contract decision, not a bug fix.

Reported: every desktop notification roost raises on macOS is attributed
to an app the user never opened.

It is not a labelling slip that a flag fixes. `display notification`
posts under the bundle running the script, and for `/usr/bin/osascript`
that bundle is Script Editor. `with title "roost"` sets the
notification's *title line*, not the app it comes from. Short of
shipping roost as an `.app`, a CLI cannot post a macOS notification
under its own name at all.

So it stops trying. roost is a program inside a terminal, and the
terminal is the thing the OS already trusts to raise a notification with
a name and an icon — so roost asks it to, with a bell and an `OSC 9`.
That is not a new mechanism: it is exactly what `infra::pty` already
re-emits for a pane's own notification, and exactly what SPEC-parity P2
names as the contract ("re-emitted to the host terminal so native
desktop notifications fire"). Same sequence, same sanitizer
(`sanitize_for_host` + `HOST_NOTIFY_CAP`), same rate limit. The emitter
simply stopped being a subprocess.

The fork was wrong a second way, which is probably how it got noticed.
On any host that honours OSC 9 — Ghostty, iTerm2, WezTerm, kitty — a
pane's notification already produced **two** banners: the relay,
correctly attributed, and the Script Editor one beside it.

What it costs: a host that ignores OSC 9 (Apple Terminal, Alacritty) no
longer gets a desktop banner, only the bell it already had. That is the
same deal every other CLI in that terminal offers, and the deal the spec
already described. What it gains, besides the name: notifications that
survive ssh, which `osascript` never did, and the deletion of ~15 ms of
CPU and ~10 MB per notification plus a thread to reap the child.

The bell deliberately leads and is its own byte rather than the
sequence's terminator: a terminal that does not implement OSC 9 swallows
the whole string, terminator included, so a body-terminating BEL alone
would have silenced the audible fallback on exactly the hosts that have
nothing else.

Gated by five tests replacing the two AppleScript-injection ones, which
tested an escaping problem that no longer exists. The body is still
attacker-controlled (an agent's own text, or a `workspace.json` `title`)
and this is still a raw write to the operator's real stdout, so the
escape it has to manage is now the OSC string's:
`a_hostile_body_cannot_break_out_of_the_osc_sequence` feeds it
`safe\x07\x1b]0;pwned\x07\x1b[2Jtail` and pins exactly two BELs and
exactly one ESC in the output.

SPEC-parity P2 gets a dated amendment, and it records what this change
deliberately does **not** do: for a pane's own OSC 9, the relay and
roost's own nudge now both put an OSC 9 on the same stream — one
verbatim, one `display_name`-prefixed and skipped for the focused pane.
They used to be different surfaces, so the overlap was invisible.
Collapsing them changes which body rides and whether the focused pane is
exempt, and that is a contract decision, not a bug fix.
@navbytes
navbytes merged commit cdb3842 into main Aug 21, 2026
3 checks passed
@navbytes
navbytes deleted the fix/notify-via-host-terminal branch August 21, 2026 06:14
navbytes added a commit that referenced this pull request Aug 21, 2026
Follow-up to #152, which fixed the attribution and left this on the
table because it changes a contract rather than a bug.

A pane's OSC 9 had two emitters. `PtyPane::queue_host_notify` relayed
the body verbatim, unconditionally, per SPEC-parity P2(b).
`App::on_pty_output` raised roost's own, `{display_name}: {body}`,
skipped for the focused pane. They were different *surfaces* — one
reached the terminal, the other forked `osascript` — which is the only
reason nobody noticed they were two notifications for one event. #152
deleted the fork, and the overlap became two near-identical banners.

Measured on the real binary before this change, one `printf
'\e]9;NEEDS-YOU\a'` in an unfocused pane:

    \x1b]9;shell · tmp: NEEDS-YOU\x07\x1b]9;NEEDS-YOU\x07

The named one wins. With eight panes open, a banner reading "Claude
needs your permission to use Bash" that cannot say *which* pane is the
exact pain DESIGN.md's "not knowing who needs me" exists to solve — and
it is the only one of the two that stays quiet about the pane you are
already looking at. The relay is retired: `queue_host_notify` and
`host_notify_bytes` are deleted, along with the two unit tests that
pinned a builder nothing calls.

So P2's (b) is now delivered by (a): one OSC 9 per notification, body
`{display_name}: {body}`, suppressed for the focused pane, bounded and
C0-stripped by the same `sanitize_for_host` + `HOST_NOTIFY_CAP`,
rate-limited per pane by `last_desktop_notify` and process-wide by
`infra::notify`'s budget. Same measurement after:

    \x1b]9;shell · tmp: NEEDS-YOU\x07

`infra::notify` stops writing to stdout from wherever the event was
noticed and hands bytes to `App::notify_host` instead, so roost's own
notifications go out on the same `host_out` queue as P2's relays and
P6's title — placed between frames like everything else roost writes to
the host (W3), rather than mid-loop.

The e2e gate had to change, and how it changed is worth reading: it
drove a **single** pane, which is now the one case that deliberately
emits nothing. It now splits first and notifies the unfocused pane, and
asserts the body *ends with* the pane's own text and is *not equal* to
it — the shape, not the naming, which is U2/C4's contract and pinned
there.

It also stopped being able to send `Alt+n` right after `settle`.
`settle` proves roost *drew*; roost draws before its control socket is
up, and a keystroke landing in that window can still be eaten by the
startup keyboard probe (`KBD_PROBE_BUDGET`). The test now waits for the
control plane and polls for the split, which is a real "roost has
started" signal rather than a frame-shaped guess — worth knowing for
any harness test that types early.
navbytes added a commit that referenced this pull request Aug 21, 2026
Follow-up to #152, which fixed the attribution and left this on the
table because it changes a contract rather than a bug.

A pane's OSC 9 had two emitters. `PtyPane::queue_host_notify` relayed
the body verbatim, unconditionally, per SPEC-parity P2(b).
`App::on_pty_output` raised roost's own, `{display_name}: {body}`,
skipped for the focused pane. They were different *surfaces* — one
reached the terminal, the other forked `osascript` — which is the only
reason nobody noticed they were two notifications for one event. #152
deleted the fork, and the overlap became two near-identical banners.

Measured on the real binary before this change, one `printf
'\e]9;NEEDS-YOU\a'` in an unfocused pane:

    \x1b]9;shell · tmp: NEEDS-YOU\x07\x1b]9;NEEDS-YOU\x07

The named one wins. With eight panes open, a banner reading "Claude
needs your permission to use Bash" that cannot say *which* pane is the
exact pain DESIGN.md's "not knowing who needs me" exists to solve — and
it is the only one of the two that stays quiet about the pane you are
already looking at. The relay is retired: `queue_host_notify` and
`host_notify_bytes` are deleted, along with the two unit tests that
pinned a builder nothing calls.

So P2's (b) is now delivered by (a): one OSC 9 per notification, body
`{display_name}: {body}`, suppressed for the focused pane, bounded and
C0-stripped by the same `sanitize_for_host` + `HOST_NOTIFY_CAP`,
rate-limited per pane by `last_desktop_notify` and process-wide by
`infra::notify`'s budget. Same measurement after:

    \x1b]9;shell · tmp: NEEDS-YOU\x07

`infra::notify` stops writing to stdout from wherever the event was
noticed and hands bytes to `App::notify_host` instead, so roost's own
notifications go out on the same `host_out` queue as P2's relays and
P6's title — placed between frames like everything else roost writes to
the host (W3), rather than mid-loop.

The e2e gate had to change, and how it changed is worth reading: it
drove a **single** pane, which is now the one case that deliberately
emits nothing. It now splits first and notifies the unfocused pane, and
asserts the body *ends with* the pane's own text and is *not equal* to
it — the shape, not the naming, which is U2/C4's contract and pinned
there.

It also stopped being able to send `Alt+n` right after `settle`.
`settle` proves roost *drew*; roost draws before its control socket is
up, and a keystroke landing in that window can still be eaten by the
startup keyboard probe (`KBD_PROBE_BUDGET`). The test now waits for the
control plane and polls for the split, which is a real "roost has
started" signal rather than a frame-shaped guess — worth knowing for
any harness test that types early.
navbytes added a commit that referenced this pull request Aug 21, 2026
…e request (#155)

v0.1.10 shipped at 01:48 UTC today, before any of this session's work
landed, so everything below is unreleased. Touching
`.github/release-request` dispatches Release, which builds four targets,
creates the `v0.1.11` tag from Cargo.toml itself, and publishes with
SHA256SUMS.txt.

**Two silent data-loss bugs.**

- `Alt+w` on the last pane — whose own prompt calls it "quit roost" —
  removed the pane and *then* quit, so `shutdown` saved a tab whose
  layout named a pane its map no longer had. The repair minted a blank
  `shell` spec on next launch, and the session id, cwd, title and note
  of the pane you quit on were gone. `Alt+q` on the same pane kept all
  of them. (#147)
- A recycled pane id inherited the dead pane's session-detection clock.
  `last_shell_seen` and `pending_detect` were the only two
  `PaneId`-keyed maps `close_pane_id` did not prune, so a pane taking
  that id could scan hours back and resume a stranger's conversation,
  permanently. (#141)

**Idle cost down ~13x.** 2.75% CPU and 888 B/s of terminal traffic, to
0.2–0.4% and ~62 B/s, flat in the pane count at every step because it
was all fixed per-frame chrome: a `String` allocated per cell per frame
(#140); the quick-launch picker's `$PATH` probe running ~1,500 `stat(2)`
a second with no dialog on screen, plus the whole help table, every
frame (#144); an unconditional 30 fps repaint (#146); and 12 fps even
with nothing animating (#149).

**Desktop notifications changed channel, and this one is user-visible.**
They used to fork `osascript`, which macOS attributes to **Script
Editor** — there is no flag for that; a CLI cannot post under its own
name. roost now asks the terminal instead, with a bell and an `OSC 9`,
which is the mechanism SPEC-parity P2 already named. Ghostty, iTerm2,
WezTerm and kitty raise a real notification from it, attributed to
themselves, and it survives ssh. **A host that ignores OSC 9 (Apple
Terminal, Alacritty) now gets only the bell.** (#152)

And a pane's notification no longer arrives twice: there were two
emitters, invisible as a duplicate only while one of them was the
`osascript` fork. The named one won, so one banner, saying which pane,
and silence for the pane you are already looking at. (#153)

**Three new fuzzers** over input surfaces nothing reached before — the
mouse router, the key router, and paste — each walking a layout being
reshaped underneath it, drawing a real frame and re-checking the layout
fuzzer's own invariants after every step. The mouse one found #147 on
seed 15. (#147, #148)

**rustfmt and clippy, enforced in CI** (#150), tuned to the style the
tree already had rather than imposed: the config was picked by measuring
churn (556 hunks vs 1,632 for the naive setting). The lint set is in
`Cargo.toml`'s `[lints]` tables so it binds locally too, and three
candidate lints were measured and rejected. It found ten `unsafe` blocks
missing the `SAFETY:` comment every other one in the tree has.

Plus: the reformat kept out of `git blame` (#151), and three test gates
that blamed roost for the machine being out of ptys (#154).

Verified before cutting: 1,048 tests, clippy clean at `-D warnings`,
`fmt --check` clean. Both agent adapters checked end to end against live
binaries (`pi --session …`, `claude --resume …`), the session-resolution
table across all five adapters, the 45s status decays timed against the
clock, and two 15-minute soaks (~78k random keystrokes each, ~1,800
panes) with no orphan, no leak and no unreadable workspace.

Note for after the merge: `HOMEBREW_TAP_TOKEN` is still unset, so
release.yml will skip the tap sync again (it did for v0.1.10). The
formula needs the usual hand-sync.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant