Skip to content

feat: many daemons in web and desktop, and notifications across them - #385

Merged
futrime merged 28 commits into
mainfrom
feat/multi-daemon-notifications
Oct 13, 2026
Merged

futrime merged 28 commits into
mainfrom
feat/multi-daemon-notifications

Conversation

@futrime

@futrime futrime commented Oct 12, 2026

Copy link
Copy Markdown
Member

Lands the 12 pull requests of the multi-daemon and notifications batch together, as #367 did. Each one was green alone; together they need the fixes in the last commit.

Multiple daemons

The page shows epics from Local and from any number of SSH daemons. It shows one at a time, and the daemon is part of the address (#/@<name>/…).

  • Daemon bar: the bottom-left of the sidebar names the current daemon, its host, its state and its live count. Clicking it opens a picker of every daemon, with Connect and Manage plus the machine's own items.
  • Settings › Daemons: add a daemon a part at a time, Import hosts from ~/.ssh/config, edit, remove or connect one. The list is kept under daemons in ~/.hmz/settings.yaml.
  • Connecting: Local runs hmz web --no-open --attached on the host over ssh, forwards a port to it, and proxies /d/<name>/api/… to it, signing in with the remote key.
    • If hmz there is missing or a different version, it is installed with uv tool install hmz==<this version>, installing uv first if needed.
    • It is never replaced while that host's daemon holds running epics.
  • Settings is split in two:
    • App holds Themes, Daemons and Notifications.
    • Daemon holds General, Accounts, Fallback, Runtimes, Backup, Workspace and Usage. A selector picks which daemon these pages read and write.
    • Batch apply copies a page to several daemons and reports per daemon. Secrets are never copied.

Notifications

  • Every hmz web serves notices for its machine: an epic done, failed or stopped, a question for a person, a chat waiting on its next turn. They come from polling host status every 2 s; it never attaches to a host.
  • Local gathers its own and every connected daemon's notices into one global feed, /api/notifications, filtered by kind as set in Settings › Notifications.
  • The page shows a bell with an unread count, a menu, and toast cards. When the page is hidden it also sends system notifications: through the browser's Notification API, or natively in the macOS app via its new notify command. Clicking a native notification focuses the window and opens the epic.

Fixes on top of the units

  • The daemon bar and the Daemons page now match the daemon registry API's shapes ({daemons: [...]}, and {name, fields, replace} when saving).
  • The bar switches through api.toDaemon, and links keep @<name>.
  • Opening a view on a daemon that cannot be reached no longer throws an unhandled error.
  • style.css is rebuilt from each unit's own block. The conflict resolution had left 5 blocks unclosed.
  • The new browser tests move to tests/system/web, following test: move the web page's browser tests to tests/system, and run the web topic in tests #369.
  • New tests/integration/test_web_daemons_together.py: a chat on an SSH daemon (the stand-in ssh) shows up as a notification on Local, tagged with that daemon.

Supersedes #371, #372, #373, #374, #375, #376, #377, #378, #379, #381, #382 and #384.

Checks

  • pre-commit run --all-files: clean.
  • pytest tests/unit tests/integration: 6241 passed.
  • pytest tests/system/web: 158 passed, in Chromium and WebKit.
  • Docs build, and every #fragment resolves.
  • Desktop: cargo fmt, clippy and test passed in feat(desktop): native notifications for hmz web's page #379. The native banner has not been seen on screen yet, because the permission prompt on the Mac it was tried on is still unanswered.

🤖 Generated with Claude Code

futrime and others added 25 commits October 12, 2026 22:46
web: daemons kept in Local's settings (Local plus SSH hosts, added or
imported), reached by running `hmz web --attached` over ssh with the
remote hmz installed at this version unless it holds running epics,
proxied under /d/<name>/api/; the daemon bar and picker; only the
current daemon shown, carried in every address; Settings grouped as
App and Daemon, with a daemon picked per page and Batch apply; notices
per machine and notifications gathered across daemons, filtered by
kind, with a bell, cards and system notifications.

runtime/doing: Host.status carries pending questions, the last end,
and the epic and task. desktop: the page may invoke `notify` alone,
shown as a native notification that opens its own-origin address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and app

Document Local and ssh daemons (adding, importing from ~/.ssh/config,
connecting, auto-install of the matching hmz, never under running epics),
the daemon bar and picker, #/@<name>/ addresses, the App and Daemon
settings groups with the daemon selector and Batch apply, and global
notifications: kinds, bell, menu, toasts, browser and macOS notifications.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bar names the daemon the page is of, its host, how it stands and
how many epics are live on it. Its picker lists GET /api/daemons (Local
alone on a 404), switches by the address's @<name>, offers Connect for
a failed daemon and Manage for Settings -> Daemons, and keeps Update,
Import, Settings, Usage and Download.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep the ssh daemons a page may switch between in settings.yaml under
`daemons`, checked as an ssh runtime is: Local first and implicit, then
each one added by hand or imported from the ssh config. Each carries how
it stands from `site.remotes` where the site has one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Serve /api/notifications, its stream, read, clear and settings on the
local interface. Notices are heard from site.notices and followed off each
reached SSH daemon's /api/notices/stream, tagged with the daemon, filtered
by the notifications kinds in settings.yaml, and the last 200 kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings gains a Daemons page -- Local and every SSH daemon with its
state, an SSH form a part at a time, import from the SSH config, connect,
and remove asked first -- and a Notifications page with the kinds, the
system toggle and the browser's Allow. Both say Unavailable where the
server does not serve their endpoints yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The address names the daemon first, #/@<name>/<view>, and Local where it
names none. api.js asks everything of that daemon under /d/<name>/api/,
save /api/daemons and /api/notifications, keeps @<name> in every link
through hash(), and on a switch clears what polls read, closes the held
streams and fires hmz:daemon, on which the view and the sidebar's lists
are drawn again. The server answers /d/local/api/ as /api/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page in the app can call `notify` with {title, body, address}. The app
posts it through UNUserNotificationCenter, asking for permission the first
time. Clicking the notification brings the window forward. If the address is
a `#/...` hash, the app opens that hash on hmz web's own origin and goes
nowhere else. A click that launches the app opens the hash once the page is
there.

