From ad96cf47db05529216e4f9eaf4701427ee7bd077 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:46:59 +0800 Subject: [PATCH] docs(specs): spec multiple daemons and notifications for web and desktop 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//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 --- specs/cli.md | 3 +- specs/daemon.md | 2 +- specs/desktop.md | 15 ++++-- specs/runtime/doing.md | 6 ++- specs/web.md | 106 +++++++++++++++++++++++++++++++++++++++-- 5 files changed, 121 insertions(+), 11 deletions(-) diff --git a/specs/cli.md b/specs/cli.md index a2a47a3b..825f5fc3 100644 --- a/specs/cli.md +++ b/specs/cli.md @@ -113,7 +113,8 @@ def tools(argv: list[str]) -> int: ... a browser on it unless told `--no-open`, and MUST exit zero once interrupted and one, saying why, where the port cannot be had. A terminate or a hangup MUST stop it as an interrupt does, and so MUST its standard input closing where it is told `--attached`: whatever started it - takes it down by going, however it went. + takes it down by going, however it went -- another machine's `hmz web` reaching this one's + daemon over ssh included. - `hmz update` MUST move this install to the newest release `coganchor.releases` fetches, with whatever installed it -- `uv tool upgrade` for a uv tool, `pipx upgrade` for a pipx one, and otherwise pip, or uv's pip where there is no pip, asked for the extras whose packages are all diff --git a/specs/daemon.md b/specs/daemon.md index 75d828ce..1eb51dab 100644 --- a/specs/daemon.md +++ b/specs/daemon.md @@ -17,7 +17,7 @@ class Daemon: protocol: int = 0 # the frontends' protocol a host speaks; 0 for an older humanize's alive: bool # property def link(self, name: str = "", kind: str = "sdk", *, replay: bool = True) -> Link: ... - def status(self) -> dict[str, Any]: ... + def status(self) -> dict[str, Any]: ... # its host's, as `Host.status` says it def detach(self) -> int: ... def stop(self, *, seconds: float = 20.0) -> bool: ... def kill(self, *, seconds: float = 20.0) -> bool: ... diff --git a/specs/desktop.md b/specs/desktop.md index 37595ce9..1b120bb0 100644 --- a/specs/desktop.md +++ b/specs/desktop.md @@ -3,8 +3,8 @@ The macOS app in `desktop/`: one window on the page `hmz web` serves, which it starts as its own child and stops as it quits. It is a frame around that page and nothing more: it draws nothing of humanize's but how it is getting there and what went wrong, and is answerable only for -having the `hmz` of its own release installed, starting it, showing its page, leaving nothing -running behind it, and keeping itself up to date. +having the `hmz` of its own release installed, starting it, showing its page and what it +notifies, leaving nothing running behind it, and keeping itself up to date. ## API @@ -39,6 +39,9 @@ invoke('status', {after}) // once it is not starting, and not `after`, the stat invoke('retry') // starts again, where it failed, stopped or found no hmz invoke('keep') // starts the hmz there, where installing its own failed and `keep` was said invoke('update_now') // installs its own now, where it is waiting + +// What `hmz web`'s page asks of it, and nothing else. +invoke('notify', {title, body, address}) // a notification; clicked, the page opens `address` ``` `HUMANIZE_HMZ` is for development and tests: it names the `hmz` to run, nothing else is looked @@ -123,8 +126,12 @@ either: it runs the first `hmz` it finds. Reload, Actual Size, Zoom In, Zoom Out and Full Screen; and the documentation under Help. It MUST NOT take a shortcut the page binds: Cmd+K, Cmd+Shift+O and Cmd+. are the page's. - MUST NOT put a script of its own into `hmz web`'s page but what says whether the window is - full screen, nor grant it any of the app's commands, nor anything but to drag and zoom the - window. + full screen, nor grant it any of the app's commands but `notify`, nor anything but that and to + drag and zoom the window. +- MUST show what `notify` is handed as a notification of macOS's own, its title and body as + text, asking macOS for leave the first time; and a click on one MUST bring the window forward + and open its address on `hmz web`'s page, only where it is a `#` address of that page's own + origin. ### Leaving diff --git a/specs/runtime/doing.md b/specs/runtime/doing.md index c8c71c6d..3228bfba 100644 --- a/specs/runtime/doing.md +++ b/specs/runtime/doing.md @@ -76,7 +76,7 @@ class Host: *, replay: bool = True) -> str: ... # the frontend's client id def detach(self, client: str) -> None: ... def asked(self, client: str, said: dict[str, Any]) -> dict[str, Any]: ... - def status(self) -> dict[str, Any]: ... + def status(self) -> dict[str, Any]: ... # beside what runs: pending, ended, epic, task def away_for(self, role: str) -> bool: ... def printed(self, text: str) -> None: ... attached: int; idle: bool; closed: bool; epic: Path | None # properties @@ -373,6 +373,10 @@ class Fallbacks: that stops or ends with any waiting, MUST say so. Claims MUST NOT gate what is said to agents. - A run MUST be started with the backup targets its frontend chose, or the defaults where it chose none, and how it was started MUST say which. +- `status` MUST say, beside what is running, every question waiting on a person -- + `{question, role, text, mode}`, `mode` being `ask`, or `listen` for a chat waiting on its next + turn -- how the last run ended, `{how, why, epic, at}` or null, and the epic and task of the + run going or the last, so that a frontend can tell what happened without attaching. - One run MUST be going at a time, and any frontend MAY start, stop or force one; forcing MUST close every conversation of a stopping run under whatever turn is open, and say how many. - An aside MUST be read-only, carry no skills, say nothing to the run, answer only the frontend diff --git a/specs/web.md b/specs/web.md index b186dc59..5cb1bf91 100644 --- a/specs/web.md +++ b/specs/web.md @@ -3,8 +3,9 @@ The web interface `hmz web` serves: every workspace's runs on this machine, drawn in a browser on it. Where the terminal interface is about the directory it is opened in, this is about the machine, as the daemon is: one more frontend of the runs each workspace's host holds, beside -every terminal interface opened on them. It runs flows and reads them back, defines no flows, -drives no agents and keeps no store of its own. +every terminal interface opened on them -- and, through the `hmz web` it starts on each over ssh, +of other machines' daemons. It runs flows and reads them back, defines no flows, drives no agents +and keeps no store of its own. ## API @@ -23,6 +24,49 @@ def serve( class Site(ThreadingHTTPServer): port: int # property address: str # property: http://127.0.0.1:/?key= + notices: Notices + remotes: Remotes +# notices.py -- what this machine's runs did that a person would want told +class Notices: + def since(self, after: int = 0) -> list[dict[str, Any]]: ... # each numbered after `after` + def heard(self, callback: Callable[[dict[str, Any]], None]) -> None: ... # each as it comes +# remote.py -- the daemons of other machines, each reached over ssh +class Remotes: + def state(self, name: str) -> dict[str, Any]: ... # {state, detail, version} + def reached(self, name: str) -> tuple[int, str]: ... # its port forwarded here, and its key + def connect(self, name: str) -> None: ... + def close(self) -> None: ... +``` + +```text +GET /api/notices?after= {notices, last}, of this machine's runs alone +GET /api/notices/stream `notice` events, resumed from Last-Event-ID + a notice {id, kind, workspace, epic, flow, task, role, text, at} + its kind done | failed | stopped | asked | listening +GET /api/daemons Local, then each SSH daemon, with how it stands +POST /api/daemons one added or changed +POST /api/daemons/remove one removed +POST /api/daemons/import ssh config's hosts not yet added, those named +GET /api/daemons/hosts ssh config's hosts not yet added +POST /api/daemons//connect reached again +GET /api/daemons//state idle | connecting | installing | up | failed + /d//api/... that daemon's /api/..., proxied; /d/local/api/ is /api/ +GET /api/notifications {items, unread}: every daemon's notices, each with + its daemon and whether it is read +GET /api/notifications/stream `notification` events +POST /api/notifications/read {ids} or {all} +POST /api/notifications/clear +GET /api/notifications/settings {kinds: {done, failed, stopped, asked, listening}, system} +POST /api/notifications/settings +``` + +```yaml +# settings.yaml, Local's alone +daemons: # each SSH daemon; Local is always there and never written + - {name, host, user, port, identity_file, proxy_jump, options: [[key, value]], alias, config} +notifications: # every kind, and the system, on unless turned off + kinds: {done: true, failed: true, stopped: true, asked: true, listening: true} + system: true ``` `listening` attaches to the runs and listens without answering; `Site.serve_forever` answers @@ -207,5 +251,59 @@ until `Site.shutdown`. `serve` is both, until it is interrupted. and the desktop app alike. - MUST draw its chrome around the window controls of the desktop app when it is shown in it, and nothing else differently. -- MUST make every view an address that can be kept and opened again, and MUST say, rather than - hide, a refusal where it happened. +- MUST make every view an address that can be kept and opened again, the daemon it is of + included, and MUST say, rather than hide, a refusal where it happened. + +### Daemons + +- MUST keep the daemons in Local's settings: `Local`, this machine's, always there and never + removed or renamed, and every other an SSH host -- set up a part at a time as a runtime's ssh + host is, or imported from the ssh config, those already added skipped -- named by letters, + digits, `.`, `_` and `-`, `local` being Local's alone. +- MUST reach an SSH daemon by running `hmz web --no-open --attached --port 0` there over ssh, as + its entry says, and forwarding a free port of this machine's loopback to the address it prints, + holding ssh's input open so that it goes as this does; MUST try again, waiting longer each + time, where it drops; and MUST stop everything it started there as it goes. +- MUST find the `hmz` there as a login shell finds it, `~/.local/bin` included, and where none is + or it is not this one's version, install this one's with `uv tool install --force --python + '>=3.12' 'hmz=='`, getting uv there with astral's installer first where it has none, + saying `Installing` while it does. It MUST NOT replace one while that machine's daemon holds + running epics, nor where it cannot tell, saying why and reaching nothing; and a development + build, `0.0.0`, MUST install nothing. +- MUST hand a page an SSH daemon's `/api/` under `/d//api/` of its own origin -- + server-sent events and uploads streamed through, and the key and cookie that daemon's `hmz web` + takes put in here, never handed to the page -- and nothing else of it; `/d/local/api/` MUST be + `/api/`. One not reached MUST be refused as unavailable, saying how it stands. +- MUST draw the sidebar's foot as the daemon bar: the daemon shown, its host, how it stands and + how many epics it runs. A click on it MUST open the picker -- every daemon and how it stands, + one picked switched to, `Connect` on one that failed, `Manage` for the daemons in Settings -- + with the machine's own choices below it. +- MUST show the daemon picked alone -- its workspaces, epics, flows, settings and everything + else but the notifications -- carrying it in every address as `#/@/...`, an address + without one being Local's, and MUST draw everything again of the daemon switched to. +- MUST group Settings as `App`, what is this page's and Local's alone -- themes, daemons and + notifications -- and `Daemon`, every page a daemon keeps -- general, accounts, fallback, + runtimes, backup, workspace and usage -- read and written of the daemon picked on them, the + one shown at first. Each Daemon page MUST offer `Batch apply`, which writes what that page + saves to every daemon picked and says how each went; MUST NOT carry what only a secret sets, + and MUST say so where it is left out. + +### Notifications + +- MUST tell, of this machine's runs, every epic that ends -- `done`, `stopped`, or `failed` + where it failed, crashed, ran out of budget or was refused -- every question a flow asks a + person, `asked`, and every chat waiting on its next turn, `listening`: each a notice, numbered + in order and kept to a bound, served after a number and as server-sent events resumed from the + last heard, `hmz web` reached over ssh included. It MUST work them out of each host's status as + the daemon answers it, never by attaching, so that no host is kept for them. +- MUST gather, on Local alone, the notices of every daemon reached into one list of + notifications, each naming its daemon and whether it is read, the newest few hundred kept, and + MUST keep out of it, before it is kept or sent, every kind turned off. +- MUST offer them, whichever daemon is shown, behind a bell with how many are unread: each + opening its epic on its daemon, marked read one or all, and cleared; and each new one as a card + that goes after a while. +- MUST tell the system of a new one where the page is hidden or not focused, unless that is + turned off: in the desktop app by its `notify`, and in a browser by its notifications, leave + for which is asked only by a click in Settings. +- MUST keep which kinds are told and whether the system is in Local's settings alone, never a + daemon's.