From ab1b07acce0a6335f2c9085ade688cd1436d54cf Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 10 Jun 2026 01:40:57 +0200 Subject: [PATCH 01/10] ci(#1555): prose-ify legacy tracker tags; add lint:allow escape hatch Clean the legacy backlog so the tracker-tag lint can become a required gate (mara's warn-during-cleanup -> full-tree-enforcement path). Rewrite the ~33 real `closes/see #NNN` provenance refs in doc-comments to prose across hive-forge, hive-c0re, hive-ag3nt, hive-matrix-mcp, hive-sh4re, and add a `lint:allow` line marker to check-issue-refs.sh for genuine non-tracker `#` (a hash-digit heading-detection test input). Tree is now lint-clean; tracker-tag lint ready to promote to required. --- hive-ag3nt/src/forge_notify.rs | 16 ++++++++-------- hive-c0re/src/agent_server.rs | 2 +- hive-c0re/src/broker.rs | 4 ++-- hive-c0re/src/dashboard.rs | 4 ++-- hive-c0re/src/lifecycle.rs | 2 +- hive-c0re/src/rebuild_queue.rs | 2 +- hive-forge/src/body.rs | 2 +- hive-forge/src/client.rs | 8 ++++---- hive-forge/src/main.rs | 8 ++++---- hive-forge/src/verbs/assign.rs | 4 ++-- hive-forge/src/verbs/comments.rs | 6 +++--- hive-forge/src/verbs/diff.rs | 2 +- hive-forge/src/verbs/lint.rs | 6 +++--- hive-forge/src/verbs/list.rs | 2 +- hive-forge/src/verbs/pr_create.rs | 2 +- hive-forge/src/verbs/timeline.rs | 8 ++++---- hive-matrix-mcp/src/handlers.rs | 2 +- hive-matrix-mcp/src/lib.rs | 2 +- hive-matrix-mcp/src/paths.rs | 2 +- hive-matrix-mcp/src/timeline.rs | 2 +- hive-matrix-mcp/src/wake.rs | 2 +- hive-sh4re/src/assets.rs | 2 +- scripts/check-issue-refs.sh | 12 ++++++++++-- 23 files changed, 55 insertions(+), 47 deletions(-) diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index 607b79a3..c5e0f4b4 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -46,7 +46,7 @@ pub async fn run(socket: PathBuf) { let token_path = format!("{state_dir}/forge-token"); // Retry reading the token to handle races where hive-priv provisions the // token after the harness starts, or where a parent-container chown briefly - // makes the file unreadable (see #1304 / #1309). We wait up to + // makes the file unreadable. We wait up to // TOKEN_RETRY_MAX * TOKEN_RETRY_SECS before giving up. let token = { let mut attempts = 0u32; @@ -205,14 +205,14 @@ fn escape_md_headings(body: &str) -> String { /// Strict `CommonMark` ATX-heading detector: 1-6 leading `#`s followed /// by either a space, tab, or end-of-line. Anything tighter (`#tag`, -/// `#123`) is a non-heading line that the renderer will not promote. +/// `#9`) is a non-heading line that the renderer will not promote. fn is_atx_heading(line: &str) -> bool { let hashes = line.bytes().take_while(|&b| b == b'#').count(); if !(1..=6).contains(&hashes) { return false; } // Bare `#` / `##` / ... on its own line, or proper ATX with a - // space/tab after the run of `#`s; anything else (`#tag` / `#123`) + // space/tab after the run of `#`s; anything else (`#tag` / `#9`) // is not a heading. matches!(line.as_bytes().get(hashes), None | Some(b' ' | b'\t')) } @@ -843,11 +843,11 @@ mod tests { #[test] fn escape_md_headings_skips_non_atx_hash_lines() { - // ATX requires a space after the `#`s. Lines like `#tag`, - // `#123`, `#!/bin/bash` are NOT headings — escaping them - // would just add cosmetic noise where the renderer - // wouldn't promote the line in the first place. - let body = "#tag\n#123\n#!/bin/bash\n####### too many hashes\nbody"; + // ATX requires a space after the `#`s. Lines like `#tag`, a + // hash-then-digits run, or `#!/bin/bash` are NOT headings — + // escaping them would just add cosmetic noise where the + // renderer wouldn't promote the line in the first place. + let body = "#tag\n#123\n#!/bin/bash\n####### too many hashes\nbody"; // lint:allow: hash-digit heading test input, not a tracker tag let escaped = escape_md_headings(body); // All four leading `#` lines pass through untouched: too few // (still need space), seven `#`s (over the cap), shebang diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 13ae10a1..c8842d06 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -841,7 +841,7 @@ pub(crate) fn handle_send( let resolved = crate::topology::resolve_recipient(agent, to); // Validate that the resolved recipient is a known local agent or the // special "operator" recipient. Without this check a typo in `to` - // silently queues a message nobody will ever read (issue #1165). + // silently queues a message nobody will ever read. // // Cross-hive messaging (`name@hive` qualified names) is not routed // through the broker — use the Matrix MCP tools for that instead. diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs index 1ad74403..52d67e2c 100644 --- a/hive-c0re/src/broker.rs +++ b/hive-c0re/src/broker.rs @@ -347,7 +347,7 @@ impl Broker { } /// Unacknowledged messages addressed to `recipient`, newest-first. - /// Backs the dashboard's operator inbox (#1469): the operator never + /// Backs the dashboard's operator inbox: the operator never /// `recv`s over an agent socket, so messages to `"operator"` sit in /// the broker with `acked_at IS NULL` until the operator hits "mark /// all read" (which calls [`Broker::mark_all_read`]). This read @@ -1352,7 +1352,7 @@ mod tests { assert_eq!(broker.ack_turn("b").unwrap(), 5); } - /// The #1462 fix: a transient `ping` fired while no `recv` is parked + /// Transient-wake regression guard: a `ping` fired while no `recv` is parked /// must NOT be lost — it's buffered and drained by the next collect. #[test] fn transient_ping_buffered_when_no_receiver_parked() { diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index d5e20fc3..077d4b53 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -1221,8 +1221,8 @@ pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) { }); } -/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox -/// (#1469). Returns messages addressed to `"operator"` that haven't been +/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox. +/// Returns messages addressed to `"operator"` that haven't been /// acked yet (the operator clears them via the existing /// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped /// tokens are validated so the client renders file links like the diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 51d7af3a..011e363b 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -1145,7 +1145,7 @@ async fn set_nspawn_flags( // Make /shared writable by every agent. Containers share host uids (no // PrivateUsers), but each agent is a distinct unix user, so a root-owned // 0755 dir leaves them unable to write — the documented "read/write for - // all agents" contract was broken (#1374). A setgid group would need a + // all agents" contract was broken. A setgid group would need a // pinned GID declared in every container plus all agent users joined to // it (cross-container coordination + a rebuild cascade); instead we use // the /tmp model — sticky world-writable (1777). The sticky bit lets any diff --git a/hive-c0re/src/rebuild_queue.rs b/hive-c0re/src/rebuild_queue.rs index 6d8d6735..394d515f 100644 --- a/hive-c0re/src/rebuild_queue.rs +++ b/hive-c0re/src/rebuild_queue.rs @@ -1224,7 +1224,7 @@ mod tests { kind: QueueKind::Rebuild, agent: "agent-a".to_owned(), source: QueueSource::Approval, - reason: "approval #42 apply commit".to_owned(), + reason: "approval 42 apply commit".to_owned(), parent_id: None, inputs: Vec::new(), approval_id: Some(42), diff --git a/hive-forge/src/body.rs b/hive-forge/src/body.rs index 0025c572..05e39c88 100644 --- a/hive-forge/src/body.rs +++ b/hive-forge/src/body.rs @@ -1,5 +1,5 @@ //! Body-input resolution shared by every verb that posts a body. -//! Matches the bash `resolve_body` helper (#382): exactly one source +//! Matches the bash `resolve_body` helper: exactly one source //! between `--body`, `--body-file`, and piped stdin. Passing both //! `--body` and `--body-file` is a clear error. diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index 3308fc9e..ace4a3b3 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -32,7 +32,7 @@ pub struct Client { pub default_repo: String, /// Global `--json` flag — verbs that have a human-readable /// default path branch on `client.json_mode()` to pick the - /// JSON output shape instead. Closes #421. + /// JSON output shape instead. json_mode: bool, } @@ -131,7 +131,7 @@ impl Client { /// params (`?limit=N&state=open&...`) are preserved. Pages drain /// while the response carries a `Link: rel="next"` header, up to /// `max_pages` (the runaway-loop safety cap). Returns the merged - /// array. Used by `lint` for repo-wide queries (closes #505). + /// array. Used by `lint` for repo-wide queries. pub fn get_json_all(&self, path: &str, max_pages: u32) -> Result> { let sep = if path.contains('?') { '&' } else { '?' }; let mut merged = Vec::new(); @@ -271,8 +271,8 @@ fn read_token() -> Result { } /// Surface non-2xx HTTP responses as anyhow errors with the response -/// body included (matches `curl --fail-with-body`). Closes #353's -/// "silent failures with no clue what went wrong" case. +/// body included (matches `curl --fail-with-body`) — turns +/// silent failures into errors with a clear message. fn check_status(resp: Response, op: &str) -> Result { let status = resp.status(); if status.is_success() { diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index d086a556..4f99bfb1 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -8,7 +8,7 @@ //! Single binary with verb subcommands. Replaces the prior bash //! script (`hive-forge-tools.nix`) so that agents and operators get //! the same error handling, exit codes, and JSON shapes regardless -//! of how the bash mood was that day (closes #280). +//! of how the bash mood was that day. #![warn(missing_docs)] // Clap-derived `Args` structs are intentionally consumed by their @@ -26,7 +26,7 @@ use clap::{Parser, Subcommand}; #[derive(Parser)] #[command( name = "hive-forge", - about = "Forgejo CLI wrapper for hyperhive (closes #280)", + about = "Forgejo CLI wrapper for hyperhive", disable_help_subcommand = true )] struct Cli { @@ -36,7 +36,7 @@ struct Cli { #[arg(short = 'r', long, global = true)] repo: Option, /// Emit JSON output instead of the verb's default human-readable - /// shape, for verbs that support both (closes #421). Verbs whose + /// shape, for verbs that support both. Verbs whose /// only output is already JSON (`issue`, `pr`, etc.) ignore this /// flag — they always print JSON regardless. #[arg(long, global = true)] @@ -100,7 +100,7 @@ enum Verb { Subscription(verbs::subscription::Args), /// List timeline events on an issue or PR (closes, label adds, /// assignments, commit refs, pushes, etc.) — the audit trail - /// `view` + `comments` don't surface (closes #783). + /// `view` + `comments` don't surface. Timeline(verbs::timeline::Args), /// Upload a file as an attachment to an issue. AttachIssue(verbs::attach::IssueArgs), diff --git a/hive-forge/src/verbs/assign.rs b/hive-forge/src/verbs/assign.rs index 5c9ed713..d257e239 100644 --- a/hive-forge/src/verbs/assign.rs +++ b/hive-forge/src/verbs/assign.rs @@ -1,7 +1,7 @@ //! `assign [--remove]` — add or remove a user from an //! issue/PR's assignee list. Forgejo has no dedicated POST endpoint — -//! we read the current list, mutate, and PATCH the issue back (closes -//! #353's "no such endpoint" trap; matches the bash helper's logic). +//! we read the current list, mutate, and PATCH the issue back (Forgejo +//! has no such endpoint; matches the bash helper's logic). use anyhow::Result; use clap::Args as ClapArgs; diff --git a/hive-forge/src/verbs/comments.rs b/hive-forge/src/verbs/comments.rs index 026bbc1a..6c5bcb6a 100644 --- a/hive-forge/src/verbs/comments.rs +++ b/hive-forge/src/verbs/comments.rs @@ -1,6 +1,6 @@ //! `comments [--limit N | --tail N]` — list comments on an -//! issue or PR. Closes the curl-fallback gap (#418); `--tail` -//! closes the third of the four #694 gaps (paging-for-long-threads +//! issue or PR. Replaces the curl fallback; `--tail` +//! handles the paging-for-long-threads //! awkwardness). //! //! - `--limit N` (default 50, Forgejo's cap) returns the first N @@ -14,7 +14,7 @@ //! on this long thread?" without scrolling through the whole //! history. //! -//! Use the global `--json` flag for JSON output (#421). +//! Use the global `--json` flag for JSON output. use anyhow::Result; use clap::Args as ClapArgs; diff --git a/hive-forge/src/verbs/diff.rs b/hive-forge/src/verbs/diff.rs index a0eb6e82..57f27bbf 100644 --- a/hive-forge/src/verbs/diff.rs +++ b/hive-forge/src/verbs/diff.rs @@ -5,7 +5,7 @@ //! `package-lock.json`, …) is collapsed to a single //! `[: contents changed (+N -M, --full for content)]` //! line so a `flake.lock` rev bump doesn't drown the human- -//! authored changes in 5 000 lines of lock churn (#222). The +//! authored changes in 5 000 lines of lock churn. The //! per-file git headers (`diff --git`, `index`, `---`, `+++`, //! and any rename / mode metadata) are suppressed alongside the //! hunks since the placeholder already carries the file path and diff --git a/hive-forge/src/verbs/lint.rs b/hive-forge/src/verbs/lint.rs index af24ffac..ff4dd516 100644 --- a/hive-forge/src/verbs/lint.rs +++ b/hive-forge/src/verbs/lint.rs @@ -1,5 +1,5 @@ //! `lint ` — issue/PR/branch lint queries for triage -//! workflows (closes #505). Replaces ad-hoc curl + jq filtering with +//! workflows. Replaces ad-hoc curl + jq filtering with //! typed commands that always emit JSON via the global `--json` //! (default is a compact one-line-per-item human shape). //! @@ -97,7 +97,7 @@ struct NoReviewerArgs { /// Reviewer login to look for (matches `@` in PR body or /// any comment). Required — defaulting to a specific name would /// bake one deployment's reviewer convention into the binary - /// (mara's nit on #507). + /// (flagged in review). #[arg(long)] reviewer: String, } @@ -177,7 +177,7 @@ fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> { continue; } // Paginate so PRs with >50 comments don't yield false positives - // (argus nit on #507). Same 1000-comment ceiling as elsewhere. + // (flagged in review). Same 1000-comment ceiling as elsewhere. let comments = client.get_json_all( &format!("/repos/{repo}/issues/{number}/comments?limit={PAGE_LIMIT}"), MAX_PAGES, diff --git a/hive-forge/src/verbs/list.rs b/hive-forge/src/verbs/list.rs index 49a78bc9..c8764a10 100644 --- a/hive-forge/src/verbs/list.rs +++ b/hive-forge/src/verbs/list.rs @@ -5,7 +5,7 @@ //! //! Mirrors Forgejo's `GET /repos/{owner}/{repo}/issues` query-string //! filters one-for-one so the mental model carries over. Closes the -//! second of the four #694 gaps (read-side; no boundary concerns — +//! read-side curl-fallback gap (no boundary concerns — //! every agent + the operator queries the issue tracker constantly). use std::fmt::Write as _; diff --git a/hive-forge/src/verbs/pr_create.rs b/hive-forge/src/verbs/pr_create.rs index 97a2b23d..c6cd21eb 100644 --- a/hive-forge/src/verbs/pr_create.rs +++ b/hive-forge/src/verbs/pr_create.rs @@ -8,7 +8,7 @@ //! hint block is filtered out of git's stderr (we print the canonical //! URL ourselves once the API returns). Other git stderr passes //! through. Default behaviour is unchanged: no push unless asked. -//! Closes the auto-push half of #222 per operator decision (opt-in +//! Adds the auto-push path per operator decision (opt-in //! flag). //! //! With `--agit` the PR is opened via Forgejo's `AGit` flow instead of diff --git a/hive-forge/src/verbs/timeline.rs b/hive-forge/src/verbs/timeline.rs index 8677cb8d..bd662e72 100644 --- a/hive-forge/src/verbs/timeline.rs +++ b/hive-forge/src/verbs/timeline.rs @@ -1,7 +1,7 @@ //! `timeline [--limit N]` — list timeline events on an -//! issue or PR. Closes #783 (last piece of the #694 epic: agents kept +//! issue or PR. Fills the gap where agents kept //! falling back to curl for "who closed this?" / "when was this -//! labelled?" archaeology). Composes naturally with `view ` / +//! labelled?" archaeology. Composes naturally with `view ` / //! `comments ` — separate verb keeps the existing shapes stable. //! //! Forgejo's `/issues/{n}/timeline` endpoint returns BOTH the actual @@ -12,7 +12,7 @@ //! //! `--tail N` is a follow-up (the timeline endpoint doesn't expose a //! total-count field so we can't use the count-then-page trick that -//! `comments --tail` lands in #770; future shape probably mirrors +//! `comments --tail` uses; future shape probably mirrors //! `comments --tail` once Forgejo grows a `count` query or we accept //! the trailing-slice cost). @@ -211,7 +211,7 @@ mod tests { //! Tests call `format_event` directly so any new event-type arm //! added in `print_event`'s dispatch is automatically covered by //! the rendering path (no parallel test-side dispatch to keep in - //! sync). Argus on PR #798 🟡: "extract a `format_event(ev) -> + //! sync). A review flagged: "extract a `format_event(ev) -> //! String` helper and test that function directly instead of //! duplicating the logic" — addressed. use super::*; diff --git a/hive-matrix-mcp/src/handlers.rs b/hive-matrix-mcp/src/handlers.rs index 1be0d4a2..23125db7 100644 --- a/hive-matrix-mcp/src/handlers.rs +++ b/hive-matrix-mcp/src/handlers.rs @@ -4,7 +4,7 @@ //! the payload to claude; `Error { message }` becomes the tool-call //! error message claude sees). //! -//! Tool surface mirrors damocles-daemon's v0 set per mara on #548: +//! Tool surface mirrors damocles-daemon's v0 set per the operator's call: //! `send_message`, `send_dm`, `send_reaction`, `send_reply`, `mark_read`, //! `list_rooms`, `list_room_members`, `read_room`. Plus a `ping` for the //! MCP bridge's liveness probe. diff --git a/hive-matrix-mcp/src/lib.rs b/hive-matrix-mcp/src/lib.rs index 8db22f9a..ae57bcd6 100644 --- a/hive-matrix-mcp/src/lib.rs +++ b/hive-matrix-mcp/src/lib.rs @@ -5,7 +5,7 @@ //! The wire protocol between the two binaries lives in [`protocol`]. //! Path helpers (token file, daemon socket) live in [`paths`]. //! -//! Phase 3 of #548. Architecture rationale + tool surface mirror the +//! Phase 3 of the matrix-MCP work. Architecture rationale + tool surface mirror the //! existing `damocles-daemon` (see issue thread for details). pub mod client; diff --git a/hive-matrix-mcp/src/paths.rs b/hive-matrix-mcp/src/paths.rs index 9838aff5..92918dd9 100644 --- a/hive-matrix-mcp/src/paths.rs +++ b/hive-matrix-mcp/src/paths.rs @@ -18,7 +18,7 @@ pub const DEFAULT_HOMESERVER: &str = "http://localhost:8008"; /// Lives under systemd's `RuntimeDirectory=hive-matrix` (a tmpfs path /// that disappears on container restart — fine, because the daemon /// recreates the socket on its own boot) so the agent unix user -/// (post-#658) can bind a socket inside it without root in `/run`. +/// can bind a socket inside it without root in `/run`. pub const DEFAULT_DAEMON_SOCKET: &str = "/run/hive-matrix/socket"; /// Resolve the matrix access-token file path. Override via diff --git a/hive-matrix-mcp/src/timeline.rs b/hive-matrix-mcp/src/timeline.rs index fcb7e425..4a81669f 100644 --- a/hive-matrix-mcp/src/timeline.rs +++ b/hive-matrix-mcp/src/timeline.rs @@ -1,7 +1,7 @@ //! Matrix event handlers: incoming room messages fire a hyperhive //! wake signal so the agent's harness drives a new claude turn. //! -//! Per mara on #548: wake body is a SHORT TEASER, not the full message +//! Per the operator's call: wake body is a SHORT TEASER, not the full message //! (msg stays unread server-side; agent fetches via `read_room`). The //! `wake::format_wake_body` truncates to ~100 chars. //! diff --git a/hive-matrix-mcp/src/wake.rs b/hive-matrix-mcp/src/wake.rs index 2fef492e..48d4ed53 100644 --- a/hive-matrix-mcp/src/wake.rs +++ b/hive-matrix-mcp/src/wake.rs @@ -7,7 +7,7 @@ //! The agent harness's `agent_server` parses it and treats it as a //! `Wake` from the matrix subsystem. //! -//! Per mara's call on #548 phase 3: the body is a SHORT TEASER, not +//! Per the operator's call (phase 3): the body is a SHORT TEASER, not //! the full message — the agent then reads the unmarked event via //! the `read_room` MCP tool. Truncation to ~100 chars keeps the wake //! prompt focused (`forge_notify` embeds longer excerpts because the diff --git a/hive-sh4re/src/assets.rs b/hive-sh4re/src/assets.rs index b5fcc0e1..ca6e239f 100644 --- a/hive-sh4re/src/assets.rs +++ b/hive-sh4re/src/assets.rs @@ -77,7 +77,7 @@ pub fn core_avatar_png() -> PathBuf { /// `$HIVE_ASSETS_DIR/branding/agent-configs.png` — secondary org /// mark for the `agent-configs/` mirror org. Rendered from /// `agent-configs.svg` at asset-build time (was rendered in -/// `hive-c0re/build.rs` before #555). +/// `hive-c0re/build.rs`). #[must_use] pub fn config_org_avatar_png() -> PathBuf { dir().join("branding/agent-configs.png") diff --git a/scripts/check-issue-refs.sh b/scripts/check-issue-refs.sh index 5cec3d3e..8053bed2 100755 --- a/scripts/check-issue-refs.sh +++ b/scripts/check-issue-refs.sh @@ -19,16 +19,24 @@ # overruns) and digit-runs followed by a letter — e.g. hash-route # fragments like #24h. Residual: a pure-numeric short hex (e.g. three # identical digits) trips it — write the six-digit form to dodge. +# +# Escape hatch: a line containing the marker `lint:allow` is exempt. +# Reserve it for genuine `#` that aren't tracker tags — e.g. a +# `#123` markdown-heading example or hash-prefixed test-input data — +# and keep a short reason next to the marker. Don't use it to keep a +# real tracker tag; rewrite those to prose. set -eu pattern='#[0-9]{2,5}([^0-9a-zA-Z]|$)' # `/dev/null` forces grep to always print a filename prefix, even when # xargs hands it a single file. `-r`/`-0` keep it robust to odd paths -# and an empty file list. +# and an empty file list. Lines carrying the `lint:allow` marker are +# dropped (legitimate non-tracker `#`; see the header). hits="$( git ls-files -z '*.rs' '*.nix' '*.js' '*.ts' '*.css' '*.html' \ - | xargs -0 -r grep -nE "$pattern" /dev/null 2>/dev/null || true + | xargs -0 -r grep -nE "$pattern" /dev/null 2>/dev/null \ + | grep -v 'lint:allow' || true )" if [ -n "$hits" ]; then From 5f57ea4ef11abdde0923055b81dd817d5b220880 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 10 Jun 2026 12:38:51 +0200 Subject: [PATCH 02/10] ci(#1555): de-circularize hive-matrix-mcp lib doc (argus nit) Drop 'Phase 3 of the matrix-MCP work' (circular inside the matrix-mcp crate) + the vague issue-thread pointer; keep the self-contained note that it mirrors damocles-daemon's v0 set. --- hive-matrix-mcp/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hive-matrix-mcp/src/lib.rs b/hive-matrix-mcp/src/lib.rs index ae57bcd6..25433b0d 100644 --- a/hive-matrix-mcp/src/lib.rs +++ b/hive-matrix-mcp/src/lib.rs @@ -5,8 +5,8 @@ //! The wire protocol between the two binaries lives in [`protocol`]. //! Path helpers (token file, daemon socket) live in [`paths`]. //! -//! Phase 3 of the matrix-MCP work. Architecture rationale + tool surface mirror the -//! existing `damocles-daemon` (see issue thread for details). +//! Architecture rationale and tool surface mirror the existing +//! `damocles-daemon` v0 set. pub mod client; pub mod paths; From 2157c3ae019e6af234882707753a5894d9e18901 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 10 Jun 2026 01:03:27 +0200 Subject: [PATCH 03/10] feat(#1591): surface pending-login + crashing-agent banner warnings --- hive-c0re/src/coordinator.rs | 36 ++++++++++ hive-c0re/src/crash_watch.rs | 1 + hive-c0re/src/dashboard.rs | 21 +++++- hive-c0re/src/host_stats.rs | 131 +++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 1 deletion(-) diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 69487a03..ff79f063 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -114,6 +114,14 @@ pub struct Coordinator { /// watcher consults both this and the active map before declaring /// a stop deliberate. recent_transient: Mutex>, + /// Timestamps of recent unexpected container crashes, keyed by agent. + /// Fed by `crash_watch` each time it classifies a stop as a crash (so + /// a crash-looping container — which `Restart=on-failure` flips back + /// to running between polls — accumulates one entry per down-transition, + /// not just whatever its point-in-time state happens to be). Read by + /// the dashboard's `agents_crashing` banner warning via + /// `recent_crash_counts`, which prunes entries older than its window. + recent_crashes: Mutex>>, /// Unified wire-facing event channel feeding the dashboard SSE /// stream. Carries broker messages (mirrored from `broker.subscribe` /// by the forwarder task in `main.rs`) and dashboard-only mutation @@ -430,6 +438,7 @@ impl Coordinator { agents: Mutex::new(HashMap::new()), transient: Mutex::new(HashMap::new()), recent_transient: Mutex::new(HashMap::new()), + recent_crashes: Mutex::new(HashMap::new()), dashboard_events, event_seq: AtomicU64::new(0), meta_updates_active: AtomicU64::new(0), @@ -1075,6 +1084,33 @@ impl Coordinator { .collect() } + /// Record an unexpected crash for `agent`. Called by the crash + /// watcher whenever it classifies a container stop as a crash (not an + /// operator action). Append-only here; pruning happens lazily on read + /// in `recent_crash_counts`. + pub fn record_crash(&self, agent: &str) { + self.recent_crashes + .lock() + .unwrap() + .entry(agent.to_owned()) + .or_default() + .push(std::time::Instant::now()); + } + + /// Per-agent count of crashes within the last `window`. Lazily reaps + /// older timestamps and drops agents with none left, so the map stays + /// bounded and only lists agents actively crashing. Powers the + /// dashboard's `agents_crashing` banner warning. + pub fn recent_crash_counts(&self, window: std::time::Duration) -> HashMap { + let now = std::time::Instant::now(); + let mut map = self.recent_crashes.lock().unwrap(); + map.retain(|_, times| { + times.retain(|ts| now.duration_since(*ts) <= window); + !times.is_empty() + }); + map.iter().map(|(k, v)| (k.clone(), v.len())).collect() + } + /// Set a transient state and return a guard that clears it on drop. /// Use this from any path where the surrounding future could be /// cancelled or panic between set and clear (HTTP handlers, spawned diff --git a/hive-c0re/src/crash_watch.rs b/hive-c0re/src/crash_watch.rs index 6a7d89dc..c7cca394 100644 --- a/hive-c0re/src/crash_watch.rs +++ b/hive-c0re/src/crash_watch.rs @@ -95,6 +95,7 @@ fn emit_crash_transitions(coord: &Coordinator, prev: &HashSet, current: continue; } tracing::warn!(agent = %stopped, "container crash detected"); + coord.record_crash(stopped); coord.notify_manager(&hive_sh4re::HelperEvent::ContainerCrash { agent: stopped.clone(), note: Some("container stopped without an operator action".into()), diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 077d4b53..05e8b43f 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -463,6 +463,13 @@ where } } +/// Window over which container crashes count toward the `agents_crashing` +/// banner warning. Wide enough that a crash-looping container (restarted +/// by `Restart=on-failure` every few seconds) keeps the warning lit +/// between flaps, short enough that a single recovered crash clears within +/// minutes. +const CRASH_WARNING_WINDOW: std::time::Duration = std::time::Duration::from_mins(10); + async fn api_state(headers: HeaderMap, State(state): State) -> axum::Json { let host = headers .get("host") @@ -523,6 +530,18 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J .map(QuestionView::from_question) .collect(); + // Banner warnings: host probes (disk) + agent-state (pending logins, + // crashing agents). Built before the response struct because the + // agent-state producer borrows `containers`, which moves in below. + let server_warnings = { + let mut w = crate::host_stats::server_warnings(); + w.extend(crate::host_stats::agent_state_warnings( + &containers, + &state.coord.recent_crash_counts(CRASH_WARNING_WINDOW), + )); + w + }; + axum::Json(StateSnapshot { seq, hostname, @@ -564,7 +583,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J .ok() .filter(|s| !s.is_empty()), peer_hives: parse_peer_hives(), - server_warnings: crate::host_stats::server_warnings(), + server_warnings, }) } diff --git a/hive-c0re/src/host_stats.rs b/hive-c0re/src/host_stats.rs index 61c95d9a..f0ac7dca 100644 --- a/hive-c0re/src/host_stats.rs +++ b/hive-c0re/src/host_stats.rs @@ -13,8 +13,12 @@ //! pressure, a failed unit, …) is a backend-only change — no frontend //! edit. Keep producers cheap; this runs on every `/api/state` assembly. +use std::collections::HashMap; + use serde::Serialize; +use crate::container_view::ContainerView; + /// One server-level warning for the dashboard's top-of-page banner. #[derive(Debug, Clone, Serialize)] pub struct ServerWarning { @@ -65,6 +69,68 @@ pub fn server_warnings() -> Vec { out } +/// Agent-state warnings derived from the live container snapshot the +/// dashboard already holds: agents that need a claude login, and agents +/// that are crashing. Kept separate from [`server_warnings`] (host +/// probes) because the caller owns the container list + crash counts; +/// the dashboard concatenates both into one banner list. +/// +/// `crash_counts` is `Coordinator::recent_crash_counts(window)` — agent → +/// number of crashes inside that window — so a crash-looping agent shows +/// its repeat count rather than a single point-in-time flap. +#[must_use] +pub fn agent_state_warnings( + containers: &[ContainerView], + crash_counts: &HashMap, +) -> Vec { + let mut out = Vec::new(); + + // `needs_login` is already running-gated in `container_view::build_all`, + // so a stopped container never lights this. + let mut pending: Vec<&str> = containers + .iter() + .filter(|c| c.needs_login) + .map(|c| c.name.as_str()) + .collect(); + if !pending.is_empty() { + pending.sort_unstable(); + out.push(ServerWarning { + kind: "pending_logins", + level: "warn", + message: format!( + "{n} agent{plural} need claude login: {list} \ + — run `hivectl login ` to authenticate", + n = pending.len(), + plural = if pending.len() == 1 { "" } else { "s" }, + list = pending.join(", "), + ), + }); + } + + if !crash_counts.is_empty() { + let mut crashing: Vec<(&String, usize)> = + crash_counts.iter().map(|(a, n)| (a, *n)).collect(); + crashing.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0))); + let list = crashing + .iter() + .map(|(a, n)| format!("{a} (×{n})")) + .collect::>() + .join(", "); + out.push(ServerWarning { + kind: "agents_crashing", + level: "crit", + message: format!( + "{n} agent{plural} crashing: {list} \ + — check the container journal (`hivectl logs `)", + n = crashing.len(), + plural = if crashing.len() == 1 { "" } else { "s" }, + ), + }); + } + + out +} + /// Disk usage for the filesystem backing the host nix store — internal to /// the disk-pressure producer above. struct DiskUsage { @@ -122,3 +188,68 @@ fn disk_usage(path: &str) -> Option { used_pct, }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn cv(name: &str, needs_login: bool) -> ContainerView { + ContainerView { + name: name.to_owned(), + container: format!("h-{name}"), + port: 0, + running: true, + needs_update: false, + needs_login, + deployed_sha: None, + pending_reminders: 0, + parent: None, + } + } + + #[test] + fn no_agent_warnings_when_all_healthy() { + let containers = [cv("alice", false), cv("bob", false)]; + assert!(agent_state_warnings(&containers, &HashMap::new()).is_empty()); + } + + #[test] + fn pending_logins_lists_sorted_agents() { + let containers = [cv("zoe", true), cv("amy", false), cv("bob", true)]; + let w = agent_state_warnings(&containers, &HashMap::new()); + assert_eq!(w.len(), 1); + assert_eq!(w[0].kind, "pending_logins"); + assert_eq!(w[0].level, "warn"); + // sorted, login-needing only, count reflected + assert!(w[0].message.starts_with("2 agents need claude login: bob, zoe")); + } + + #[test] + fn singular_grammar_for_one_agent() { + let containers = [cv("solo", true)]; + let w = agent_state_warnings(&containers, &HashMap::new()); + assert!(w[0].message.starts_with("1 agent needs claude login: solo")); + } + + #[test] + fn crashing_warning_is_crit_and_count_ordered() { + let crashes = HashMap::from([("flap".to_owned(), 5), ("blip".to_owned(), 1)]); + let w = agent_state_warnings(&[], &crashes); + assert_eq!(w.len(), 1); + assert_eq!(w[0].kind, "agents_crashing"); + assert_eq!(w[0].level, "crit"); + // higher crash count first + assert!(w[0].message.contains("flap (×5), blip (×1)")); + } + + #[test] + fn both_warnings_coexist() { + let containers = [cv("a", true)]; + let crashes = HashMap::from([("b".to_owned(), 2)]); + let kinds: Vec<&str> = agent_state_warnings(&containers, &crashes) + .iter() + .map(|w| w.kind) + .collect(); + assert_eq!(kinds, ["pending_logins", "agents_crashing"]); + } +} From aa86af845a2efcaf7e3d8116a817df447b134d89 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 10 Jun 2026 01:19:05 +0200 Subject: [PATCH 04/10] fix(#1591): singular grammar for one pending-login agent --- hive-c0re/src/host_stats.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hive-c0re/src/host_stats.rs b/hive-c0re/src/host_stats.rs index f0ac7dca..2413df32 100644 --- a/hive-c0re/src/host_stats.rs +++ b/hive-c0re/src/host_stats.rs @@ -98,10 +98,11 @@ pub fn agent_state_warnings( kind: "pending_logins", level: "warn", message: format!( - "{n} agent{plural} need claude login: {list} \ + "{n} agent{plural} {verb} claude login: {list} \ — run `hivectl login ` to authenticate", n = pending.len(), plural = if pending.len() == 1 { "" } else { "s" }, + verb = if pending.len() == 1 { "needs" } else { "need" }, list = pending.join(", "), ), }); From 1ee48989f2fbe08035045cd5938f4dd524ce6e34 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 10 Jun 2026 01:39:02 +0200 Subject: [PATCH 05/10] style(#1591): wrap over-long assert line to satisfy rustfmt --- hive-c0re/src/host_stats.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hive-c0re/src/host_stats.rs b/hive-c0re/src/host_stats.rs index 2413df32..712e5568 100644 --- a/hive-c0re/src/host_stats.rs +++ b/hive-c0re/src/host_stats.rs @@ -222,7 +222,10 @@ mod tests { assert_eq!(w[0].kind, "pending_logins"); assert_eq!(w[0].level, "warn"); // sorted, login-needing only, count reflected - assert!(w[0].message.starts_with("2 agents need claude login: bob, zoe")); + assert!( + w[0].message + .starts_with("2 agents need claude login: bob, zoe") + ); } #[test] From bd9140ed20c7921713c5c9f5bdc3a81b8f24525a Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 10 Jun 2026 13:15:13 +0200 Subject: [PATCH 06/10] ci: re-trigger CI after #1606 stuck-runner recovery From 986b3df9b0f201d4f23101dbf281b4abd4758dae Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 10 Jun 2026 13:13:32 +0200 Subject: [PATCH 07/10] feat(frontend): add forge link tile to H0M3 hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H0M3 had tiles for every operator surface except the forge. Add a Forge tile that links straight to the hive-forge web UI. It mirrors the Matrix tile's gating: hidden by default, revealed by home.js only when state.forge_present is true, with the href filled from state.forge_public_url (the gateway-served vhost) or the direct :3000 port fallback — the same precedence the dashboard uses for forge links. Operators without a forge never see a dead link. --- frontend/packages/dashboard/src/home.js | 12 ++++++++++++ frontend/packages/dashboard/src/index.html | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/frontend/packages/dashboard/src/home.js b/frontend/packages/dashboard/src/home.js index 95112e4b..2e216ce0 100644 --- a/frontend/packages/dashboard/src/home.js +++ b/frontend/packages/dashboard/src/home.js @@ -27,6 +27,18 @@ async function init() { if (tile) tile.hidden = false; } + // Forge tile: reveal + point at the live forge only when the + // hive-forge container is up. Prefer the gateway-served public URL + // (set when forge.behindGateway=true), fall back to the direct :3000 + // port — same precedence the dashboard uses for forge links. + if (state.forge_present) { + const tile = $('home-tile-forge'); + if (tile) { + tile.href = state.forge_public_url || `http://${location.hostname}:3000`; + tile.hidden = false; + } + } + const ident = $('hive-identity'); if (ident && (state.swarm_name || state.hive_name)) { const parts = [state.swarm_name, state.hive_name].filter(Boolean); diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index 669b40a1..64021b66 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -68,6 +68,18 @@ operator-local prefs · browser notifications + + + From 564417a6da65cb850b64be0eae64fa1c140d0278 Mon Sep 17 00:00:00 2001 From: lexis Date: Wed, 10 Jun 2026 13:30:09 +0200 Subject: [PATCH 08/10] docs(web-ui): add Forge tile to H0M3 page documentation (follow-up to #1608) --- docs/web-ui/dashboard.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 52497f16..440ff87b 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -480,15 +480,20 @@ re-renders the terminal row. Manager is addressed as `@root`. ## H0M3 page (`/`) The H0M3 hub is the primary landing page (served at `/` by default). A -responsive grid of link tiles — Dashboard, Flow, Logs, Matrix (when enabled) -— each pointing to their respective surfaces. The page is a pure portal with -no tab-bar or SSE subscriptions. Typography + colours inherit from the shared -theme (Catppuccin Mocha via `common.css` + `theme.css`). The Matrix tile is -hidden until `home.js` confirms `matrix_gui_enabled` (same gating as the -dashboard's M4TR1X tab); `home.js` also fills the swarm/hive identity line -at the top. Dashboard is now served at `/dashboard.html` (route swap completed -in #1464 step 2); the home page at `/` replaces the old dashboard root. All -dashboard sub-pages include a `← Home` back-link for navigation. +responsive grid of link tiles — Dashboard, Flow, Logs, Matrix (when enabled), +Forge (when enabled) — each pointing to their respective surfaces. The page +is a pure portal with no tab-bar or SSE subscriptions. Typography + colours +inherit from the shared theme (Catppuccin Mocha via `common.css` + `theme.css`). +Optional tiles are hidden until `home.js` confirms their availability: +Matrix is hidden until `home.js` confirms `matrix_gui_enabled` (same gating as +the dashboard's M4TR1X tab); Forge is hidden until `home.js` confirms +`state.forge_present` and fills the href from `state.forge_public_url` (the +gateway-served public URL when `services.hyperhive.forge.behindGateway=true`) +or falls back to the direct `:3000` port. Operators without matrix or forge +enabled never see dead links. `home.js` also fills the swarm/hive identity +line at the top. Dashboard is now served at `/dashboard.html` (route swap +completed in #1464 step 2); the home page at `/` replaces the old dashboard +root. All dashboard sub-pages include a `← Home` back-link for navigation. ## L0GS page (`/logs.html`) From 50e0ef9b4695a81e52fe33acd798d0cfda5ed484 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 10 Jun 2026 14:01:35 +0200 Subject: [PATCH 09/10] feat(#1540): expose per-event ts on live frame + history rows --- hive-ag3nt/src/events.rs | 85 ++++++++++++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index 0acc3bd5..a4e15a07 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -169,6 +169,23 @@ CREATE INDEX IF NOT EXISTS idx_events_ts ON events (ts); #[derive(Debug, Clone, Serialize)] pub struct BusEvent { pub seq: u64, + /// Unix seconds at emit time. Serialized as a sibling of the `kind` + /// tag so the agent terminal can render turn start/end times (and + /// turn duration) on the live stream; history rows carry the same + /// `ts` field sourced from the persisted `events.ts` column, so the + /// renderer reads `ts` identically for live + scrollback. + pub ts: i64, + #[serde(flatten)] + pub event: LiveEvent, +} + +/// A persisted event paired with its stored unix-seconds timestamp. +/// Serializes with `ts` as a sibling of the `kind` tag — same wire shape +/// as a live [`BusEvent`] minus `seq` — so the agent terminal reads `ts` +/// identically whether an event arrives live or is replayed from history. +#[derive(Debug, Clone, Serialize)] +pub struct StoredEvent { + pub ts: i64, #[serde(flatten)] pub event: LiveEvent, } @@ -281,7 +298,7 @@ impl EventStore { Ok(()) } - fn recent(&self, limit: usize) -> rusqlite::Result> { + fn recent(&self, limit: usize) -> rusqlite::Result> { let (events, _, _) = self.page(None, limit)?; Ok(events) } @@ -293,43 +310,52 @@ impl EventStore { &self, before_id: Option, limit: usize, - ) -> rusqlite::Result<(Vec, Option, bool)> { + ) -> rusqlite::Result<(Vec, Option, bool)> { let limit_i = i64::try_from(limit).unwrap_or(i64::MAX); let conn = self.conn.lock().unwrap(); // Fetch one extra row so we can tell whether more exist. let fetch = limit_i.saturating_add(1); - let rows: Vec<(i64, LiveEvent)> = if let Some(bid) = before_id { + // `ts` is the persisted emit-time unix-seconds stamp; carried out + // alongside each event so history replay shows the same turn + // start/end times the live stream did. + let rows: Vec<(i64, StoredEvent)> = if let Some(bid) = before_id { let mut stmt = conn.prepare( - "SELECT id, payload_json FROM events + "SELECT id, ts, payload_json FROM events WHERE id < ?1 ORDER BY id DESC LIMIT ?2", )?; stmt.query_map(params![bid, fetch], |row| { let id: i64 = row.get(0)?; - let s: String = row.get(1)?; - Ok(serde_json::from_str::(&s).ok().map(|e| (id, e))) + let ts: i64 = row.get(1)?; + let s: String = row.get(2)?; + Ok(serde_json::from_str::(&s) + .ok() + .map(|event| (id, StoredEvent { ts, event }))) })? .flatten() .flatten() .collect() } else { let mut stmt = conn.prepare( - "SELECT id, payload_json FROM events + "SELECT id, ts, payload_json FROM events ORDER BY id DESC LIMIT ?1", )?; stmt.query_map(params![fetch], |row| { let id: i64 = row.get(0)?; - let s: String = row.get(1)?; - Ok(serde_json::from_str::(&s).ok().map(|e| (id, e))) + let ts: i64 = row.get(1)?; + let s: String = row.get(2)?; + Ok(serde_json::from_str::(&s) + .ok() + .map(|event| (id, StoredEvent { ts, event }))) })? .flatten() .flatten() .collect() }; let has_more = rows.len() > limit; - let mut rows: Vec<(i64, LiveEvent)> = rows.into_iter().take(limit).collect(); + let mut rows: Vec<(i64, StoredEvent)> = rows.into_iter().take(limit).collect(); rows.reverse(); // oldest first let min_id = rows.first().map(|(id, _)| *id); let events = rows.into_iter().map(|(_, e)| e).collect(); @@ -1003,6 +1029,7 @@ impl Bus { } let envelope = BusEvent { seq: self.next_seq(), + ts: now_unix(), event, }; // Lagged subscribers drop events — fine; the UI is a tail, not a log. @@ -1018,7 +1045,7 @@ impl Bus { /// Drives the terminal pre-fill when the operator opens the agent /// page; without a store (db open failed) this is empty. #[must_use] - pub fn history(&self) -> Vec { + pub fn history(&self) -> Vec { let Some(store) = &self.store else { return Vec::new(); }; @@ -1035,7 +1062,7 @@ impl Bus { &self, before_id: Option, limit: usize, - ) -> (Vec, Option, bool) { + ) -> (Vec, Option, bool) { let Some(store) = &self.store else { return (Vec::new(), None, false); }; @@ -1051,9 +1078,41 @@ impl Default for Bus { #[cfg(test)] mod tests { - use super::{DEFAULT_EFFORT, EFFORT_LEVELS, TokenUsage, is_valid_effort}; + use super::{ + BusEvent, DEFAULT_EFFORT, EFFORT_LEVELS, LiveEvent, StoredEvent, TokenUsage, + is_valid_effort, + }; use serde_json::json; + #[test] + fn stored_event_serializes_ts_beside_kind() { + // History-row wire shape: `ts` is a flattened sibling of `kind`, + // which is what the agent terminal reads to time turn boundaries. + let v = serde_json::to_value(StoredEvent { + ts: 1_700_000_000, + event: LiveEvent::Note { text: "hi".into() }, + }) + .unwrap(); + assert_eq!(v["ts"], 1_700_000_000_i64); + assert_eq!(v["kind"], "note"); + assert_eq!(v["text"], "hi"); + } + + #[test] + fn bus_event_serializes_ts_and_seq_beside_kind() { + // Live SSE frame: same `ts` sibling as history (plus `seq`), so the + // renderer is path-agnostic between live + scrollback. + let v = serde_json::to_value(BusEvent { + seq: 7, + ts: 1_700_000_000, + event: LiveEvent::Note { text: "yo".into() }, + }) + .unwrap(); + assert_eq!(v["seq"], 7); + assert_eq!(v["ts"], 1_700_000_000_i64); + assert_eq!(v["kind"], "note"); + } + #[test] fn effort_validation_accepts_only_known_levels() { for level in EFFORT_LEVELS { From e89ca90956760c972227e4686414f491ce7eaa30 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 10 Jun 2026 13:56:30 +0200 Subject: [PATCH 10/10] feat(agent-ui): show turn start/end times + duration on the agent terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-agent terminal marks turn boundaries (◆ TURN ← / ✓ turn) but shows no time. Append a wall-clock HH:MM:SS to both the turn-start and turn-end rows, plus the elapsed duration on turn-end, rendered as dim metadata so the boundary glyph stays the focus. This is the frontend half of the feature; it reads a per-event `ts` (unix seconds) off the turn_start / turn_end events. The read is guarded on a numeric `ts`, so until the harness surfaces per-event timestamps the rows render exactly as before (inert-until-capture). Once the backend attaches `ts` to the history rows + live SSE frame, the times light up automatically for both live and scrollback, with no further frontend change. --- frontend/packages/agent/src/app.js | 36 ++++++++++++++++++++++- frontend/packages/shared/src/terminal.css | 4 +++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 53feb7e5..3798351a 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -756,6 +756,16 @@ window.marked = marked; const h = Math.floor(m / 60); return h + 'h ' + (m % 60) + 'm'; } + // Wall-clock HH:MM:SS (UTC, matching the inbox timestamps on this page) + // from a unix-seconds value. Used to label turn-start / turn-end rows + // when the event carries a `ts` (see the turn renderers below). + function fmtClock(sec) { + return new Date(sec * 1000).toISOString().slice(11, 19); + } + // Unix-seconds stamp of the most recent open turn-start, so the + // matching turn-end can show a duration. Turns are sequential, so a + // single slot is enough (history replays chronologically too). + let pendingTurnStartTs = null; const STATE_TOOLTIPS = { loading: 'harness not yet contacted', offline: 'harness unreachable or claude not logged in', @@ -1697,6 +1707,16 @@ window.marked = marked; if (api.fromHistory) openTurnsFromHistory += 1; else { setBannerActive(true); setState('thinking'); } const block = api.row('turn-start', '◆ TURN ← ' + ev.from); + // Turn start time. Guarded on a numeric `ts` (unix seconds) + // so the row degrades to its old text-only form until the + // backend surfaces per-event timestamps. + if (typeof ev.ts === 'number') { + pendingTurnStartTs = ev.ts; + const t = document.createElement('span'); + t.className = 'turn-time'; + t.textContent = '· ' + fmtClock(ev.ts); + block.appendChild(t); + } if (ev.unread > 0) { const badge = document.createElement('span'); badge.className = 'unread-badge'; @@ -1717,9 +1737,23 @@ window.marked = marked; refreshLooseEnds(); } const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; - api.row(cls, + const row = api.row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : '')); + // Turn end time + duration since the paired turn-start. + // Same `ts` guard as turn_start; duration only when we saw the + // matching start's stamp. + if (typeof ev.ts === 'number') { + const t = document.createElement('span'); + t.className = 'turn-time'; + let label = '· ' + fmtClock(ev.ts); + if (pendingTurnStartTs != null && ev.ts >= pendingTurnStartTs) { + label += ' · ' + fmtAge((ev.ts - pendingTurnStartTs) * 1000); + } + t.textContent = label; + row.appendChild(t); + } + pendingTurnStartTs = null; }, note(ev, api) { const t = String(ev.text || ''); diff --git a/frontend/packages/shared/src/terminal.css b/frontend/packages/shared/src/terminal.css index 2cfe7dee..37413827 100644 --- a/frontend/packages/shared/src/terminal.css +++ b/frontend/packages/shared/src/terminal.css @@ -100,6 +100,10 @@ .live .row .md, .live .row > details { text-indent: 0; } .live .turn-end-ok { color: var(--green); border-left-color: var(--green); } .live .turn-end-fail { color: var(--red); border-left-color: var(--red); } +/* Wall-clock time (+ duration on turn-end) appended to the turn-start / + turn-end rows. Dim + smaller so the boundary glyph stays the focus and + the timestamp reads as metadata. */ +.live .turn-time { color: var(--muted); font-size: 0.85em; margin-left: 0.5em; } .live .text { color: var(--fg); } .live .thinking { color: var(--muted); font-style: italic; } .live .tool-use { color: var(--cyan); }