Skip to content

Run hey watch: one live connection per shell, the Imbox read on every change - #3

Merged
robzolkos merged 10 commits into
masterfrom
hey-cli-engine
Aug 23, 2026
Merged

Run hey watch: one live connection per shell, the Imbox read on every change#3
robzolkos merged 10 commits into
masterfrom
hey-cli-engine

Conversation

@jeremy

@jeremy jeremy commented Aug 21, 2026

Copy link
Copy Markdown
Member

Built on the redesign from #2, which is now on master. Requires hey-cli ≥ 0.2.0 (basecamp/hey-cli#243 — merged 2026-08-22 as 0f189d7: the watch's new field and --events new, and its ready/disconnected/resync lines), so merging and publishing wait until 0.2.0 is on the AUR — users must never receive a plugin that needs a CLI they cannot install — it runs against a local make install build meanwhile (verified live on the merged CLI at 010efb3 (equivalent to main’s 0f189d7): connected watch, an external mail → one HEY toast, hey seennew:false).

Why

hey-cli shipped its own inline hey-unread bar module in hey setup omarchy the same week this plugin grew its panel — two HEY icons, two Imbox pollers, two product stances in one slot. We settled on: the CLI is the engine, the plugin is the face. An earlier cut of this PR gave the plugin a hey omarchy poll; that was ten minutes stale by construction and an Omarchy-named subcommand on a general CLI, so hey-cli#243 replaced it with its generic commands: hey box imbox is the read, hey watch is the wake-up, and every added/updated line the watch writes says whether the thread is new mail — the CLI decides that once, on HEY's clock; what to do about it (the toast) is the plugin's.

