From e43dcec88a16966cfda12436ddde32e22123eae6 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:23:56 +0000 Subject: [PATCH 01/20] ci: write a release's notes since the release before it, not its last pre-release (#387) release-please takes the last release to be the one its manifest names, so 0.1.0's notes said only what changed since 0.1.0-beta.2. release.yml now opens the release pull request through release-please's API, held to a lockfile, telling it the line's last release, or for a `.0` none, so a release's notes cover all its pre-releases did. Co-authored-by: Claude Opus 5.5 (cherry picked from commit d35b69a878505c6ba9bab76ffed1ce38d7cd107d) --- docs/contributing/releasing.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/contributing/releasing.md b/docs/contributing/releasing.md index 8f7d23ad..8a416ac1 100644 --- a/docs/contributing/releasing.md +++ b/docs/contributing/releasing.md @@ -34,18 +34,17 @@ rules as `main`. Three workflows do it: or, for a new line, since the line before it was cut. A pre-release counts as a last release only to the pre-releases after it: `0.2.0` says all that changed since `0.1.x`, not just since `0.2.0-rc.1`. -2. **`ci.yml`** runs on the release pull request, approved by `release.yml`: on a pull request - a workflow opened, it waits for approval, and its `ci-ok` is needed to merge it. It runs +2. **`ci.yml`** runs on the release pull request, started by `release.yml`: a pull request a + workflow opened starts no checks of its own, and `ci-ok` is needed to merge it. It runs again on every push to a `release/*` branch, the merge included, on what the push changed. 3. **`publish.yml`**, on a push to a `release/*` branch that merged its release pull request, tags the merge commit `vX.Y.Z`, builds a wheel and an sdist, runs `twine check --strict` (which fails if PyPI could not render `README.md`), publishes both to - [PyPI](https://pypi.org/project/hmz/), and only then publishes the GitHub release, so it is - never out before its package. Beside them, on macOS, it builds [the macOS app](/user/desktop) - as one disk image for Apple silicon and Intel, `humanize-X.Y.Z-macos-universal.dmg`, attests - it, and attaches it to the release, with the app's update beside it: see - [The app's updates](#the-apps-updates). Those are all the release offers: the wheel and the - sdist are PyPI's. + [PyPI](https://pypi.org/project/hmz/), and only then publishes the GitHub release with + them attached, so the release never offers files PyPI refused. Beside them, on macOS, it + builds [the macOS app](/user/desktop) as one disk image for Apple silicon and Intel, + `humanize-X.Y.Z-macos-universal.dmg`, attests it, and attaches it to the release too, with + the app's update beside it: see [The app's updates](#the-apps-updates). `main` itself never says a released version: its `pyproject.toml` says `0.0.0`, and so does `tauri.conf.json`. From 7fe0cd4d8c827ebd649a0b02b7c40ce4a346094e Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:36:32 +0000 Subject: [PATCH 02/20] fix(web): line up controls that share a row (#395) The composer's Options chip stood 22px tall beside an 18px role summary, fields' rows centred on their labels rather than their controls, and selects, segmented controls, icon buttons and small buttons each had a height of their own. Two tokens, --control-h and --control-h-sm, now set how tall a control stands; rows mixing type sizes sit on one baseline; and two sizes a later .icon-btn rule silently overrode are restored. Co-authored-by: Claude Opus 5.5 (cherry picked from commit dd787946a6e30f568ed990389152bfa82659ad9b) --- src/hmz/web/static/style.css | 120 +++++++++++------------------------ 1 file changed, 37 insertions(+), 83 deletions(-) diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index de919944..cf15000c 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -62,11 +62,6 @@ select, a button and an icon button, and the small ones on a composer's bar. */ --control-h: 32px; --control-h-sm: 26px; - /* The sidebar's one column, as ChatGPT and Claude keep theirs: every row's icon stands in a slot - this far in from the sidebar's edge, and every row's words start just past it. */ - --side-in: 16px; - --side-slot: 20px; - --side-text: calc(var(--side-in) + var(--side-slot) + 8px); } [data-theme='dark'] { @@ -265,19 +260,17 @@ code { background: linear-gradient(90deg, var(--green) 0 50%, var(--cyan) 50%); } -/* As tall as the bar over the view, its rule included, and centred on the same line. */ .side-top { display: flex; align-items: center; justify-content: space-between; - padding: 8px 8px 13px calc(var(--side-in) - 2px); + padding: 12px 10px 8px 14px; } .brand { display: flex; align-items: center; - /* The mark, 2px wider than the slot each side, centred on it: the name starts where words do. */ - gap: 6px; + gap: 9px; color: var(--ink); text-decoration: none; } @@ -296,16 +289,15 @@ code { .side-nav { display: grid; gap: 1px; - padding: 2px 8px 8px; + padding: 4px 8px 8px; border-bottom: 1px solid var(--line); } .side-nav a { display: flex; align-items: baseline; - gap: 8px; - min-height: var(--control-h); - padding: 6px calc(var(--side-in) - 8px); + gap: 10px; + padding: 7px 10px; color: var(--ink); text-decoration: none; font-weight: 600; @@ -321,10 +313,6 @@ code { box-shadow: inset 4px 0 0 var(--cyan); } -.side-nav a > .icon { - margin: 0 calc((var(--side-slot) - 18px) / 2); -} - .side-badge { margin-left: auto; min-width: 18px; @@ -375,12 +363,10 @@ code { } .side-quiet.nested { - padding-left: calc(var(--side-text) - 8px); + padding-left: 34px; } -/* An epic: its mark in the slot, and over it, where the row is pointed at, what opens it out. */ .side-run { - position: relative; display: flex; align-items: center; } @@ -393,7 +379,7 @@ code { display: flex; align-items: baseline; gap: 8px; - padding: 6px calc(var(--side-in) - 8px); + padding: 6px 8px; color: var(--ink); text-decoration: none; } @@ -420,8 +406,8 @@ code { .side-toggle { flex: none; - width: var(--side-slot); - height: 100%; + width: 22px; + height: 30px; display: grid; place-items: center; padding: 0; @@ -432,13 +418,12 @@ code { } .side-toggle .icon { - grid-area: 1 / 1; width: 14px; height: 14px; transition: transform 0.15s; } -.side-toggle[aria-expanded='true'] .icon-chevron { +.side-toggle[aria-expanded='true'] .icon { transform: rotate(90deg); } @@ -446,41 +431,9 @@ code { color: var(--ink); } -.side-run > .side-toggle { - position: absolute; - inset: 0 auto 0 calc(var(--side-in) - 8px); - opacity: 0; -} - -.side-run.current > .side-toggle { - color: var(--on-ink); -} - -.side-run:is(:hover, :has(.side-toggle:focus-visible)) > .side-toggle { - opacity: 1; -} - -.side-run:is(:hover, :has(.side-toggle:focus-visible)) .side-mark { - visibility: hidden; -} - -/* A workspace's folder, as an epic's mark, gives way to what folds it where it is pointed at. */ -.side-folder .side-toggle .icon-folder { - color: var(--cyan-ink); -} - -.side-folder .side-toggle .icon-chevron, -.side-folder:is(:hover, :has(.side-toggle:focus-visible)) .side-toggle .icon-folder { - visibility: hidden; -} - -.side-folder:is(:hover, :has(.side-toggle:focus-visible)) .side-toggle .icon-chevron { - visibility: visible; -} - .side-mark { flex: none; - width: var(--side-slot); + width: 14px; text-align: center; font-size: 11px; color: var(--tone, var(--ink-3)); @@ -504,16 +457,14 @@ code { color: var(--ink-3); } -/* The line down an epic opened out runs under the slot's middle, and its agents' icons stand - where the epic's words start. */ .side-tree { - margin: 0 0 6px calc(var(--side-in) + var(--side-slot) / 2 - 9px) !important; - padding: 2px 0 2px calc(var(--side-slot) / 2 - 1px) !important; + margin: 0 0 6px 21px !important; + padding: 2px 0 2px 6px !important; border-left: 2px solid var(--line-strong); } .side-tree ul { - padding-left: 22px !important; + padding-left: 18px !important; } .side-agent { @@ -639,15 +590,7 @@ code { .side-folder { display: flex; align-items: center; - gap: 8px; - min-height: var(--control-h-sm); margin-top: 6px; - padding-left: calc(var(--side-in) - 8px); -} - -.side-folder > .side-toggle { - align-self: stretch; - height: auto; } .side-folder-name { @@ -655,6 +598,7 @@ code { min-width: 0; display: flex; align-items: center; + gap: 7px; padding: 4px 0; font: 600 11px var(--mono); text-transform: uppercase; @@ -662,6 +606,12 @@ code { color: var(--ink-2); } +.side-folder-name .icon { + width: 14px; + height: 14px; + color: var(--cyan-ink); +} + .side-folder.current .side-folder-name { color: var(--ink); } @@ -677,6 +627,11 @@ code { opacity: 1; } +.side-group > ul, +.side-group > .side-quiet { + margin-left: 8px !important; +} + .pickers { display: flex; align-items: center; @@ -3746,27 +3701,26 @@ pre.exo-args { /* ------------------------------------------------------------------ the macOS app's window */ -/* There the page runs to the window's top edge, and the window's traffic lights stand where - macOS puts them, in its top 32 pixels at the left. That strip is kept for them alone, across - the whole window: the sidebar draws its top row under it, the view under it, and a dialog that - fills a narrow window under it. Full screen, where they are gone, the page is drawn as anywhere - else. */ +/* There the page runs to the window's top edge, and the window's traffic lights stand over its + top left, centred on the top row: what is drawn first along it starts past them -- the + sidebar's top row, or the bar over the view where the sidebar is folded away or a drawer, or a + dialog that fills a narrow window -- and full screen, where they are gone, the page is drawn + as anywhere else. */ :root[data-shell='macos']:not([data-fullscreen]) { - --lights-strip: 32px; + --lights: 80px; } :root[data-shell='macos']:not([data-fullscreen]) .side-top { - /* The band across the sidebar's top runs under the lights, inside the strip. */ - padding-top: calc(var(--lights-strip) - 4px + 8px); + padding: 7px 10px 13px calc(var(--lights) + 6px); } -:root[data-shell='macos']:not([data-fullscreen]) .main { - padding-top: var(--lights-strip); +:root[data-shell='macos']:not([data-fullscreen]) .side-folded .bar { + padding-left: var(--lights); } @media (max-width: 860px) { - :root[data-shell='macos']:not([data-fullscreen]) .overlay { - padding-top: var(--lights-strip); + :root[data-shell='macos']:not([data-fullscreen]) :is(header.bar, .settings-nav, .dialog-head) { + padding-left: var(--lights); } } From 75650a48ef8bdd108c6dd3204699a35bffa3bce9 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:16:05 +0000 Subject: [PATCH 03/20] ci: find the release pull request by its branch to run CI on, never on main (#398) release.yml looked it up by its `autorelease: pending` label, which release-please had not yet added for 0.2.0-beta.2: the lookup came back empty and CI was dispatched on main, leaving the release pull request without `ci-ok`. Co-authored-by: Claude Opus 5.5 (cherry picked from commit ce90040cc0906dfb28ac9c62236a16bdf685fea8) --- .github/workflows/release.yml | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34ea2f52..eb218a01 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: contents: write # the release branch pull-requests: write # the release pull request issues: write # the `autorelease:` labels release-please finds its pull requests by - actions: write # to approve ci.yml's run on the release pull request + actions: write # to run ci.yml on the release pull request steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -128,10 +128,10 @@ jobs: npm ci --no-audit --no-fund node release-pr.js - # A pull request this workflow's token opens or updates starts ci.yml held for approval, - # and only that run's `ci-ok` lets it merge: a dispatched run's does not count. So it is - # approved here, found by release-please's branch rather than its `autorelease: pending` - # label, which the pull request may not have yet. + # A pull request this workflow's token opens or updates starts no `ci-ok` of its own, and + # the branch rule needs one. A dispatch is the one event that token may start. The pull + # request is found by release-please's branch, not its `autorelease: pending` label, which + # it may not have yet; and never dispatched without one, which would run CI on `main`. - name: Run CI on the release pull request env: GH_TOKEN: ${{ github.token }} @@ -143,13 +143,4 @@ jobs: echo "::error::No release pull request is open against $BRANCH." exit 1 fi - for _ in $(seq 30); do - run="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow ci.yml --branch "$head" \ - --event pull_request --status action_required --json databaseId --jq '.[0].databaseId // ""')" - if [[ -n "$run" ]]; then - gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runs/$run/approve" - exit 0 - fi - sleep 10 - done - echo "::warning::No CI run on $head waits for approval: the pull request did not change." + gh workflow run ci.yml --ref "$head" --repo "$GITHUB_REPOSITORY" From 8391ad17f8371db9b3bdbfa04fd0e47122c1ee2a Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:25:44 +0000 Subject: [PATCH 04/20] fix: line up the sidebar's top row with the traffic lights, and every row on one column (#399) * fix(web): hang every sidebar row on one column The sidebar's brand, its three links, the workspace folders and the epics each started at an x of their own: icons at 16, 17, 19 and 29px, words at 36, 39, 53 and 60px. As ChatGPT and Claude lay theirs out, every row's icon now stands centred in one slot (--side-in, --side-slot) and every row's words start at --side-text just past it; an epic opened out runs its line under the slot's middle and its agents' icons where the epic's words start. A workspace's folder and an epic's mark stand in that slot themselves, giving way to the chevron that folds them where the row is pointed at or the chevron has the focus, rather than the chevron taking a column of its own. Co-Authored-By: Claude Opus 5.5 * fix(desktop): centre the traffic lights on the sidebar's top row Tauri's `y` is where the title bar is made to end, and AppKit keeps its buttons 9 points above that, so `y: 21` stood the lights' centre 19 down, 8 above the logo, the bell and the fold at 27 and the bar's 28. They now stand at `y: 30`, centred 28 down, and at `x: 19`, the close button centred on the sidebar's column of icons. The sidebar's top row is as tall as the bar over the view and centred on the same line, in the app and the browser. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 (cherry picked from commit fc0588f6c9c31724d2293a6c8f3f6e0408b24132) --- desktop/src-tauri/src/chrome.rs | 16 ++++-- src/hmz/web/static/style.css | 99 ++++++++++++++++++++++++--------- 2 files changed, 84 insertions(+), 31 deletions(-) diff --git a/desktop/src-tauri/src/chrome.rs b/desktop/src-tauri/src/chrome.rs index bb87732c..0bde9d70 100644 --- a/desktop/src-tauri/src/chrome.rs +++ b/desktop/src-tauri/src/chrome.rs @@ -1,10 +1,17 @@ -//! The window's chrome, one with the page: no title bar of its own but the traffic lights, where -//! macOS puts them, over a strip the page keeps for them, and the page told it is in the app, and -//! when the window is full screen and they are gone, to keep it no more. +//! The window's chrome, one with the page: no title bar of its own but the traffic lights, +//! standing over the page's top row, and the page told it is in the app, and when the window is +//! full screen and they are gone, to draw that row around them. use std::sync::atomic::{AtomicBool, Ordering}; -use tauri::{Manager, Runtime, TitleBarStyle, WebviewWindow, WebviewWindowBuilder, Window}; +use tauri::{ + LogicalPosition, Manager, Runtime, TitleBarStyle, WebviewWindow, WebviewWindowBuilder, Window, +}; + +/// Where the traffic lights stand: centred on the page's top row, 57 pixels tall with the rule +/// under it, so 28 down; the close button centred on the sidebar's column of icons, 26 in. `y` is +/// where the title bar is made to end, and AppKit keeps its 14-point buttons 9 points above that. +const LIGHTS: LogicalPosition = LogicalPosition { x: 19.0, y: 30.0 }; /// What WKWebView says it is where it is not told otherwise. const WEBKIT: &str = @@ -21,6 +28,7 @@ pub fn dress<'a, R: Runtime, M: Manager>( window .title_bar_style(TitleBarStyle::Overlay) .hidden_title(true) + .traffic_light_position(LIGHTS) .user_agent(&user_agent(version)) } diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index cf15000c..e110390d 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -62,6 +62,11 @@ select, a button and an icon button, and the small ones on a composer's bar. */ --control-h: 32px; --control-h-sm: 26px; + /* The sidebar's one column, as ChatGPT and Claude keep theirs: every row's icon stands in a slot + this far in from the sidebar's edge, and every row's words start just past it. */ + --side-in: 16px; + --side-slot: 20px; + --side-text: calc(var(--side-in) + var(--side-slot) + 8px); } [data-theme='dark'] { @@ -260,17 +265,19 @@ code { background: linear-gradient(90deg, var(--green) 0 50%, var(--cyan) 50%); } +/* As tall as the bar over the view, its rule included, and centred on the same line. */ .side-top { display: flex; align-items: center; justify-content: space-between; - padding: 12px 10px 8px 14px; + padding: 8px 8px 13px calc(var(--side-in) - 2px); } .brand { display: flex; align-items: center; - gap: 9px; + /* The mark, 2px wider than the slot each side, centred on it: the name starts where words do. */ + gap: 6px; color: var(--ink); text-decoration: none; } @@ -289,15 +296,16 @@ code { .side-nav { display: grid; gap: 1px; - padding: 4px 8px 8px; + padding: 2px 8px 8px; border-bottom: 1px solid var(--line); } .side-nav a { display: flex; align-items: baseline; - gap: 10px; - padding: 7px 10px; + gap: 8px; + min-height: var(--control-h); + padding: 6px calc(var(--side-in) - 8px); color: var(--ink); text-decoration: none; font-weight: 600; @@ -313,6 +321,10 @@ code { box-shadow: inset 4px 0 0 var(--cyan); } +.side-nav a > .icon { + margin: 0 calc((var(--side-slot) - 18px) / 2); +} + .side-badge { margin-left: auto; min-width: 18px; @@ -363,10 +375,12 @@ code { } .side-quiet.nested { - padding-left: 34px; + padding-left: calc(var(--side-text) - 8px); } +/* An epic: its mark in the slot, and over it, where the row is pointed at, what opens it out. */ .side-run { + position: relative; display: flex; align-items: center; } @@ -379,7 +393,7 @@ code { display: flex; align-items: baseline; gap: 8px; - padding: 6px 8px; + padding: 6px calc(var(--side-in) - 8px); color: var(--ink); text-decoration: none; } @@ -406,8 +420,8 @@ code { .side-toggle { flex: none; - width: 22px; - height: 30px; + width: var(--side-slot); + height: 100%; display: grid; place-items: center; padding: 0; @@ -418,12 +432,13 @@ code { } .side-toggle .icon { + grid-area: 1 / 1; width: 14px; height: 14px; transition: transform 0.15s; } -.side-toggle[aria-expanded='true'] .icon { +.side-toggle[aria-expanded='true'] .icon-chevron { transform: rotate(90deg); } @@ -431,9 +446,41 @@ code { color: var(--ink); } +.side-run > .side-toggle { + position: absolute; + inset: 0 auto 0 calc(var(--side-in) - 8px); + opacity: 0; +} + +.side-run.current > .side-toggle { + color: var(--on-ink); +} + +.side-run:is(:hover, :has(.side-toggle:focus-visible)) > .side-toggle { + opacity: 1; +} + +.side-run:is(:hover, :has(.side-toggle:focus-visible)) .side-mark { + visibility: hidden; +} + +/* A workspace's folder, as an epic's mark, gives way to what folds it where it is pointed at. */ +.side-folder .side-toggle .icon-folder { + color: var(--cyan-ink); +} + +.side-folder .side-toggle .icon-chevron, +.side-folder:is(:hover, :has(.side-toggle:focus-visible)) .side-toggle .icon-folder { + visibility: hidden; +} + +.side-folder:is(:hover, :has(.side-toggle:focus-visible)) .side-toggle .icon-chevron { + visibility: visible; +} + .side-mark { flex: none; - width: 14px; + width: var(--side-slot); text-align: center; font-size: 11px; color: var(--tone, var(--ink-3)); @@ -457,14 +504,16 @@ code { color: var(--ink-3); } +/* The line down an epic opened out runs under the slot's middle, and its agents' icons stand + where the epic's words start. */ .side-tree { - margin: 0 0 6px 21px !important; - padding: 2px 0 2px 6px !important; + margin: 0 0 6px calc(var(--side-in) + var(--side-slot) / 2 - 9px) !important; + padding: 2px 0 2px calc(var(--side-slot) / 2 - 1px) !important; border-left: 2px solid var(--line-strong); } .side-tree ul { - padding-left: 18px !important; + padding-left: 22px !important; } .side-agent { @@ -590,7 +639,15 @@ code { .side-folder { display: flex; align-items: center; + gap: 8px; + min-height: var(--control-h-sm); margin-top: 6px; + padding-left: calc(var(--side-in) - 8px); +} + +.side-folder > .side-toggle { + align-self: stretch; + height: auto; } .side-folder-name { @@ -598,7 +655,6 @@ code { min-width: 0; display: flex; align-items: center; - gap: 7px; padding: 4px 0; font: 600 11px var(--mono); text-transform: uppercase; @@ -606,12 +662,6 @@ code { color: var(--ink-2); } -.side-folder-name .icon { - width: 14px; - height: 14px; - color: var(--cyan-ink); -} - .side-folder.current .side-folder-name { color: var(--ink); } @@ -627,11 +677,6 @@ code { opacity: 1; } -.side-group > ul, -.side-group > .side-quiet { - margin-left: 8px !important; -} - .pickers { display: flex; align-items: center; @@ -3711,7 +3756,7 @@ pre.exo-args { } :root[data-shell='macos']:not([data-fullscreen]) .side-top { - padding: 7px 10px 13px calc(var(--lights) + 6px); + padding-left: calc(var(--lights) + 8px); } :root[data-shell='macos']:not([data-fullscreen]) .side-folded .bar { From 0fd5a7a3e77684410d42703e28a1988f9a5d38db Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:28:50 +0000 Subject: [PATCH 05/20] ci: approve the release pull request's own CI run instead of dispatching one (#400) A dispatched run's ci-ok does not satisfy the branch rule on 0.2.0-beta.2's pull request; the pull_request run, held for approval as it is on a pull request a workflow opened, does. Co-authored-by: Claude Opus 5.5 (cherry picked from commit a4b64a35ce7eb72cce44bd780d69065f8679ea53) --- .github/workflows/release.yml | 21 +++++++++++++++------ docs/contributing/releasing.md | 4 ++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb218a01..34ea2f52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: contents: write # the release branch pull-requests: write # the release pull request issues: write # the `autorelease:` labels release-please finds its pull requests by - actions: write # to run ci.yml on the release pull request + actions: write # to approve ci.yml's run on the release pull request steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -128,10 +128,10 @@ jobs: npm ci --no-audit --no-fund node release-pr.js - # A pull request this workflow's token opens or updates starts no `ci-ok` of its own, and - # the branch rule needs one. A dispatch is the one event that token may start. The pull - # request is found by release-please's branch, not its `autorelease: pending` label, which - # it may not have yet; and never dispatched without one, which would run CI on `main`. + # A pull request this workflow's token opens or updates starts ci.yml held for approval, + # and only that run's `ci-ok` lets it merge: a dispatched run's does not count. So it is + # approved here, found by release-please's branch rather than its `autorelease: pending` + # label, which the pull request may not have yet. - name: Run CI on the release pull request env: GH_TOKEN: ${{ github.token }} @@ -143,4 +143,13 @@ jobs: echo "::error::No release pull request is open against $BRANCH." exit 1 fi - gh workflow run ci.yml --ref "$head" --repo "$GITHUB_REPOSITORY" + for _ in $(seq 30); do + run="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow ci.yml --branch "$head" \ + --event pull_request --status action_required --json databaseId --jq '.[0].databaseId // ""')" + if [[ -n "$run" ]]; then + gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runs/$run/approve" + exit 0 + fi + sleep 10 + done + echo "::warning::No CI run on $head waits for approval: the pull request did not change." diff --git a/docs/contributing/releasing.md b/docs/contributing/releasing.md index 8a416ac1..1f7d0049 100644 --- a/docs/contributing/releasing.md +++ b/docs/contributing/releasing.md @@ -34,8 +34,8 @@ rules as `main`. Three workflows do it: or, for a new line, since the line before it was cut. A pre-release counts as a last release only to the pre-releases after it: `0.2.0` says all that changed since `0.1.x`, not just since `0.2.0-rc.1`. -2. **`ci.yml`** runs on the release pull request, started by `release.yml`: a pull request a - workflow opened starts no checks of its own, and `ci-ok` is needed to merge it. It runs +2. **`ci.yml`** runs on the release pull request, approved by `release.yml`: on a pull request + a workflow opened, it waits for approval, and its `ci-ok` is needed to merge it. It runs again on every push to a `release/*` branch, the merge included, on what the push changed. 3. **`publish.yml`**, on a push to a `release/*` branch that merged its release pull request, tags the merge commit `vX.Y.Z`, builds a wheel and an sdist, runs `twine check --strict` From 6c267ab4428a837640c81c2f9aaf72dc047b46a1 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:11:23 +0000 Subject: [PATCH 06/20] fix(desktop): keep a strip for the traffic lights over the sidebar's top row (#402) The lights' position the app set never took: they stood where macOS puts them, in the window's top 32 points, with the sidebar's top row beside them and centred lower. The app now leaves them there, and the sidebar keeps that strip for them alone, drawing its top row under it, from its left edge as every row below it is. Co-authored-by: Claude Opus 5.5 (cherry picked from commit 01d54c43feaf4edea2ab91c360c5c0a7dc2f69dd) --- desktop/src-tauri/src/chrome.rs | 16 ++++------------ src/hmz/web/static/style.css | 14 ++++++++------ 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/desktop/src-tauri/src/chrome.rs b/desktop/src-tauri/src/chrome.rs index 0bde9d70..bb87732c 100644 --- a/desktop/src-tauri/src/chrome.rs +++ b/desktop/src-tauri/src/chrome.rs @@ -1,17 +1,10 @@ -//! The window's chrome, one with the page: no title bar of its own but the traffic lights, -//! standing over the page's top row, and the page told it is in the app, and when the window is -//! full screen and they are gone, to draw that row around them. +//! The window's chrome, one with the page: no title bar of its own but the traffic lights, where +//! macOS puts them, over a strip the page keeps for them, and the page told it is in the app, and +//! when the window is full screen and they are gone, to keep it no more. use std::sync::atomic::{AtomicBool, Ordering}; -use tauri::{ - LogicalPosition, Manager, Runtime, TitleBarStyle, WebviewWindow, WebviewWindowBuilder, Window, -}; - -/// Where the traffic lights stand: centred on the page's top row, 57 pixels tall with the rule -/// under it, so 28 down; the close button centred on the sidebar's column of icons, 26 in. `y` is -/// where the title bar is made to end, and AppKit keeps its 14-point buttons 9 points above that. -const LIGHTS: LogicalPosition = LogicalPosition { x: 19.0, y: 30.0 }; +use tauri::{Manager, Runtime, TitleBarStyle, WebviewWindow, WebviewWindowBuilder, Window}; /// What WKWebView says it is where it is not told otherwise. const WEBKIT: &str = @@ -28,7 +21,6 @@ pub fn dress<'a, R: Runtime, M: Manager>( window .title_bar_style(TitleBarStyle::Overlay) .hidden_title(true) - .traffic_light_position(LIGHTS) .user_agent(&user_agent(version)) } diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index e110390d..0a3bb024 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3746,17 +3746,19 @@ pre.exo-args { /* ------------------------------------------------------------------ the macOS app's window */ -/* There the page runs to the window's top edge, and the window's traffic lights stand over its - top left, centred on the top row: what is drawn first along it starts past them -- the - sidebar's top row, or the bar over the view where the sidebar is folded away or a drawer, or a - dialog that fills a narrow window -- and full screen, where they are gone, the page is drawn - as anywhere else. */ +/* There the page runs to the window's top edge, and the window's traffic lights stand where + macOS puts them, in its top 32 pixels at the left. The sidebar keeps that strip for them alone, + drawing its top row under it; where the sidebar is folded away or a drawer, the bar over the + view, or a dialog that fills a narrow window, starts past them. Full screen, where they are + gone, the page is drawn as anywhere else. */ :root[data-shell='macos']:not([data-fullscreen]) { --lights: 80px; + --lights-strip: 32px; } :root[data-shell='macos']:not([data-fullscreen]) .side-top { - padding-left: calc(var(--lights) + 8px); + /* The band across the sidebar's top runs under the lights, inside the strip. */ + padding-top: calc(var(--lights-strip) - 4px + 8px); } :root[data-shell='macos']:not([data-fullscreen]) .side-folded .bar { From 520f8b937145593fdb4b02f0f5dcf7af2ed6c25f Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:26:44 +0000 Subject: [PATCH 07/20] fix(desktop): keep the traffic lights' strip across the whole window (#403) The sidebar kept a strip for the lights, but folded away the bar over the view stood beside them, centred lower, as it did before. The strip now runs across the whole window: the view is drawn under it too, and moves the window where it is dragged, and a dialog that fills a narrow window starts under it. Nothing is pushed right past the lights any more. Co-authored-by: Claude Opus 5.5 (cherry picked from commit dcf9d02508a7ed79999de8402e5a49ea1b47aa77) --- src/hmz/web/static/style.css | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index 0a3bb024..de919944 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3747,12 +3747,11 @@ pre.exo-args { /* ------------------------------------------------------------------ the macOS app's window */ /* There the page runs to the window's top edge, and the window's traffic lights stand where - macOS puts them, in its top 32 pixels at the left. The sidebar keeps that strip for them alone, - drawing its top row under it; where the sidebar is folded away or a drawer, the bar over the - view, or a dialog that fills a narrow window, starts past them. Full screen, where they are - gone, the page is drawn as anywhere else. */ + macOS puts them, in its top 32 pixels at the left. That strip is kept for them alone, across + the whole window: the sidebar draws its top row under it, the view under it, and a dialog that + fills a narrow window under it. Full screen, where they are gone, the page is drawn as anywhere + else. */ :root[data-shell='macos']:not([data-fullscreen]) { - --lights: 80px; --lights-strip: 32px; } @@ -3761,13 +3760,13 @@ pre.exo-args { padding-top: calc(var(--lights-strip) - 4px + 8px); } -:root[data-shell='macos']:not([data-fullscreen]) .side-folded .bar { - padding-left: var(--lights); +:root[data-shell='macos']:not([data-fullscreen]) .main { + padding-top: var(--lights-strip); } @media (max-width: 860px) { - :root[data-shell='macos']:not([data-fullscreen]) :is(header.bar, .settings-nav, .dialog-head) { - padding-left: var(--lights); + :root[data-shell='macos']:not([data-fullscreen]) .overlay { + padding-top: var(--lights-strip); } } From 3432debeedb883fecd97062c99b71a1d428e75ec Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:06:23 +0000 Subject: [PATCH 08/20] feat: chat like Claude, no all-sessions log, Exomyth fixes, split profiling, TUI parity (#422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): drop Exomyth's arrows and keep its panel's text from overlapping Exomyth no longer draws an arrow from a span to the process it started, nor offers the Arrows toggle; Perfetto draws nothing like them. The panel's facts overlapped in WebKit: a process named over several lines sits in its fact as a button, an inline-block whose baseline is its last line, so the facts' baseline alignment drew it up over the fact above. It is top-aligned now. The chart's tooltip, kept only inside the window, also lay over the panel after a click; it is kept over the chart now. Co-Authored-By: Claude Opus 5.5 * feat(web): tell nothing of the epic you are on, and mark unread epics in the sidebar A notification of the epic the page is on, while it is looked at, is read as it comes: no card and no system notification. An epic with one unread has a mark in the sidebar, as does its workspace while folded; opening the epic reads them. Co-Authored-By: Claude Opus 5.5 * feat(web): drop the log of every session and the sidebar's Exomyth row An epic's sidebar tree lists only its agents and their sessions: the "Log" row every agent and person appeared on, and the "Exomyth" row, are gone, as is "Open log" from the epic's menu. Exomyth opens from the epic's menu or the chart in its bar. `#/epics//logs` with no agent now opens the epic's graph; per-agent and per-person logs and sessions stay. The transcript's shared mode, which only that log used, goes too. Co-Authored-By: Claude Opus 5.5 * feat: profile the flow and its environment apart in the web page and the TUI The New epic form's Options carries two boxes, Profile flow and Profile environment, and the /flow roles page two switches, profile flow and profile environment, each remembered per flow, carried on resume and sent with the start as profile_flow and profile_environment. The epic panel says which of the two an epic was profiled for. Co-Authored-By: Claude Opus 5.5 * feat(web): open nothing on Flowverses until chosen, and list no local or user The Flowverses tab no longer shows the Add flowverse form by default: a flowverse's detail, or the form adding one, appears only once its card is chosen, and choosing it again closes it. A `?verse=` address still opens that flowverse. `local` and `user` are places read where they stand rather than indexes, so they are no longer listed among flowverses; they remain under Installed. Co-Authored-By: Claude Opus 5.5 * style: format the flowverses unit test Co-Authored-By: Claude Opus 5.5 * feat(web): say a chat running only while its agent answers, and each turn done as a notice A chat epic's row and detail say how=running while a turn is open and done otherwise (stopped, left or over, short of failing). The host's status and run standing carry turn: running|done for chat, done where it asks what to say next. Notices tell each finished chat turn as done and never a chat stopped. Co-Authored-By: Claude Opus 5.5 * feat(tui): find /epics by status, flow, workspace and task, a page at a time /epics now has filter rows above the list (status, flow, and workspace on this machine), each a dropdown with counts, beside the `/` search, and pages of 50 behind Newer and Older buttons, as the web's search dialog has. How an epic went and whether a search finds it move into hmz.runtime.epic (`ending`, `found`, `ENDINGS`), which the web's /api/runs and the TUI share. Co-Authored-By: Claude Opus 5.5 * feat(tui): read a chat as Claude Code reads one conversation A chat is one agent and one conversation, so the TUI drops everything that is about a flow of several agents while one runs: no monitor on left, no views to round on tab/shift+tab, no outworlder line above the prompt, no Exomyth for its epic, and nothing about stopping. The status line says running while the assistant answers and done once it waits for you. ctrl+c clears the line and, on an empty one, twice leaves, as in Claude Code; /stop is refused; /clear ends the chat so the next line starts another; /exit and ctrl+q leave without asking, a host keeping a chat it holds. Tests that exercised flow-wide behaviour through chat now run chat's own code under another name (TALKS), which the interface reads as any flow. Co-Authored-By: Claude Opus 5.5 * feat(tui): usage page, download from backups, smoother import/export, flow-update count Co-Authored-By: Claude Opus 5.5 * feat(tui): give Exomyth in the terminal what the web's has Exomyth in the TUI now does what the web page's Exomyth does, all from the keyboard: - `v` picks a range from one span to another. Press it again to hold the range and a third time to drop it. The pane shows the range's spans, time and tokens by category, and `f` zooms to the range. - `p` opens a pane on the process of the row under the cursor. - The flame is built like the web's: one stack per agent, flow or program, then its spans as they nest. `m` puts sessions apart or merges them. `t` cycles the weight through time, tokens in, tokens out and count. - A Slices tab lists every span name with its count, total, self, avg, p50, p95, max, in and out. `o` changes the sort column, `O` reverses it, and `enter` finds the slice on the timeline. - `tab` and `shift+tab` go round the three views. Lanes of any process are drawn and weighed the same way, so a lane of nested slices, such as a sampled flow, shows without special code. Co-Authored-By: Claude Opus 5.5 * feat(web): draw a chat as its conversation alone, as a chat app does A chat epic is opened on its conversation wherever it is opened: no graph, no agents or sessions under its sidebar row, no Exomyth, Log or People, and no Stop. It is said only as running while its agent answers and done otherwise, from the live `turn` where the server says it. A new epic started in its workspace ends the chat going there, as a new chat leaves the last. Co-Authored-By: Claude Opus 5.5 * feat(tui): open the epic from the monitor, and stop or force it there The monitor gains a `◫ epic` node, under the all-agents one, saying how the epic stands and its budget against what has gone. Enter or a click opens its page, the web's epic panel in the terminal: figures (time, spend, tokens), the budget limit by limit with a meter, what it was set up with (flow, task, agents, environments, params, whether profiled) and the flows called, live. For every flow but chat, the page has a `Stop` button while the epic runs and `Force stop` while it is stopping, each asked first in a box and only acting on the epic it was asked about. ctrl+c and /stop work as before. Co-Authored-By: Claude Opus 5.5 * test(web): a chat on another machine tells each turn done, not its stopping Co-Authored-By: Claude Opus 5.5 * feat(tui): tell of this machine's epics as notifications, and dot them in /epics The TUI now tells, as the web interface does, of every epic on this machine that ends, asks, or waits for its next chat turn -- a toast and, unless the system is turned off, the terminal bell and an OSC 9 system notification -- never of the epic on the screen. Each epic told of is dotted in /epics until it is opened there, and a Notifications page in /settings switches the same kinds and system setting the web interface's does. What is news is worked out once: hmz.web.notices moves to hmz.runtime.watching.notices, with the notification preferences, so both interfaces share it; the web routes for /api/notices move to hmz.web.notifications. Co-Authored-By: Claude Opus 5.5 * feat(tui): drop the all-agents log; land a run on its first conversation No view shows every agent's lines at once any more. The monitor's first row is a line of how far the epic has got rather than a node, and tab/shift+tab round only the conversations running and the outworlders. A run lands on the agent of its first conversation as it opens, with its task put there; before then the interface's own (home) transcript is up. A question part of no conversation is shown, and answered, on the view being read; /stop and /afk work from any view. Co-Authored-By: Claude Opus 5.5 * fix(tui): download one epic at a time, mark here by directory, read usage of its days only Co-Authored-By: Claude Opus 5.5 * feat(tui): read an epic back as its graph, sessions and transcripts `/epics` rows now say an epic's agents, spend, duration and backup targets, as the web lists them. An epic's sheet gains a `graph` row: its agents drawn as the monitor draws them, turns and handovers read back from its logs, its sessions under each agent, and the tree of flows it called; enter on a session reads what it did, searchable. The graph's button and the monitor's `ctrl+e` (or a click on `Exomyth` above it) open Exomyth, never for a chat. The transcript reader moves from `hmz.web.runs` into `Epics.transcripts`, which the web and the terminal both ask. Co-Authored-By: Claude Opus 5.5 * feat(tui): daemons page, /epics @daemon and an m menu on every list The TUI reaches other machines' daemons as the web page does: a Daemons page in /settings lists Local and every SSH daemon with how it stands, adds, edits, imports, connects and removes them; `/epics @` (or the list's Daemon… button) lists that daemon's epics through its own web interface, each exported here, backed up there or copied. `m` on a row of epics, flows, flowverses or daemons opens that row's menu, the keyboard equivalent of the web page's right-click menu. A chat epic offers no Exomyth. What the daemons are and how one is reached moves from hmz.web into hmz.runtime.doing.daemons (Hmz.daemons), so both frontends share it; the web routes keep their behaviour. Co-Authored-By: Claude Opus 5.5 * feat: profile a flow's own Python apart from the programs its agents start `--profile` is split into `--profile-flow` and `--profile-environment`, and `profile` into `profile_flow` and `profile_environment` on `hmz.run`, `Runner`, `Epic`, the daemon's `start`, run status, `Ran`, settings and the web API. The environment is the psutil profile it was (`profile.jsonl`). The flow is sampled with pyinstrument on the thread and task it runs in, written as `flow-profile.jsonl` (`t`, `dt`, `stack` per stretch), and drawn as a `python` process under the epic: one `flow` track of `sample` slices, each frame nested in what called it, in the Exomyth timeline and the Chrome trace. The TUI's single switch now drives `profile_environment` until its own controls land; the web page is left to its own change. Co-Authored-By: Claude Opus 5.5 * style: sort imports after merging the profiling frontends Co-Authored-By: Claude Opus 5.5 * docs: end the web walkthrough's chat with /clear, and name all nine settings pages Co-Authored-By: Claude Opus 5.5 * feat(tui): batch apply settings to other daemons, and start or follow an epic on one The daemons page offers Batch apply, writing this machine's general, fallback, runtimes and backup settings to the daemons picked as the web page's Batch apply does. /epics @ starts a new epic there with n, and follows one going there from its menu, through that machine's web interface (hmz.daemon.link.remote over Remotes.followed). Co-Authored-By: Claude Opus 5.5 * test(daemon): a remote link asks that interface and is told its stream Co-Authored-By: Claude Opus 5.5 * test: type the stand-in URL striker Co-Authored-By: Claude Opus 5.5 * refactor(tui): reach batch apply and refusals through the layers the terminal may name Co-Authored-By: Claude Opus 5.5 * test(tui): read a stopped epic's row whichever line it wraps on Co-Authored-By: Claude Opus 5.5 * fix(web): find a chat between turns as the epic going, not only one running A chat whose turn is done is listed done, so #/live looked for a running epic and never named it; it now takes the epic a host holds first. Co-Authored-By: Claude Opus 5.5 * fix: settle what review found in the batch - a new epic started on another daemon from the terminal carries the profiling and backup that directory remembers, rather than writing them off - the terminal reaches other daemons through one reach, however many threads ask first - a download from a backup lets go of its sheet whatever goes wrong - a new chat from the web replaces the chat a host holds, not merely the newest epic, and starts once that one has unwound - the epic's page says which of its parts are profiled, and nothing of the old flag Co-Authored-By: Claude Opus 5.5 * docs(tui): a chat lands on its conversation, read without a reading line Co-Authored-By: Claude Opus 5.5 * fix: let the docs build, and read a pasted path whichever line it wraps onto A line of the backup download's reference began with , which VitePress took for an HTML block; a long temporary path wrapped the pasted one on CI. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 (cherry picked from commit d863c64d80a27a13b42945d1b5d03f0178db1ce2) --- .../theme/components/HmzTimeline.vue | 6 +- .../components/user-after/SpecReader.vue | 3 +- docs/features/steering.md | 4 +- docs/reference/cli.md | 8 +- docs/reference/daemon.md | 13 +- docs/reference/files.md | 3 +- docs/reference/flows.md | 2 +- docs/reference/sdk.md | 34 +- docs/reference/settings.md | 22 +- docs/reference/tracing.md | 84 +- docs/reference/tui.md | 414 ++- docs/reference/web.md | 14 +- docs/tapes/profiling.tape | 8 +- docs/tapes/stage.py | 2 +- docs/user/afk.md | 37 +- docs/user/backups.md | 2 + docs/user/btw.md | 37 +- docs/user/ci.md | 2 +- docs/user/completion.md | 6 +- docs/user/conversations.md | 95 +- docs/user/export.md | 13 +- docs/user/first-run.md | 21 +- docs/user/installation.md | 2 +- docs/user/leaving.md | 2 +- docs/user/monitor.md | 54 +- docs/user/questions.md | 6 +- docs/user/remote-execution.md | 5 +- docs/user/settings.md | 104 +- docs/user/steering.md | 15 +- docs/user/stopping.md | 22 +- docs/user/tracing.md | 49 +- docs/user/troubleshooting.md | 2 +- docs/user/unattended.md | 3 +- docs/user/web.md | 65 +- pyproject.toml | 1 + specs/cli.md | 11 +- specs/daemon.md | 5 +- specs/flows.md | 3 +- specs/runtime/SPEC.md | 34 +- specs/runtime/doing.md | 50 +- specs/runtime/tracing.md | 37 +- specs/runtime/watching.md | 17 + specs/tui.md | 179 +- specs/web.md | 48 +- src/hmz/cli/__init__.py | 3 +- src/hmz/daemon/link.py | 90 +- src/hmz/runtime/doing/core.py | 48 +- src/hmz/runtime/doing/daemons.py | 1132 +++++++ src/hmz/runtime/doing/epics.py | 86 +- src/hmz/runtime/doing/hosting.py | 31 +- src/hmz/runtime/doing/running.py | 9 +- src/hmz/runtime/epic.py | 187 +- src/hmz/runtime/exporting.py | 22 +- src/hmz/runtime/runner.py | 43 +- src/hmz/runtime/settings.py | 46 +- src/hmz/runtime/tracing/chrome.py | 48 +- src/hmz/runtime/tracing/collector.py | 35 +- src/hmz/runtime/tracing/profile.py | 217 +- src/hmz/runtime/tracing/timeline.py | 29 +- src/hmz/{web => runtime/watching}/notices.py | 138 +- src/hmz/tui/afar.py | 206 ++ src/hmz/tui/app.py | 714 ++-- src/hmz/tui/complete.py | 11 +- src/hmz/tui/epic.py | 457 +++ src/hmz/tui/exomyth.py | 857 ++++- src/hmz/tui/flows.py | 140 +- src/hmz/tui/monitoring.py | 472 ++- src/hmz/tui/pick.py | 3013 +++++++++++++---- src/hmz/tui/settings.py | 147 +- src/hmz/tui/stored.py | 289 ++ src/hmz/tui/usage.py | 240 ++ src/hmz/web/__init__.py | 5 +- src/hmz/web/daemons.py | 231 +- src/hmz/web/flows.py | 8 +- src/hmz/web/held.py | 3 +- src/hmz/web/notifications.py | 82 +- src/hmz/web/places.py | 5 +- src/hmz/web/remote.py | 677 +--- src/hmz/web/runs.py | 207 +- src/hmz/web/site.py | 15 +- src/hmz/web/static/app.js | 2 + src/hmz/web/static/epic.js | 23 +- src/hmz/web/static/notifications.js | 45 +- src/hmz/web/static/sidebar.js | 68 +- src/hmz/web/static/style.css | 13 +- src/hmz/web/static/transcript.js | 28 +- src/hmz/web/static/views/epic.js | 92 +- src/hmz/web/static/views/exomyth/flame.js | 3 +- src/hmz/web/static/views/exomyth/model.js | 2 - src/hmz/web/static/views/exomyth/timeline.js | 36 +- src/hmz/web/static/views/exomyth/view.js | 8 - src/hmz/web/static/views/flows.js | 51 +- src/hmz/web/static/views/new.js | 41 +- src/hmz/web/verses.py | 23 +- tests/integration/doubles_tui.py | 91 +- tests/integration/test_core_profiling.py | 71 + tests/integration/test_tui_attach.py | 31 +- tests/integration/test_tui_backup.py | 47 +- tests/integration/test_tui_chat.py | 128 + tests/integration/test_tui_daemons.py | 204 ++ tests/integration/test_tui_epics.py | 236 +- tests/integration/test_tui_exomyth.py | 147 +- tests/integration/test_tui_flows.py | 60 + tests/integration/test_tui_monitor.py | 112 +- tests/integration/test_tui_notifications.py | 122 + tests/integration/test_tui_runs.py | 77 +- tests/integration/test_tui_settings.py | 46 +- .../integration/test_web_daemons_together.py | 33 +- tests/integration/test_web_notices.py | 39 +- tests/integration/test_web_notifications.py | 2 +- tests/integration/test_web_remote.py | 2 +- tests/integration/test_web_runs.py | 22 +- tests/integration/test_web_site.py | 20 + tests/system/test_tui_ralph_loop.py | 2 +- tests/system/web/doubles_browser.py | 23 + tests/system/web/test_web_exomyth.py | 71 +- .../web/test_web_notification_centre.py | 74 + tests/system/web/test_web_page.py | 222 +- tests/system/web/test_web_places.py | 8 +- tests/system/web/test_web_scoping.py | 8 +- tests/system/web/test_web_verses.py | 14 +- tests/unit/cli/test_exec.py | 6 +- tests/unit/daemon/test_link.py | 54 +- tests/unit/runtime/doing/test_core.py | 34 +- tests/unit/runtime/doing/test_daemons.py | 197 ++ tests/unit/runtime/doing/test_epics.py | 75 +- tests/unit/runtime/doing/test_hosting.py | 46 +- tests/unit/runtime/doing/test_running.py | 5 +- tests/unit/runtime/test_epic.py | 103 +- tests/unit/runtime/test_runner.py | 16 +- tests/unit/runtime/test_settings.py | 36 +- tests/unit/runtime/tracing/test_chrome.py | 19 +- tests/unit/runtime/tracing/test_collector.py | 21 +- tests/unit/runtime/tracing/test_profile.py | 93 +- tests/unit/runtime/tracing/test_timeline.py | 30 +- .../{web => runtime/watching}/test_notices.py | 95 +- tests/unit/tui/test_app.py | 3 +- tests/unit/tui/test_complete.py | 2 +- tests/unit/tui/test_exomyth.py | 159 +- tests/unit/tui/test_monitoring.py | 106 + tests/unit/tui/test_pick_acts.py | 26 + tests/unit/tui/test_pick_does.py | 38 +- tests/unit/tui/test_pick_lines.py | 16 +- tests/unit/tui/test_settings.py | 3 + tests/unit/web/test_remote.py | 27 - tests/unit/web/test_runs.py | 125 +- tests/unit/web/test_verses.py | 39 + uv.lock | 46 + 148 files changed, 11842 insertions(+), 3412 deletions(-) create mode 100644 src/hmz/runtime/doing/daemons.py rename src/hmz/{web => runtime/watching}/notices.py (66%) create mode 100644 src/hmz/tui/afar.py create mode 100644 src/hmz/tui/epic.py create mode 100644 src/hmz/tui/stored.py create mode 100644 src/hmz/tui/usage.py create mode 100644 tests/integration/test_core_profiling.py create mode 100644 tests/integration/test_tui_chat.py create mode 100644 tests/integration/test_tui_daemons.py create mode 100644 tests/integration/test_tui_notifications.py create mode 100644 tests/unit/runtime/doing/test_daemons.py rename tests/unit/{web => runtime/watching}/test_notices.py (69%) create mode 100644 tests/unit/tui/test_pick_acts.py delete mode 100644 tests/unit/web/test_remote.py create mode 100644 tests/unit/web/test_verses.py diff --git a/docs/.vitepress/theme/components/HmzTimeline.vue b/docs/.vitepress/theme/components/HmzTimeline.vue index b36d73d3..e0b7df1f 100644 --- a/docs/.vitepress/theme/components/HmzTimeline.vue +++ b/docs/.vitepress/theme/components/HmzTimeline.vue @@ -316,7 +316,7 @@ const scene = useScene({ cam.shot(l.shots.push, T3, 1.4) tl.to(one('.divider'), { autoAlpha: 1, duration: 0.01 }, T3 + 0.4) tl.to(one('.divider-line'), { drawSVG: '100%', duration: 0.8, ease: 'cine' }, T3 + 0.4) - // Only a run started with `--profile` records its programs. + // Only a run started with `--profile-environment` records its programs. tl.fromTo(one('.profile'), { autoAlpha: 0, x: 14 }, { autoAlpha: 1, x: 0, duration: 0.5, ease: 'back.out(1.8)' }, T3 + 0.5) tl.fromTo(q('.profile rect'), { strokeWidth: 1.2 }, { strokeWidth: 3, duration: 0.2, yoyo: true, repeat: 1, ease: 'power2.out' }, T3 + 0.9) STARTED.forEach((link, k) => { @@ -425,8 +425,8 @@ const scene = useScene({ - - hmz exec … --profile + + hmz exec … --profile-environment diff --git a/docs/.vitepress/theme/components/user-after/SpecReader.vue b/docs/.vitepress/theme/components/user-after/SpecReader.vue index 01a59d67..0f9fa2fe 100644 --- a/docs/.vitepress/theme/components/user-after/SpecReader.vue +++ b/docs/.vitepress/theme/components/user-after/SpecReader.vue @@ -53,7 +53,8 @@ const EXAMPLES = [ const USAGE = [ 'usage: hmz exec [-h] -f FLOW [-a ROLE=SPEC[,...]] [-e ROLE=SPEC[,...]]', - ' [-p KEY=VALUE[,...]] [--profile] [--resume] [--json]', + ' [-p KEY=VALUE[,...]] [--profile-flow] [--profile-environment]', + ' [--resume] [--json]', ' task', ] diff --git a/docs/features/steering.md b/docs/features/steering.md index 4cff7f99..6e5d73b6 100644 --- a/docs/features/steering.md +++ b/docs/features/steering.md @@ -19,8 +19,8 @@ import { withBase } from 'vitepress'
-- **To the agent on your screen.** Reading every agent at once, it goes to whichever has a turn - open. See [conversations](/user/conversations). +- **To the agent on your screen.** Reading an agent, it goes to whichever of its + conversations has a turn open. See [conversations](/user/conversations). - **Never dropped.** A line typed between turns waits on the pin for the next turn to start. - **Shared.** Everybody [reading the run](/features/daemon#several-people-on-one-run) sees each line waiting, marked with who said it. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 72ccaa4c..36a37197 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -111,7 +111,8 @@ with no interface. The run's [outworlder](#nobody-is-at-the-prompt) is always aw ```text usage: hmz exec [-h] -f FLOW [-a ROLE=SPEC[,...]] [-e ROLE=SPEC[,...]] - [-p KEY=VALUE[,...]] [--profile] [--resume] [--json] + [-p KEY=VALUE[,...]] [--profile-flow] [--profile-environment] + [--resume] [--json] [--backup NAME] task ``` @@ -124,7 +125,8 @@ usage: hmz exec [-h] -f FLOW [-a ROLE=SPEC[,...]] [-e ROLE=SPEC[,...]] | `-a`, `--agents` | [``](#writing-an-agent) list | 0‥n, merged | none | One agent per agent role. | | `-e`, `--envs` | [``](#writing-an-environment) list | 0‥n, merged | none | One environment per environment role. | | `-p`, `--params` | [``](#writing-params) list | 0‥n, merged | the flow's defaults | Fields of the flow's `FlowParams`, and, as [`budget.`](#writing-a-budget), what the run may spend: **required** except for `chat`. | -| `--profile` | flag | 0‥1 | off | [Profile](/reference/tracing#profiling-a-run) the programs the run's agents start, as well as tracing them. | +| `--profile-flow` | flag | 0‥1 | off | [Profile](/reference/tracing#profiling-a-run) the flow's own Python, as well as tracing the run. | +| `--profile-environment` | flag | 0‥1 | off | [Profile](/reference/tracing#profiling-a-run) the programs the run's agents start, as well as tracing them. | | `--resume` | flag | 0‥1 | off | [Pick up](#picking-a-run-up) the newest run of this flow here. | | `--json` | flag | 0‥1 | off | Write the run as [NDJSON](#ndjson) on stdout. | | `--backup` | a [backup target](/reference/settings#backup) name, or `none` | 0‥n, merged | `backup.defaults` | Where the epic is [backed up](/user/backups); `none` anywhere: nowhere. A name settings has none of exits `2`. Once the run has said how it went, `hmz exec` waits up to 60 s for the epic's backups, saying `hmz exec: backing the epic up` on stderr, and says where it left one. | @@ -902,7 +904,7 @@ Every command is a shell around [`hmz.sdk.Hmz`](/reference/sdk#hmz): | --- | --- | | `hmz exec ` | [`Hmz().exec(argv)`](/reference/sdk#hmz-exec) | | reading an `hmz exec` line | [`Hmz().read(argv)`](/reference/sdk#hmz-read) → `Line` | -| `hmz exec` without the line | [`Hmz().run(flow, task, agents=…, envs=…, params=…, budget=…, profile=…, resume=…)`](/reference/sdk#hmz-run) | +| `hmz exec` without the line | [`Hmz().run(flow, task, agents=…, envs=…, params=…, budget=…, profile_flow=…, profile_environment=…, resume=…)`](/reference/sdk#hmz-run) | | `hmz` (as a frontend of held runs) | [`Daemons().host().link()`](/reference/sdk#daemons) | `hmz web` is a frontend of held runs as `hmz` is; in Python it is `hmz.web.serve()`, which is diff --git a/docs/reference/daemon.md b/docs/reference/daemon.md index 899855c1..daf6fb04 100644 --- a/docs/reference/daemon.md +++ b/docs/reference/daemon.md @@ -281,7 +281,7 @@ An exception inside a request is refused with `str(exception)` or its type name. | `do` | Fields (type, default) | Answer | Refusals | | --- | --- | --- | --- | | `hello` | `workspace` (str: the daemon hands the frontend to its host; not read by the host), `name` (str, `""`: `@`), `kind` (str, `"sdk"`), `replay` (bool, `true`; only a literal `false` disables) | `client` | `this frontend has already said hello`, `the frontend went while it was saying hello`, `this host has closed` | -| `start` | `flow` (str, required), `task` (str), `agents` (`{role: -a spec}`), `envs` (`{role: -e spec}`), `params` (object), `budget` (Budget JSON), `profile` (bool; whether the run is [profiled](/reference/tracing#profiling-a-run)), `resume` (`false`, `true`, or an epic path) | `run` (int) | `a flow to start is named`, `a flow is already running`, `a flow is already starting`, `this host has closed`, any [`Refused`](/reference/cli#what-is-refused-before-anything-runs) message. A run that is only *stopping* does not block `start`. | +| `start` | `flow` (str, required), `task` (str), `agents` (`{role: -a spec}`), `envs` (`{role: -e spec}`), `params` (object), `budget` (Budget JSON), `profile_flow`, `profile_environment` (bool; whether the run's flow, and the programs its agents start, are [profiled](/reference/tracing#profiling-a-run)), `resume` (`false`, `true`, or an epic path) | `run` (int) | `a flow to start is named`, `a flow is already running`, `a flow is already starting`, `this host has closed`, any [`Refused`](/reference/cli#what-is-refused-before-anything-runs) message. A run that is only *stopping* does not block `start`. | | `say` | `text` (str, required), `to` (str, `""`) | — | `a line says something`, `no flow is running to say it to` (also while stopping), ` is 's` (for `to: "outworlder:"`) | | `answer` | `question` (id), `text` (str, required; a number `1‥n` that is not itself an option picks that option) | — | `an answer says something`, `already answered by `, `no question is waiting`, ` is 's` | | `stop` | — | — | `the flow is already stopping: it is closing out the turn it was in`, `no flow is running, so there is nothing to stop` | @@ -342,7 +342,7 @@ first (counted in `elided`), the newest always kept. Every record but `printed` | `type` | Fields | | --- | --- | -| `started` | `flow`, `ref`, `task`, `by` (frontend name), `client`, `roles` (declared agent roles the line fills), `names` (`{role: agent name}`: what the agent filling each is called, which its conversations are keyed by), `outworlders` (runtime-filled roles), `agents` (`{role: spec}`), `envs` (`{role: spec}`), `params` (object), `budget` (Budget JSON), `profile` (bool), `resume` (`""`, or `str()` of what was given: `"True"`, an epic path), `began` (host monotonic), `at` (Unix time) | +| `started` | `flow`, `ref`, `task`, `by` (frontend name), `client`, `roles` (declared agent roles the line fills), `names` (`{role: agent name}`: what the agent filling each is called, which its conversations are keyed by), `outworlders` (runtime-filled roles), `agents` (`{role: spec}`), `envs` (`{role: spec}`), `params` (object), `budget` (Budget JSON), `profile_flow`, `profile_environment` (bool), `resume` (`""`, or `str()` of what was given: `"True"`, an epic path), `began` (host monotonic), `at` (Unix time) | | `opened` | `role` (the role its agent fills), `key` (`/`), `agent` (the agent's own name, such as `NeiKos496`), `cli`, `model`, `counts` (sorted token kinds the backend reports), `forks` (bool), `person` (bool), `kept` (directory of the session's logs; `""` for a person or the CLI's own home), `env`, `harness`, `mono`, `wall` (Unix time) | | `event` | `key`, `session` (`key` where the event names a conversation, else `""`), `agent`, `cli`, `model`, `ident` (backend's conversation id, `""` until named), `kind`, `text`, `whose`, `tokens`, `spent`, `at`, `mono`. Same fields as [`hmz exec --json`](/reference/cli#ndjson). | | `asked` | `question` (`q`, host-wide), `role` (default `outworlder`), `text`, `options` (list), `mode` (`ask` where there are options or a turn is open, else `listen`), `key` (`/` of the one conversation with a turn open, else `""`) | @@ -478,9 +478,18 @@ def attach(kind: str, name: str = "", *, workspace=None) -> Link: ... def linked(host: Host, name: str = "", kind: str = "tui", *, replay: bool = True) -> Link: ... def reached(at: Path, workspace: str, name: str = "", kind: str = "sdk", *, replay: bool = True) -> Link: ... +def remote(remotes: Remotes, daemon: str, workspace: str) -> Link: ... Hmz, Host, Refused # hmz.runtime's, fetched on access ``` +`hmz.daemon.link.remote` reaches the runs of a workspace on another machine through that +machine's `hmz web`, reached over ssh as `/epics @` reaches it: each request is posted to +`/api/held/` with the workspace (a refusal answered `{"ok": false, "why": …}`, raised as +`Refused`), and its `/api/held/stream` is told to the link -- every `record` and `standing` +event's data, then `gone` where the stream says `gone` or `again` or ends. The link's `client` +is the one that interface says it is; `OSError` where it says nothing within 30 s. `close()` +stops following. + `attach` is how `hmz` and `hmz web` reach runs held apart from them: it finds the host holding the workspace's runs, or starts the daemon and one where none is, and links to it as `kind`, tried **3** times **0.5 s** apart while linking raises `OSError`, the last of which it raises. A diff --git a/docs/reference/files.md b/docs/reference/files.md index 2cf84e99..dd3886f7 100644 --- a/docs/reference/files.md +++ b/docs/reference/files.md @@ -284,7 +284,8 @@ One run ([Tracing › Epics](/reference/tracing#epics) has every schema). `_.jsonl` | one per flow call | [records](/reference/tracing#records-of-called-flows) | | `resume.jsonl` | resumable runs only; compacted via `.resume.jsonl..new` + fsync + rename, then appended `O_APPEND` | [journal](/reference/flows#journal) | -| `profile.jsonl` | profiled runs only | [profile](/reference/tracing#profile-jsonl) | +| `profile.jsonl` | runs whose environment was profiled | [profile](/reference/tracing#profile-jsonl) | +| `flow-profile.jsonl` | runs whose flow was profiled | [flow profile](/reference/tracing#flow-profile-jsonl) | | `host.log` | runs held by a [host process](/reference/daemon#files) only; appended (`0600`), never rotated | that process's descriptors 1 and 2 while the run is the one it holds: output of the CLIs the run started, and the carrier's failures | | `.held` | empty, `0600`; `flock`ed exclusively by the process running the run until `ended` is written | a run whose `.held` is locked is still going, and is not [picked up](/reference/cli#picking-a-run-up) | | `sessions//…` | by the CLI itself, redirected | the CLI's own layout | diff --git a/docs/reference/flows.md b/docs/reference/flows.md index e14404df..a72f0910 100644 --- a/docs/reference/flows.md +++ b/docs/reference/flows.md @@ -1785,7 +1785,7 @@ Read [Security](/user/security) before running any. ```sh hmz exec -f [-a =[,…]]… [-e =[,…]]… [-p =[,…]]… - [--profile] [--resume] [--json] + [--profile-flow] [--profile-environment] [--resume] [--json] ``` `-a`, `-e` and `-p` may each be repeated; every occurrence is a comma list. A comma diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index 94266d78..6d03c5ee 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -113,14 +113,15 @@ def run( envs: Mapping[str, str | EnvDriver] | Iterable[EnvSpec] = (), params: Mapping[str, Any] | FlowParams | None = None, budget: Budget | Mapping[str, Any] | None = None, - profile: bool = False, + profile_flow: bool = False, + profile_environment: bool = False, resume: bool | str | os.PathLike[str] = False, outworlder: OutworlderDriver | None = None, ) -> Run ``` -Equivalent to `Run(self.runner(flow, agents=…, envs=…, params=…, budget=…, profile=…, resume=…), task, -outworlder=outworlder)`. Nothing starts. Where each agent's harness runs is not a parameter: +Equivalent to `Run(self.runner(flow, agents=…, envs=…, params=…, budget=…, profile_flow=…, +profile_environment=…, resume=…), task, outworlder=outworlder)`. Nothing starts. Where each agent's harness runs is not a parameter: it is the `affinity` of the [runtime](#runtimes) its work is on. | Parameter | Accepts | Default | @@ -131,7 +132,8 @@ it is the `affinity` of the [runtime](#runtimes) its work is on. | `envs` | `{role: spec}` with an [`-e` spec](/reference/cli#writing-an-environment) without `=`, or an `EnvDriver`; or an iterable of `EnvSpec`. | `()` | | `params` | A mapping (strings read as `-p` reads them) or an instance of the flow's `FlowParams`. `None`: defaults. | `None` | | `budget` | A [`Budget`](/reference/flows#what-a-run-may-spend), or a mapping validated by `Budget.model_validate` (`{"cost": 5}`, `{"duration": 3600}`, `{"duration": "PT1H"}`; `"1h"` is **not** accepted). `None`: `Budget(cost=inf)` for `chat`, else refused. | `None` | -| `profile` | Whether to [profile](/reference/tracing#profiling-a-run) the programs the run's agents start, as well as tracing them; as `--profile`. | `False` | +| `profile_flow` | Whether to [profile](/reference/tracing#profiling-a-run) the flow's own Python, as well as tracing the run; as `--profile-flow`. | `False` | +| `profile_environment` | Whether to [profile](/reference/tracing#profiling-a-run) the programs the run's agents start, as well as tracing them; as `--profile-environment`. | `False` | | `resume` | `False`: from the top. `True`: the newest resumable epic of this flow here. A path: that epic. | `False` | | `outworlder` | The driver filling `Outworlder` roles (e.g. `fakes.FakeOutworlder`). `None`: always away, as under `hmz exec`. | `None` | @@ -156,8 +158,8 @@ except Refused as why: ### `Hmz.runner` {#hmz-runner} ```python -def runner(self, flow, *, agents=(), envs=(), params=None, budget=None, profile=False, - resume=False) -> Runner +def runner(self, flow, *, agents=(), envs=(), params=None, budget=None, profile_flow=False, + profile_environment=False, resume=False) -> Runner ``` Parameters and refusals as [`run`](#hmz-run), without `task` and `outworlder`. Returns the @@ -180,8 +182,8 @@ hmz = Hmz() line = hmz.read(["-f", "ralph_loop", "-a", "agent=claude/claude-opus-5:high", "-p", "budget.duration=6h,budget.cost=50", "fix the build"]) run = hmz.run(line.flow, line.task, agents=line.agents, envs=line.envs, - params=line.params, budget=line.budget, profile=line.profile, - resume=line.resume) + params=line.params, budget=line.budget, profile_flow=line.profile_flow, + profile_environment=line.profile_environment, resume=line.resume) ``` ### `Hmz.exec` {#hmz-exec} @@ -213,7 +215,7 @@ One run of one flow. Made by [`Hmz.run`](#hmz-run). Constructing one starts noth | `task` | `str` | | | `declaration` | [`Declaration`](#declaration) | What the flow declares. | | `budget` | `Budget` | What the run may spend. | -| `profile` | `bool` | Whether the run is [profiled](/reference/tracing#profiling-a-run) as well as traced. | +| `profile_flow`, `profile_environment` | `bool` | Whether the run's flow, and the programs its agents start, are [profiled](/reference/tracing#profiling-a-run) as well as traced. | | `usage` | `hmz.flows.Usage` | Spent so far: `duration: timedelta`, `cost: float`, `output_tokens: int`. | | `agents` | `tuple[AgentBase, ...]` | The coganchor agent behind each **open** session, oldest first. Empty for fake drivers. | | `epic` | `Path \| None` | The epic directory, once started. | @@ -390,7 +392,8 @@ places tried after it, in order. | `foreign(epic)` | `str` | Why an imported run cannot be picked up here (its workspace is not at that path, or lacks the commit its bundle recorded), or `""`. | | `state(epic, flow: str = "")` | `dict[str, Any]` | A resumable flow's kept `ctx.state`: the run's own flow, or another by canonical ref. | | `traced(epic, *, output=None, start=None, end=None)` | `tuple[Path, dict]` | Gathers the run's own sessions into a Chrome trace. Default `output`: the epic's `traces/`, named for the moment. `start`/`end`: any wording `dateparser` reads. [Tracing](/reference/tracing#from-python). | -| `trace(*, sessions=None, agents=None, output=None, start=None, end=None, profile=None, kept=None)` | `dict` | The same collector for any sessions. `sessions=None`: every session of the workspace; empty iterable: none. `kept`: directories of kept sessions to read (default: everywhere humanize keeps them). | +| `trace(*, sessions=None, agents=None, output=None, start=None, end=None, profile=None, flow_profile=None, kept=None)` | `dict` | The same collector for any sessions. `sessions=None`: every session of the workspace; empty iterable: none. `kept`: directories of kept sessions to read (default: everywhere humanize keeps them). | +| `transcripts(epic)` | `list[dict]` | What each session of the run said and did, read back out of its logs: per session that did anything, `key` (`:`), `agent` and `actions` -- each `category`, `name`, `at`, `start`, `seconds` and `args` -- in order. Writes nothing. | | `timeline(epic)` | `Timeline` | The run as an [Exomyth timeline](/reference/tracing#timeline): `.document`, and `.span(id)` for one span whole. Writes nothing. | | `bundled(epic, *, output, transcript=None)` | `tuple[Path, dict]` | One `.hmzepic` of the whole run, credentials struck out. `output` (required): a file, or a directory to put it in. Returns the path and its manifest. [Exporting a run](/user/export). | | `imported(archive)` | `Path` | Reads a `.hmzepic` back in as an epic of the workspace it ran in, wherever that is. `ValueError` for anything else, an unsafe member, or an epic already here. [Importing a run](/user/export#importing-a-run). | @@ -471,8 +474,8 @@ class Link: def asked(self, said: Mapping[str, Any], *, seconds: float | None = None) -> dict[str, Any] def start(self, flow: str | os.PathLike[str], task: str, *, agents: Mapping[str, Any] | None = None, envs: Mapping[str, Any] | None = None, - params: Any = None, budget: Any = None, profile: bool = False, - resume: bool | str | os.PathLike[str] = False) -> dict[str, Any] + params: Any = None, budget: Any = None, profile_flow: bool = False, + profile_environment: bool = False, resume: bool | str | os.PathLike[str] = False) -> dict[str, Any] def say(self, text: str, *, to: str = "") -> dict[str, Any] def answer(self, question: str, text: str) -> dict[str, Any] def stop(self) -> dict[str, Any] @@ -498,7 +501,7 @@ in this process. | `heard(listener)` | Delivers every message, already-queued ones first, to `listener` on a thread of the link's own, in order. `RuntimeError` if a listener is already set. | | `__iter__` | Yields every message in order until `gone` or `close()`. `RuntimeError` if a listener is set. | | `asked(said, *, seconds=None)` | Sends one [request](/reference/daemon#requests) and waits (`seconds=None`: indefinitely; ignored in process). Returns the reply (`ok: true`). Raises [`Refused`](#refused) with the reply's `why`, or `TimeoutError("no answer to '' in s")`. | -| `start(…)` | `start` request. `agents`/`envs`: specs as `-a`/`-e` after `=`. `params`, `budget`: mappings or models (serialised). `profile`: whether the run is [profiled](/reference/tracing#profiling-a-run). Reply carries `run`. | +| `start(…)` | `start` request. `agents`/`envs`: specs as `-a`/`-e` after `=`. `params`, `budget`: mappings or models (serialised). `profile_flow`, `profile_environment`: whether the run's flow, and the programs its agents start, are [profiled](/reference/tracing#profiling-a-run). Reply carries `run`. | | `say`, `answer`, `stop`, `force`, `afk`, `claim`, `release`, `board`, `aside` | The request of the same name. | | `close()` | Lets go of the host; runs continue. | @@ -552,7 +555,8 @@ declaration order. | `envs` | `tuple[EnvSpec, ...]` | `()` | every `-e`, in order | | `params` | `dict[str, str]` | `{}` | every `-p` but `budget.*`, values unparsed | | `budget` | `Budget \| None` | `None` | every `-p budget.=`, parsed | -| `profile` | `bool` | `False` | `--profile` | +| `profile_flow` | `bool` | `False` | `--profile-flow` | +| `profile_environment` | `bool` | `False` | `--profile-environment` | | `resume` | `bool` | `False` | `--resume` | | `as_json` | `bool` | `False` | `--json` | @@ -614,7 +618,7 @@ them. | Type | Fields | | --- | --- | -| `Ran` | `at: Path`, `flow`, `task`, `workspace`, `began`, `ended` (`""` while running or abandoned), `how` (`done`, `failed`, `stopped`, or `""`), `agents: tuple[Drove, ...]`, `sessions: tuple[Session, ...]`, `called: tuple[Called, ...]`, `resumable: bool`, `ref`, `envs: tuple[str, ...]` (as `-e` spells them, a run's kept in an older spelling included), `params: dict`, `budget: dict \| None`, `picked_up: str` (epic name or `""`), `profile: bool` (whether it was [profiled](/reference/tracing#profiling-a-run)); property `name` | +| `Ran` | `at: Path`, `flow`, `task`, `workspace`, `began`, `ended` (`""` while running or abandoned), `how` (`done`, `failed`, `stopped`, or `""`), `agents: tuple[Drove, ...]`, `sessions: tuple[Session, ...]`, `called: tuple[Called, ...]`, `resumable: bool`, `ref`, `envs: tuple[str, ...]` (as `-e` spells them, a run's kept in an older spelling included), `params: dict`, `budget: dict \| None`, `picked_up: str` (epic name or `""`), `profile_flow: bool`, `profile_environment: bool` (whether its flow, and the programs its agents started, were [profiled](/reference/tracing#profiling-a-run)); property `name` | | `Drove` | `agent` (the role), `backend`, `model`, `effort` (`""` = auto), `provider` (`""` = as local), `name` (what the agent filling it is called; `""` in an epic written before agents had names); property `spec` (`-a` spelling after `=`) | | `Called` | `flow`, `task`, `record` (file in the epic), `began`, `ended`, `how`, `calls: tuple[Called, ...]` | | `Session` | `agent`, `backend`, `provider` (`local` = as local), `ident` (backend's id), `name`, `at`, `flow`, `parent` (forked-from id or `""`), `record`, `where` (kept-session path), `harness` (`local`, `self`, the `:` of a runtime an affinity sent it to, or `""` for work on this machine) | diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 0626d5b4..62ead2fe 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -39,7 +39,8 @@ workspaces: cost: 20.0 output_tokens: null graceful: true - profile: true # profile a run of it started from the TUI + profile_flow: true # profile the flow's Python in a run started from the TUI + profile_environment: true # and the programs its agents start backup: [lab] # the targets an epic of it goes to; [] for none harness: local enable_sentry: false # this machine @@ -54,7 +55,7 @@ daemons: # this machine; Settings › Daemons in the w identity_file: ~/.ssh/id_ed25519 proxy_jump: "" options: [[ServerAliveInterval, "30"]] -notifications: # this machine; Settings › Notifications in the web interface +notifications: # this machine; /settings notifications, and Settings › Notifications on the web kinds: {done: true, failed: true, stopped: true, asked: true, listening: true} system: true spelling: 2 # how envs are spelled; written with every change @@ -88,7 +89,7 @@ backup: # this machine | `btw` | `str`: `cli[@account]/model:effort` | `""`: the running flow's first agent | `/settings` › General › **/btw agent** | the TUI | the next time `/btw` is entered | | `every_epic` | `bool` (only `true` is on) | `false`: this directory's | `ctrl+t` on [`/epics`](/reference/tui#the-runs-that-have-already-happened) | the TUI | the next time `/epics` is opened | | `daemons` | list of `{name, host, user, port, identity_file, proxy_jump, options: [[keyword, value]], alias, config}` | `[]`: Local alone | the web interface's Settings › Daemons, [`POST /api/daemons`](/reference/web#api-settings) and its `import` | `hmz web` | at once: a daemon added is reached the first time the page asks for it, one removed is let go of | -| `notifications` | `{kinds: {done, failed, stopped, asked, listening}: bool, system: bool}` | every kind told, and shown by the system too; only `false` turns one off | the web interface's Settings › Notifications, [`POST /api/notifications/settings`](/reference/web#api-notifications) | `hmz web` | immediately: the next notice of a kind is told or not | +| `notifications` | `{kinds: {done, failed, stopped, asked, listening}: bool, system: bool}` | every kind told, and shown by the system too; only `false` turns one off | [`/settings notifications`](/reference/tui#notifications-page), the web interface's Settings › Notifications, [`POST /api/notifications/settings`](/reference/web#api-notifications) | `hmz`, `hmz web` | immediately: the next notice of a kind is told or not | | `spelling` | `int` | absent: written before `-e` wrote `@` only before a provider | every write of the file, as `2` | every process, as it reads the file | absent, `envs` are read as their old spelling meant and rewritten once; `2`, as written | What error reports contain and exclude is listed on `/settings` › General › **What is sent**. @@ -118,12 +119,14 @@ inherits the setup of the built-in or installed flow it shadows. | `envs` | `{role: str}` | each environment role's [`-e`](/reference/flows#e-environments) spec after `=` | any non-string value makes the mapping read as absent; in a file with no [`spelling`](#spelling), a spec in the spelling `-e` refuses with a hint (`local@/x`, `docker@local/x`, `swarm@local/x`, `apple-container@local/x`, `slurm@local/x`, an unsaved ssh host out of brackets) is rewritten in the one the hint gives, in every workspace, when the file is first read | | `params` | mapping | the params, as JSON | validated through the flow's current params model; a failure reads as not set up | | `budget` | `{duration, cost, output_tokens, graceful}` | the budget as `Budget.model_dump_json` writes it (`duration` ISO 8601, `cost` `"Infinity"` for unlimited) | validated as a `Budget`; a failure reads as absent | -| `profile` | `bool`, written only as `true` | whether a run of the flow started from the TUI is [profiled](/reference/tracing#profiling-a-run), set on the `/flow` menu's **profiling** row | anything but `true` reads as off | +| `profile_flow` | `bool`, written only as `true` | whether a run of the flow started from the TUI or the web profiles the flow's own Python, set on the `/flow` menu's **profile flow** row | anything but `true` reads as off | +| `profile_environment` | `bool`, written only as `true` | whether a run of the flow started from the TUI or the web [profiles](/reference/tracing#profiling-a-run) the programs its agents start, set on the `/flow` menu's **profile environment** row | anything but `true` reads as off | | `backup` | `[str]` | the [backup targets](#backup) an epic of the flow goes to, by name, as **Options → Backup** in `hmz web` saved them; `[]` is none | anything but a list reads as absent, which is the defaults; a name settings has no target of is dropped by whoever starts the epic | **Writing.** Saving `/flow` replaces the flow's whole entry and sets `flow`. `agents` is always -written; `envs`, `params`, `budget`, `profile` and `backup` are written as given, carried over -from the file where the caller passes none, and an empty value (or `profile` off) erases the +written; `envs`, `params`, `budget`, `profile_flow`, `profile_environment` and `backup` are +written as given, carried over from the file where the caller passes none, and an empty value +(or a profiling switch off) erases the key -- but an empty `backup`, which is kept as none. A `harness` key an older humanize wrote is dropped at the next save. @@ -131,8 +134,9 @@ humanize wrote is dropped at the next save. flow), it uses the remembered setup only if it is complete: the remembered agent roles equal the flow's declared agent roles; every required environment role is remembered; the params still validate; a budget is remembered unless the flow runs without one. Otherwise `/flow` -opens to ask. `profile` goes with the rest. `hmz exec` and `Hmz().run` never read `flows`; they -use only their own arguments (`--profile`, `profile=True`). +opens to ask. `profile_flow` and `profile_environment` go with the rest. `hmz exec` and `Hmz().run` never read `flows`; they +use only their own arguments (`--profile-flow`, `--profile-environment`, `profile_flow=True`, +`profile_environment=True`). ### `fallbacks` @@ -254,7 +258,7 @@ outcome is a `backed` line in the epic's `epic.jsonl` (`to`, `ok`, `error`, `why | Missing, unreadable, invalid YAML, not a mapping | read as empty; never prevents a start | | Unknown keys | kept at the top level and in a workspace entry; a flow's entry is replaced whole when `/flow` is saved | | Write | each change is written at once: take `flock(LOCK_EX)` on `.settings.yaml.lock` beside the file; re-read the file; make this one change to what it holds now (one machine key, one workspace's `flow` and one flow's entry, one workspace removed, or the whole of `fallbacks`, `clis`, `backup` or one runtime); write `.settings.yaml..new` (mode `0600` for a new file, the existing file's otherwise), fsync, rename over; release the lock. The writer then holds what it wrote. A file that exists but does not read as a mapping is never written over: it is left for whoever is editing it to correct. | -| Concurrent writers | serialized by the lock. A write changes only what it is about: every machine key, workspace, workspace key and flow entry another writer wrote survives it, whenever this writer read the file. `envs`, `params`, `budget`, `profile` and `backup` not handed to a `/flow` save are carried over from the file as it is at the write. Two writes to the same key or the same flow's entry: the later wins. A writer that cannot open the lock file, gets an error from `flock`, or has waited 30 s for it writes without it. | +| Concurrent writers | serialized by the lock. A write changes only what it is about: every machine key, workspace, workspace key and flow entry another writer wrote survives it, whenever this writer read the file. `envs`, `params`, `budget`, `profile_flow`, `profile_environment` and `backup` not handed to a `/flow` save are carried over from the file as it is at the write. Two writes to the same key or the same flow's entry: the later wins. A writer that cannot open the lock file, gets an error from `flock`, or has waited 30 s for it writes without it. | | Write failure, or a file that does not read as a mapping | a `Settings` holds the change in memory and writes nothing; a fallback, added CLI or runtime is refused with `OSError` | ## `/settings` rows diff --git a/docs/reference/tracing.md b/docs/reference/tracing.md index 34a41df7..3e724052 100644 --- a/docs/reference/tracing.md +++ b/docs/reference/tracing.md @@ -59,6 +59,7 @@ With the lower-level [agent API](/reference/agents), each `AgentBase` has `id` a | `sessions` | `None`: every session. A comma-separated string or an iterable of ids: only those. An **empty** iterable: none. Each id matches a session whose key, key without `:`, or short id **starts with** it. A kept session brings the sub-agents it started. | | `start`, `end` | Any wording [dateparser](https://dateparser.readthedocs.io/) reads (`"3 days ago"`, `"2026-08-09 09:00"`), made timezone-aware. Records outside the window are dropped. Default: unbounded. | | `profile` | A `profile.jsonl` path (or its epic directory), or the records; programs overlapping the window are drawn. | +| `flow_profile` | A `flow-profile.jsonl` path (or its epic directory), or the samples; drawn as one more process, `python`, whose one track `flow` nests each frame in the one that called it. Samples overlapping the window are drawn. | | `kept` | Directories humanize kept sessions in, each holding `/` laid out as that CLI's home, read beside every CLI's own home. | A trace of a **run** (`Epics.traced`) names exactly the ids the run's epic recorded, with no @@ -161,7 +162,8 @@ Every value is a string. The same epic laid out for a viewer of its own rather than for Perfetto: compact, columnar, and without the payloads, which are asked for one span at a time. Built by `hmz.runtime.tracing.timeline.build` from the sessions a [trace](#document) of the epic would -be drawn from, the epic itself, the tree of flows it called and its `profile.jsonl`; served as +be drawn from, the epic itself, the tree of flows it called, its `profile.jsonl` and its +`flow-profile.jsonl`; served as [`GET /api/runs//timeline`](/reference/web#api-runs) and from Python as [`Epics.timeline`](#epics-timeline). Unlike a Chrome trace, **every session is its own process**, under the session that started it. @@ -194,7 +196,7 @@ a process's `kind`. | `strings` | The string table. | | `processes` | Lanes of the chart's first level, by `id` (= index). | | `tracks` | Lanes of the second level, by index, each one process's. | -| `spawns` | Arrows: columns `span` (the span that started it) and `process` (what it started), sorted by span. A sub-agent's comes from the action that started it; every other session's from the flow call that opened it, or the epic's own span. | +| `spawns` | What started each process: columns `span` (the span that started it) and `process` (what it started), sorted by span. A sub-agent's comes from the action that started it; every other session's from the flow call that opened it, or the epic's own span. | | `summary` | `start` (0), `end` (µs; the last moment anything did), counts of `processes`, `tracks`, `spans`, and `categories`: `{: {count, time, in, out}}` (`time` = durations summed, µs; `in`/`out` = tokens). | ### Processes {#timeline-processes} @@ -202,15 +204,15 @@ a process's `kind`. | Field | Is | | --- | --- | | `id` | Its index in `processes`. | -| `kind` | `flow` (the epic and the flows it called; first, where there is an epic), `session` (one agent or sub-agent conversation, ordered by start), or `os` (one program of a [profiled](#profiling-a-run) epic, ordered by start then pid). | -| `name` | `