- The app's commands get an ACL manifest. The remote page
  (http://127.0.0.1:*) is granted only `notify`, next to drag and zoom. The
  app's own page keeps status, retry, keep and update_now.
- No new crates: objc2, objc2-foundation, block2 and
  objc2-user-notifications are already in the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bell in the sidebar's top row with the unread count, a menu of the
history newest first (kind, daemon, flow and what it said, how long ago)
with Mark read and Clear, a card at the bottom right for each one heard
on the stream, and -- while the page is hidden or unfocused -- the
browser's Notification or the macOS app's `notify` command. Clicking any
of them reads it and opens the epic going or the epic that ended.

Built on the global /api/notifications endpoints; where the server has
none, the bell is hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings now has two groups. App holds this page's own settings (Themes, and Daemons and
Notifications once their pages exist). Daemon holds the rest, read from and written to the
daemon picked above them: the one on screen by default, kept in the address as
?daemon=<name>. General, Fallback, Runtimes and Backup gain Batch apply, which sends what
the page holds to each daemon ticked and says what each answered. Accounts say their
secrets stay put.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A host's status now says the questions it waits on (`pending`), how the last
run to end ended (`ended`), and the run's `epic` and `task`. `hmz web` polls
every workspace's host status every 2 s, without attaching, and turns what
changed into notices (done, failed, stopped, asked, listening), served at
`GET /api/notices?after=` and as `notice` events on `GET /api/notices/stream`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…here

Each daemon under `daemons` in settings.yaml is reached by running
`hmz web --attached` over ssh, with a loopback port forwarded to it, and
proxied at /d/<name>/api/... (SSE and uploads streamed) with its own key;
/d/local/api/... is this machine. A missing or other hmz there is installed
at this version with uv tool install, never while its daemon may hold
running epics. Adds /api/daemons/{name}/state and /connect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…manfia/humanize into feat/multi-daemon-notifications
…e into feat/multi-daemon-notifications

# Conflicts:
#	src/hmz/web/site.py
… into feat/multi-daemon-notifications

# Conflicts:
#	docs/reference/web.md
#	src/hmz/web/__init__.py
#	src/hmz/web/site.py
…e into feat/multi-daemon-notifications

# Conflicts:
#	src/hmz/web/static/sidebar.js
…to feat/multi-daemon-notifications

# Conflicts:
#	src/hmz/web/static/sidebar.js
…manize into feat/multi-daemon-notifications

# Conflicts:
#	src/hmz/web/static/pickers.js
#	src/hmz/web/static/style.css
#	src/hmz/web/static/views/settings.js
#	src/hmz/web/static/views/settings/runtimes.js
#	src/hmz/web/static/views/settings/usage.js
…humanfia/humanize into feat/multi-daemon-notifications

# Conflicts:
#	src/hmz/web/static/style.css
#	src/hmz/web/static/views/settings.js
…manize into feat/multi-daemon-notifications

# Conflicts:
#	src/hmz/web/static/sidebar.js
#	src/hmz/web/static/style.css
#	tests/integration/doubles_web.py
…/humanize into feat/multi-daemon-notifications

# Conflicts:
#	docs/user/web.md
- The daemon bar reads `{daemons: [...]}`, switches through `toDaemon`, and keeps `@<name>` on its links.
- Settings drops the optional-page guard now that both pages exist, and its links keep `@<name>`.
- The Daemons page saves `{name, fields, replace}`, and a rename removes the old entry.
- A view on a daemon that cannot be reached no longer throws: `places.js` catches the refusal.
- The notification settings are read only when a system notification is about to show.
- `style.css` is rebuilt from each unit's own block, with every brace closed, and the daemon bar no longer cuts "Local" short.
- The browser tests move under `tests/system/web`, as #369 moved them. Their fakes now follow the merged API, and `opened()` takes `routed=`.
- A new integration test covers a chat on an SSH daemon notifying Local.
- Docs: the proxy, the state and connect endpoints, the `daemons` key, and one Notifications section on the desktop page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 12, 2026 23:55

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread src/hmz/web/proxy.py
Comment thread src/hmz/web/proxy.py
futrime and others added 3 commits October 13, 2026 08:26
…z web never listened

Also formats the daemons test, and has the remote tests list the stand-in's processes when a daemon never comes up, so CI says where it waits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… what a remote hmz web says as it comes up

A notification sent between a stream's headers and its first read was lost, which hung the stream test on Linux. The stand-in remote now dumps its stack on SIGABRT, so CI says where a remote hmz web hangs on macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HTTPServer.server_bind asks socket.getfqdn for a name the interface never uses, and on macOS runners that lookup hung long enough that a remote hmz web never said its address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@futrime
futrime added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit 6514f96 Oct 13, 2026
13 checks passed
@futrime
futrime deleted the feat/multi-daemon-notifications branch October 13, 2026 01:01
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