What changes

  • Service kind. manifest.json declares kinds: ["service", "bar-widget"] with Service.qml as the service entry point. The shell instantiates it once; every bar widget — one per monitor — reads bar.shell.serviceFor("37signals.hey") and pushes its settings into it (the shell injects settings into widgets only). So one hey watch runs per shell, and an IPC refresh is one call. A shell without service support gets a widget-local instance per bar, kept inert (active: false) once a shared one exists.
  • hey watch is the wake-up. Run as setpriv --pdeathsig TERM hey --account all watch --events added,updated,deleted,new,resync so it dies with the shell, covers every linked account whatever hey accounts use persisted, and asks for every event by name — a CLI without the new event refuses the command up front (unknown event), which reads as an old CLI, rather than running a watch that never says which threads are new. The probe also reads hey --version ahead of hey auth status, so a release below 0.2.0 is reported before any watch starts. Any change line on its stdout re-reads the Imbox with hey box imbox --limit N --json (plus --account all as before), debounced (300 ms) so a burst costs one read — plus one follow-up when changes land while a read is in flight, since that read may predate them. The watch says ready once its cursors are set and it is listening (again after every reconnect's catch-up), and the read on that line is what makes the picture gap-free: anything before the cursor is in the read, anything after it is an event. It catches up from its own cursor after a disconnect, so a laptop back from suspend is current within seconds. The ten-minute timer stays as a safety net.
  • Live state and exit handling. connected follows the watch's own ready / disconnected lines (a reconnect backoff inside hey watch is not live); watching says the process is alive. The logo tooltip says · live while connected; status over IPC reports watching, connected and watchError. On exit only the last stderr line is kept (the CLI's envelope). authauthenticated = false, no restart until the next probe succeeds. unknown command/unknown flag → "HEY CLI 0.2.0 or newer is required". Anything else → restart on a backoff from 2 s doubling to 60 s (reset after a run longer than a minute), the panel header saying "Live updates paused — …" meanwhile.
  • Notifications are the plugin's. While notify is on, a watch line with new: true whose box is the Imbox (the watch follows every box; only Imbox mail asks for attention) is collected, debounced 1.5 s so one read's burst is one toast, and sent as one omarchy-notification-send --glyph --app-name HEY -u low --exec "omarchy-launch-or-focus-tui --app-id=org.omarchy.hey hey tui" <headline> [description] -p [-r <id>]Sender — Subject for one thread, N new in Imbox with the first senders for more; --app-name HEY is what lets SUPER+CTRL+comma silence it, --exec runs daemon-side so it survives shell restarts, and the printed id is trusted for ten minutes for -r; a later burst replaces only while the prior popup is still tracked, while the 1.5-second debounce prevents same-burst stacking. Flipping notify no longer restarts the watch; it only gates the toast path (off drops what was about to go out). Model.js carries the text/argv logic (composeMailToast, notificationText, toastCommand, replaceableToastId) under node tests. The switch in the header, hey setup omarchy --notify and omarchy bar set 37signals.hey notify true --json all write the same key. No state file.
  • Kept from the earlier cut: Screener count from hey screener list --count --json (now a bare number from the CLI's global --count; the older envelope still parses), errors read from the CLI's stderr envelope, the coalesced refresh (its follow-up now deferred a tick — the busy binding loop is gone), the flocked setup flow, the tests harness. A successful hey seen no longer schedules its own delayed re-read while the watch is connected — the cable reports it back; a failed mutation still re-reads.
  • Tests: SplitParser stub, Process stub with emitLine and the two-arg exited (and an exit on running = false, as a real Process reports); tst_service.qml covers the watch argv, ready making it live and reading, disconnected turning live off without a read, a burst as one debounced read, events mid-read as one follow-up, auth exit waiting for the probe, other exits on a doubling backoff, the old CLI, a new Imbox line as one toast with the exact argv, two lines in the window as one toast, the next burst replacing via -r, The Feed's new mail / a new:false line / notify off not toasting, the notify flip leaving the watch alone, the bare screener count, and the inactive local instance. ./tests/run (46 node + 57 QML, no QML warnings) and omarchy plugin validate green.

Open for your call

  • Events carry the full posting; applying them as deltas instead of re-reading per batch remains a follow-up. The shipped resync event keeps the current full re-read path correct after a 409. Recorded in hey-cli's docs/omarchy.md.
  • The panel shows an unread number per account; hey-cli's docs record that as the plugin's call (the bar glyph and the toast stay count-free).

jeremy added 2 commits August 21, 2026 00:15
…toasts

hey-cli 0.2.0 grows `hey omarchy poll --limit N [--notify] --json`, which
answers in the shape of `hey box imbox --json` and, with --notify, toasts
new Imbox mail itself -- one toast per poll at most, replaced rather than
stacked, identified as HEY so SUPER+CTRL+comma silences it. The plugin
passes --notify when its bar entry says `notify: true`; a switch in the
panel header (and N) flips that key through updateEntryInline, the same key
`hey setup omarchy --notify` and `omarchy bar set 37signals.hey notify true`
write. The manifest declares it, default off.

The Screener count comes from `hey screener list --count --json` instead of
a token pulled out of the CLI and handed to curl; the plugin never sees a
token now. Failures are read from stderr, where the CLI writes its error
envelope, and `auth` (the CLI's actual code) takes the sign-in branch. A
CLI too old for `omarchy poll` is named in the header.

`hey tui` nudges the plugin over IPC after it archives or marks a thread.
The refresh handler fans that out to every per-monitor instance, the way
the shell's BarWidget.broadcast does, and a refresh that arrives mid-fetch
is coalesced into one follow-up rather than dropped.

Tests: PR #1's harness (node --test over Model.js, qmltestrunner with Process
stubs) ported as files, plus the AUR spellings of the setup plan; the probe
stderr test stays with #1's Service.qml change. Requires hey-cli >= 0.2.0.
A build that already has the hidden omarchy group but not poll trips over
--limit before it gets to report an unknown command; seen live.
@jeremy

jeremy commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

@jzimdars @robzolkos — heads-up before this draft surprises you: #3 layers hey-cli’s new hey omarchy poll engine onto plugin-design, so one CLI call supplies the panel/icon data and handles new-mail toasts; it also replaces the Screener token+curl path with hey screener list --count. The plugin side is tested (11 Node + 15 QML, plus omarchy plugin validate) and remains intentionally draft.

Two things to flag up front:

  • Omarchy still constructs one widget/service per monitor. This draft broadcasts/coalesces IPC refreshes and hey-cli flocks notification state to prevent duplicate toasts, but it does not elect a single polling owner. That architectural choice is left for your call.
  • The companion hey-cli PR #236 has two final state/lock safety fixes to address before merge/release: reject an empty or relative state directory, and never unlink the lock sidecar (the plugin entry survives hey setup omarchy --remove and keeps polling). The 0.2.0 release remains gated on this repo becoming public and Run hey watch: one live connection per shell, the Imbox read on every change #3 being merge-approved.

No immediate action requested; I wanted you to have the context before encountering the draft cold.

@jeremy

jeremy commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

@jzimdars @robzolkos — correction to my earlier heads-up: the polling architecture described there is being superseded. The proposed direction is now a generic hey watch wake-up/notification engine plus a singleton Omarchy service, with the panel continuing to re-read hey box imbox; #236 will not be the release path if that review converges. #3 is still draft and mid-rework, so please hold review until the watch design is corrected and the draft is updated. No action needed yet.

… change

The plugin polled `hey omarchy poll` every ten minutes, so the bar could show a thread
as unread ten minutes after it was read elsewhere, and a toast could land ten minutes
after the mail. hey-cli's answer is `hey watch` (basecamp/hey-cli, branch watch-notify):
it follows every box over HEY's cable, catches up from its cursor after a disconnect and
prints a line per change. The plugin treats any line as a wake-up and re-reads the Imbox
with `hey box imbox`, coalesced so a burst of changes costs one read, and starts the watch
before the first read so nothing falls between the two. The ten-minute timer stays as a
safety net.

The plugin is now a service as well as a bar widget: the shell instantiates Service.qml
once and every bar — one per monitor — reads that instance, so one `hey watch` runs per
shell; panels push their settings into it, and an IPC refresh is one call. A shell
without service support gets a widget-local instance per bar, kept inert once a shared
one exists. A watch that exits signed out waits for the next probe; any other exit is
restarted on a backoff from two seconds to a minute, and the panel header says so in
the meantime. Flipping `notify` restarts the watch with or without `--notify`, which
now sends the toasts itself, one per batch of changes, with no state file.
@jeremy jeremy changed the title Run hey omarchy poll: one Imbox read for the panel, the icon and the toasts Run hey watch: one live connection per shell, the Imbox read on every change Aug 21, 2026
…p, a bare screener count

`connected` meant the watch process was alive, which says nothing about the cable: a
reconnect backoff inside `hey watch` looked live. The watch now reports itself —
"ready" once it is listening and again after each catch-up, "disconnected" when the
cable drops — so `connected` follows those lines and `watching` is the process. The
read on "ready" is also what makes the startup picture gap-free, rather than the order
the two processes were started in.

A burst of watch lines cost two reads: the first line's and the follow-up the rest
queued. Lines are debounced now, so a burst is one read, plus one follow-up only when
changes land while a read is in flight, since that read may predate them.

The follow-up refresh started inside busy's own change handler and flipped the
processes busy is made of while busy was still being notified — a binding loop QML
warned about on every coalesced refresh since the coalescing landed. It starts on the
next turn of the event loop instead. The test harness lets the refresh timer's start
trigger fire first, as it does in the shell, instead of racing it.

`hey screener list --count --json` answers a bare number since the CLI's global --count
took over from the command's own flag; the parser takes that alongside the older
envelope.
@jeremy

jeremy commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

@jzimdars @robzolkos — the hold is lifted; this is ready for your review (still a draft only for the release gate — it needs the hey-cli with hey watch --notify, basecamp/hey-cli#243, on the AUR).

What you are looking at, in one breath: the plugin is now a service as well as a bar widget, so one hey watch runs per shell and every bar reads it; a watch line is a wake-up that re-reads hey box imbox (debounced); connected follows the watch's own ready/disconnected lines; the CLI sends the toasts (hey watch --notify, Imbox only, DND-aware, no state file); nothing Omarchy-specific lives in the CLI any more. The description above is current, ./tests/run and omarchy plugin validate are green, and it runs on my desktop against a local build. The two calls I'd most like yours on are under "Open for your call".

@robzolkos

Copy link
Copy Markdown
Collaborator

@jeremy this is a great add! Agree with deltas and unread counts per account.

@jeremy
jeremy marked this pull request as ready for review August 21, 2026 19:47
Copilot AI balanced review requested due to automatic review settings August 21, 2026 19:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces live HEY Imbox updates through a shell-wide service backed by hey watch.

Changes:

  • Adds debounced live refreshes, reconnect handling, notifications, and watch backoff.
  • Shares one service across monitor widgets and exposes live status over IPC.
  • Adds Node/QML coverage and updates documentation and requirements.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
Service.qml Implements shared watch and refresh lifecycle.
Panel.qml Connects widgets to the shared service and adds notification controls.
Model.js Adds CLI command and response helpers.
manifest.json Registers the service and notification setting.
README.md Documents live updates, notifications, and CLI requirements.
tests/run Runs Node and QML test suites.
tests/model.test.js Tests model helpers and CLI commands.
tests/setup-command.test.js Tests setup locking and recovery.
tests/qml/tst_service.qml Tests service refresh and watch behavior.
tests/qml/imports/Quickshell/Quickshell.qml Stubs Quickshell environment access.
tests/qml/imports/Quickshell/qmldir Registers the Quickshell test module.
tests/qml/imports/Quickshell/Io/Process.qml Stubs process execution and exits.
tests/qml/imports/Quickshell/Io/ProcessRegistry.qml Tracks test process instances.
tests/qml/imports/Quickshell/Io/SplitParser.qml Stubs streaming line parsing.
tests/qml/imports/Quickshell/Io/StdioCollector.qml Stubs collected process output.
tests/qml/imports/Quickshell/Io/qmldir Registers Quickshell I/O test types.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Model.js Outdated
Comment thread Service.qml

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6affc33793

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Service.qml
Comment thread Service.qml
Comment thread Service.qml Outdated
jeremy added a commit to basecamp/hey-cli that referenced this pull request Aug 21, 2026
Stanko asked on #243 whether --notify could just be --run-async. It couldn't,
and the reason it couldn't is the tell: the flag bundled two things. What
counts as new mail — unseen, not muted, active_at later than the watch's
record of the thread or than its start on HEY's clock, remembered across every
box so a move-in isn't new — is HEY's semantics and state across events, and
belongs in the CLI, decided once and Go-tested. notify-send, the app-name,
-p/-r, the Omarchy glyph and exec hints, `Sender — Subject`, one toast per
read and the Imbox-only scope are presentation, a desktop's business, and
belonged in the plugin that already owns rendering and settings.

So the CLI grows a generic, scriptable notion of new mail and loses the
desktop: every added and updated line carries "new": true|false, --events new
selects the true ones (a union with added, updated and deleted), a --run-*
script sees HEY_NEW=1, and `hey watch --box imbox --events new
--exit-on-first` is "block until new mail". The tracker always runs — one
cheap serverNow GET at startup, one map — so the line shape is stable, and a
read is classified before it is recorded, as before. --notify, --notify-box,
desktopNotifier and notifiedBoxes are gone; neither flag was released, so
.surface-breaking needs no entry. The Omarchy plugin toasts from the new
lines itself (basecamp/omarchy-hey-plugin#3).

Docs follow: docs/omarchy.md's "hey watch --notify" becomes "New mail is a
watch event" and "The plugin toasts", the decision record tightens to
"Generic commands; the face presents", README and the skill lead with
--events new, and `hey setup omarchy --notify` says it turns on the plugin's
toasts rather than running hey watch --notify.
…notify

hey-cli 881aa9e made new mail a watch event: every added and updated line
says "new": true|false, decided once by the CLI on HEY's clock, and the
flag that sent the toast itself is gone. The toast is presentation, so it
moves here, where the rendering and the notify setting already are.

Service.qml collects the new lines whose box is the Imbox while notify is
on, debounces them for 1.5 s so one read's burst is one toast, and sends
one omarchy-notification-send — --app-name HEY so SUPER+CTRL+comma
silences it, the bar's envelope as the glyph, --exec focusing the TUI
daemon-side, -p to learn the daemon's id and -r to replace the last toast
while that id is recent enough to trust. Flipping notify no longer
restarts the watch; it only gates the toast path, and off drops what was
about to go out. Model.js carries the text and argv logic the Go used to
(composeMailToast, notificationText, toastCommand, replaceableToastId)
under node tests; tst_service.qml drives the lines end to end: a new Imbox
line is one toast, two in the window are one, the next burst replaces,
The Feed's new mail and a new:false line do not toast, notify off does not.
@jeremy

jeremy commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

@jzimdars @robzolkos heads-up from 951118e: the toasts are now the plugin's. hey-cli's --notify is gone (basecamp/hey-cli@881aa9e — Stanko's review) and in its place every hey watch line says "new": true|false, decided once by the CLI; Service.qml collects the Imbox's new lines for 1.5 s and sends one omarchy-notification-send --app-name HEY … -p [-r <id>], with the text/argv in Model.js under node tests. Flipping notify no longer restarts the watch. So the app-name, glyph, click-to-focus and replace-not-stack are yours to restyle now — see the Notifications section of the README.

jeremy added 2 commits August 21, 2026 14:44
…re-read after a failed seen

Five review threads on #3. The probe now prints `hey version`'s line ahead of
the auth status and parseProbe splits them: a release below 0.2.0 — which has
a `hey watch`, but one that says neither ready nor which threads are new —
sets cliOutdated, so no watch runs and the header says to upgrade, while the
Imbox is still read on the timer. A dev build or an unreadable version is not
held against the CLI.

A stop the service asked for still reports an exit; _watchStopping marks it
so watchExited neither records a "live updates stopped" error nor schedules a
restart. Authentication lost during the accounts or Imbox read now goes
through signedOut, which stops the watch as the probe's path always did.

A failed `hey seen` schedules the delayed re-read even while live, since the
panel marked the thread seen optimistically and the cable has nothing to
report; its error is parsed from the envelope like every other handler's.
The handler had been calling root.actionStatusTimer and root.refreshAfterRead
— ids, not properties — so it threw before advancing the queue: the status
never cleared and a second mark never ran. Both are reached by id now, and a
test marks two threads in a row.
Sol's review: plain `hey watch` honoured a persisted `hey accounts use`
filter, hiding another account's changes from the panel, and a CLI with a
watch but no `new` field ran and looked connected while toasts silently
never fired. The watch now runs as `hey --account all watch --events
added,updated,deleted,new,resync` — every account, and every event by name,
`resync` included now that hey-cli makes it selectable — and a CLI that does
not know `new` refuses the command up front, which cliTooOld recognizes
alongside an unknown command or flag.
jeremy added a commit to basecamp/hey-cli that referenced this pull request Aug 22, 2026
Stanko asked on #243 whether --notify could just be --run-async. It couldn't,
and the reason it couldn't is the tell: the flag bundled two things. What
counts as new mail — unseen, not muted, active_at later than the watch's
record of the thread or than its start on HEY's clock, remembered across every
box so a move-in isn't new — is HEY's semantics and state across events, and
belongs in the CLI, decided once and Go-tested. notify-send, the app-name,
-p/-r, the Omarchy glyph and exec hints, `Sender — Subject`, one toast per
read and the Imbox-only scope are presentation, a desktop's business, and
belonged in the plugin that already owns rendering and settings.

So the CLI grows a generic, scriptable notion of new mail and loses the
desktop: every added and updated line carries "new": true|false, --events new
selects the true ones (a union with added, updated and deleted), a --run-*
script sees HEY_NEW=1, and `hey watch --box imbox --events new
--exit-on-first` is "block until new mail". The tracker always runs — one
cheap serverNow GET at startup, one map — so the line shape is stable, and a
read is classified before it is recorded, as before. --notify, --notify-box,
desktopNotifier and notifiedBoxes are gone; neither flag was released, so
.surface-breaking needs no entry. The Omarchy plugin toasts from the new
lines itself (basecamp/omarchy-hey-plugin#3).

Docs follow: docs/omarchy.md's "hey watch --notify" becomes "New mail is a
watch event" and "The plugin toasts", the decision record tightens to
"Generic commands; the face presents", README and the skill lead with
--events new, and `hey setup omarchy --notify` says it turns on the plugin's
toasts rather than running hey watch --notify.
jeremy added a commit to basecamp/hey-cli that referenced this pull request Aug 22, 2026
Stanko asked on #243 whether --notify could just be --run-async. It couldn't,
and the reason it couldn't is the tell: the flag bundled two things. What
counts as new mail — unseen, not muted, active_at later than the watch's
record of the thread or than its start on HEY's clock, remembered across every
box so a move-in isn't new — is HEY's semantics and state across events, and
belongs in the CLI, decided once and Go-tested. notify-send, the app-name,
-p/-r, the Omarchy glyph and exec hints, `Sender — Subject`, one toast per
read and the Imbox-only scope are presentation, a desktop's business, and
belonged in the plugin that already owns rendering and settings.

So the CLI grows a generic, scriptable notion of new mail and loses the
desktop: every added and updated line carries "new": true|false, --events new
selects the true ones (a union with added, updated and deleted), a --run-*
script sees HEY_NEW=1, and `hey watch --box imbox --events new
--exit-on-first` is "block until new mail". The tracker always runs — one
cheap serverNow GET at startup, one map — so the line shape is stable, and a
read is classified before it is recorded, as before. --notify, --notify-box,
desktopNotifier and notifiedBoxes are gone; neither flag was released, so
.surface-breaking needs no entry. The Omarchy plugin toasts from the new
lines itself (basecamp/omarchy-hey-plugin#3).

Docs follow: docs/omarchy.md's "hey watch --notify" becomes "New mail is a
watch event" and "The plugin toasts", the decision record tightens to
"Generic commands; the face presents", README and the skill lead with
--events new, and `hey setup omarchy --notify` says it turns on the plugin's
toasts rather than running hey watch --notify.
…a pipe

Found on the first live run of ea0cdab: piped, `hey version` prints the JSON
envelope, so the probe's first line was `{` and nothing after it parsed —
the panel said it could not check the CLI. `hey --version` is the one-line
form.
jeremy added a commit to basecamp/hey-cli that referenced this pull request Aug 22, 2026
… of hey omarchy (#243)

* hey watch --notify: the engine under the Omarchy bar plugin, in place of hey omarchy

The 37signals.hey bar plugin needed two things from hey-cli: the Imbox, and to know
when it changed. `hey omarchy bar-status` and its planned successor `hey omarchy poll`
answered both with one ten-minute poll that also diffed a fingerprint file to toast new
mail — a toast could land ten minutes after the mail, the TUI had to nudge the plugin
over IPC after its own mutations, and an Omarchy-named command that sounded watch-like
sat on the CLI surface.

`hey box imbox --json` is the read and `hey watch` is the wake-up: it catches up from
its cursor on reconnect and skips ahead on a 409, so the bar is live and self-healing,
and the cable reports the TUI's own mutations back within a second. `hey watch --notify`
is the notifier — a generic flag on a generic command: notify-send under the app-name
HEY so notification silencing applies, at most one toast per batch of changes, replaced
rather than stacked, with Omarchy's glyph and focus-the-TUI hints when Omarchy is on
PATH. What counts as new is an unseen, unmuted posting that was added, or updated with
an active_at later than the watch last recorded (or than its start) — active_at moves on
new mail only, so seen flips, mutes and moves never toast. No state file: a box's first
read is its catch-up from the server's cursor (the box's last activity, not this moment)
and is recorded but never toasted, and what the notifier remembers lives and dies with
the watch.

The cable now authorizes every dial with the credentials of the moment. actioncable-go
reuses the header it was given on each reconnect, so a watch that outlived its access
token came back with a stale one and exited with an auth error; a Transport wrapper
refreshes the Authorization or Cookie header from the auth manager per dial, which the
TUI's live updates get too.

The `hey omarchy` group goes, never released: bar-status, the poll state and its lock,
the config-degraded signalling only the poll reported, the account-scope and local-config
exemptions, and the smoke state dir's reason. `hey setup omarchy` keeps configuring the
plugin's notify key and removing the legacy hey-unread module; turning toasts on is now
just setting the key.

* Address review: notify the Imbox by default, remember every thread, say ready/disconnected/resync

The notifier measured a thread's activity against its start only when it had no
record, and it recorded only what --events let through and only after a box's first
read, which it dropped whole. Three holes: mail that arrived while the watch was
starting up was thrown away with the backlog; with --events updated an arrival was
never recorded, so its later seen flip read as new; and a first read that ended in a
409 left the next arrival to be dropped as backlog. Now every posting the watch reads
is recorded, in every box and whatever --events reports, and a toast needs activity
later than the record — or later than the watch's start for a thread without one,
which is what keeps a first read's backlog quiet without touching what arrived since.

--notify covered every watched box, so the plugin, which watches all of them to see a
thread leave the Imbox, would have toasted The Feed and Paper Trail too while the docs
promised the Imbox. It toasts the Imbox unless --notify-box names another watched box;
a name that isn't watched is a usage error, since that toast would never come.

The watch now says so itself when its state changes: "ready" once the cursors are set
and the subscription is live, and again after each reconnect's catch-up, so a reader
that reads its own state on that line has no gap to reason about; "disconnected" when
the cable drops; "resync" when a box changed more than the feed can list and the watch
skipped ahead, which used to reach stderr only and left a watcher stale. A resync is a
change — scripts run for it and --exit-on-first counts it; ready and disconnected go to
stdout alone, since a script runs per change and neither is one. Their box is omitted,
so the event's box is a pointer now and the environment leaves the box variables out.

Rebased onto main, which meanwhile refreshed the cable credentials per dial itself
(WithHeaderFunc); the transport wrapper here is gone in its favour.

* Address review: order the connection news, cut over on HEY's clock, leave the plugin's notify alone

A drop and the reconnect that followed it reached the loop through two channels, so a
reconnect that completed while a slow catch-up held the loop could be announced ready
and then, from the other channel, disconnected — a reader following the watch's own news
would sit offline until the next reconnect. The cable's callbacks now queue every
transition in order behind one wake-up, and the loop drains the queue in that order: a
drop is announced, a reconnect catches up and is announced ready.

The cutoff between backlog and new mail was the workstation's clock, compared with the
server's active_at: a clock behind the server toasts backlog, a clock ahead sits on new
mail. The watch now reads HEY's own clock off the Date header of one cheap, uncached
request before the cursors are taken, and falls back to the local clock only when the
server's can't be read.

hey setup omarchy --remove deleted the bar plugin's notify key. The key is the plugin's:
the panel and `omarchy bar set` write it as readily as setup does, so removal cannot
tell a preference it wrote from one it didn't. --remove leaves it and says so;
--no-notify is the explicit off switch.

* New mail is a watch event; the toast moves to the face

Stanko asked on #243 whether --notify could just be --run-async. It couldn't,
and the reason it couldn't is the tell: the flag bundled two things. What
counts as new mail — unseen, not muted, active_at later than the watch's
record of the thread or than its start on HEY's clock, remembered across every
box so a move-in isn't new — is HEY's semantics and state across events, and
belongs in the CLI, decided once and Go-tested. notify-send, the app-name,
-p/-r, the Omarchy glyph and exec hints, `Sender — Subject`, one toast per
read and the Imbox-only scope are presentation, a desktop's business, and
belonged in the plugin that already owns rendering and settings.

So the CLI grows a generic, scriptable notion of new mail and loses the
desktop: every added and updated line carries "new": true|false, --events new
selects the true ones (a union with added, updated and deleted), a --run-*
script sees HEY_NEW=1, and `hey watch --box imbox --events new
--exit-on-first` is "block until new mail". The tracker always runs — one
cheap serverNow GET at startup, one map — so the line shape is stable, and a
read is classified before it is recorded, as before. --notify, --notify-box,
desktopNotifier and notifiedBoxes are gone; neither flag was released, so
.surface-breaking needs no entry. The Omarchy plugin toasts from the new
lines itself (basecamp/omarchy-hey-plugin#3).

Docs follow: docs/omarchy.md's "hey watch --notify" becomes "New mail is a
watch event" and "The plugin toasts", the decision record tightens to
"Generic commands; the face presents", README and the skill lead with
--events new, and `hey setup omarchy --notify` says it turns on the plugin's
toasts rather than running hey watch --notify.

* Ready waits for a failed catch-up read; a skip-ahead moves the box's new-mail cutoff

Two Codex threads on #243. A catch-up read that failed transiently left the
box queued for a retry and returned nil, so ready was announced with a box
still behind it — a reader then took the gap for a clean picture. catchUp now
announces ready only once every box is read (readyOnceCaughtUp); a retry that
reads the last box owed announces it (retryUnread), and a drop in between
cancels the debt, since the reconnect's catch-up announces its own.

A 409 skipped a box ahead without the new-mail record learning anything about
the gap, so a thread that arrived in it and was later moved or labelled while
still unseen measured its gap activity against the watch's start and read as
new. skippedTo moves that box's cutoff up to the cursor it skipped to — HEY's
last_posting_activity_at for the box, which bounds every thread in it — and
the cutoff is the box's alone, so another box's mail is unaffected. A gap
thread moved to another box may still read as new once; the resync line is
the reader's cue to re-read either way.

* A doorbell read pays the ready a catch-up owed

The box a catch-up left behind may be the one that rings next, and its read
clears it: announce the owed ready there too, rather than waiting for the
retry that may be two minutes off. TestWatchDoorbellReadPaysTheReadyACatchUpOwed.

* Ready yields to a queued drop; a script inherits no other event's variables

Two Codex threads on a5183a5. A drop queued while a catch-up was reading —
noteConnection appended it, listen hadn't drained it — let ready go out with
the subscription already down; readyOnceCaughtUp now looks at the queue and
withholds it, so the drain says disconnected and the reconnect's catch-up
says its own ready. And scriptCommand built its environment on os.Environ,
so a watch started by another watch's script handed HEY_NEW=1 or a thread
id to events that never set them; the event's variables are stripped from
the inherited environment first.

* Resync is an event of its own; a skip-ahead floors the box for known threads too; HEY_NEW=0

Sol's advisory on #243. The floor a 409 skip-ahead sets (newMail.skippedTo)
now applies to every thread in that box, known or not: a reply the watch
missed inside the gap and then a move while still unseen measured its gap
activity against an older record and read as new. Activity at or before the
floor — the cursor the box skipped to, its last posting activity — is never
new there; mail after it is, on a known thread or a new one.

resync joins the watchable events. It is reported by default, scripts run for
it and --exit-on-first counts it, and --events can leave it out — --events
new does, so `--events new --exit-on-first` never exits on a 409 and a
notify-send one-liner never toasts on one. The plugin asks for it by name.

Scripts see HEY_NEW=0 as well as HEY_NEW=1, on top of the inherited-variable
strip, so a test for new mail never reads a parent's value.

* Cursors start no later than the watch did; transitions one at a time; no ready on the way out

Three Codex threads on 83faa7d. The server bakes a box's last posting activity
into its cursor, so mail that landed between the watch reading HEY's clock and
reading the box list sat behind the cursor and was never read: noLaterThan
moves such a cursor back to the start, and the catch-up reads it as new.
followConnection took the whole transition queue at once, so a drop queued
behind a reconnect was invisible to dropQueued while the reconnect caught up;
it now takes one transition at a time. And readyOnceCaughtUp no longer pays
a ready to a watch that --exit-on-first has already finished or an interrupt
has cut short.

* The watch's start is HEY's clock when it asked, not when HEY answered

Sol's finding. serverNow returned the Date header as it stood when the
server answered, so mail that landed while the request was in flight — its
updated_at no later than Date — made a box cursor no later than the start,
which noLaterThan left where it was, and the mail sat behind the initial
cursor for good. An SDK retry could widen that window by seconds. The Date is
now translated back to the request's start by the monotonic elapsed time, so
a slow or retried request only moves the start earlier; the fallback is the
local clock at the start for the same reason. Two tests: a delayed response
puts the start at least the delay before Date, and the whole window end to
end — a posting landing during the clock request, the box's cursor moved back
to the start, the catch-up reading it, new: true.

* The watch's start is a whole millisecond, strictly before the instant

Codex on eabf979: started was nanosecond-precise while every cursor and
active_at is milliseconds, the feed answers what is strictly later than its
cursor, and isNew asks for strictly later than the start — so mail in the
start's own millisecond was neither read nor new. cutoffBefore truncates the
start to the millisecond and steps one back, on both the server's clock and
the local fallback.

* The local-clock fallback test allows for the cutoff's millisecond step

* Follow every box and report the --box ones; record a posting as soon as it is classified

Two Codex threads on b1e1dba. --box used to pick the boxes the watch read, so
activity in the others was never recorded and an unseen reply in The Feed
followed by a move into the Imbox read as new under the documented
`--box imbox --events new`. Every box is followed now and --box picks the
boxes whose changes are reported; a --box that names nothing is still not
found. And a read's postings were classified against the pre-read record and
recorded afterwards as a batch, so a posting carried twice by one read could
be new twice; classify records each posting as soon as it is decided.

* Ready is decided and announced under the transition lock; the watch stops with its last reported box

Two Codex threads on c2bb571. A drop could be queued between readyOnceCaughtUp
looking at the queue and announcing: the look and the announcement are now one
critical section with noteConnection's queueing, so a drop lands before the
look and withholds the ready, or after the ready is out, in the order things
happened. And since every box is followed but only the --box ones reported,
stopWatching gives up when the last reported box is gone rather than when the
last box is: a watch following the rest for the record alone would sit there
for good, reporting nothing.

* A resync is reported only when the box was actually skipped ahead

Codex on 8c68f04: a 409 on a box the server no longer lists drops the box,
and when another reported box remains that path returned nil — so readBox
still reported a resync for a box that is gone, inviting a re-read of
nothing and ending --exit-on-first on it. skipAhead now says whether it
moved the cursor, and a resync is reported only when it did.
Live: three HEY toasts a minute apart got three ids — each earlier popup had
expired, and a replaces_id the daemon no longer tracks is a new notification
by the freedesktop rules; probing the daemon directly, -r replaced a live
popup in place under the same id. So replacement covers a burst that lands
while the previous toast is still up; the ten minutes is only how long the
id is trusted.
@jeremy

jeremy commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

Live on the merged CLI (hey-cli 0f189d7/010efb3 installed, plugin e3de553): service watching: true, connected: true, 4 accounts; an external (GitHub notification) email → one HEY toast 2 s after the watch line (history id 1 after the restart), hey seennew:false, hey unseennew:false; a two-mail burst → one 2 new in Imbox toast. On replacement: three toasts a minute apart got fresh ids because each earlier popup had already expired — the daemon honours replaces_id only for a notification it still tracks (probed directly: -p → id 3, -r 3 replaced it in place under id 3). So -r covers a burst landing while the previous toast is still up; the burst debounce is what stops stacking otherwise. README now says exactly that. DND (SUPER+CTRL+comma) verified by Sol: app-name HEY does not bypass it.

@jeremy
jeremy requested review from jzimdars and robzolkos August 22, 2026 09:11

@robzolkos robzolkos left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this end to end with the latest HEY CLI and the PR installed in Omarchy. All 23 Node tests and 42 QML tests pass, plugin validation is clean, and the CLI tests pass.

Live testing also worked as intended: one connected hey watch process, no warnings or errors, and marking all New for you threads seen on my phone updated the panel from 5 to 0 without a manual refresh.

Looks great — approved.

@robzolkos
robzolkos changed the base branch from plugin-design to master August 23, 2026 19:36
@robzolkos
robzolkos merged commit 2c48980 into master Aug 23, 2026
3 checks passed
@robzolkos
robzolkos deleted the hey-cli-engine branch August 23, 2026 20:26
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.

3 participants