treefmt: apply prettier

Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
This commit is contained in:
atlas 2026-09-02 14:29:33 +02:00
commit 39b95c2ede
203 changed files with 10090 additions and 6085 deletions

View file

@ -33,7 +33,7 @@ claude --name <memorable> --model <cheaper-than-you> --dangerously-skip-permissi
by name for follow-ups. by name for follow-ups.
- **`--model <cheaper-than-you>`** - think about which model the task - **`--model <cheaper-than-you>`** - think about which model the task
actually needs; don't spend a bigger model's tokens than your own on actually needs; don't spend a bigger model's tokens than your own on
mechanical work a cheaper one handles fine. Never use a *bigger* model mechanical work a cheaper one handles fine. Never use a _bigger_ model
than yourself for a sub-agent - if the task needs that much capability, than yourself for a sub-agent - if the task needs that much capability,
it's not the "mechanical batch" case this skill is for. it's not the "mechanical batch" case this skill is for.
- **`--dangerously-skip-permissions`** - required in headless (`-p`) - **`--dangerously-skip-permissions`** - required in headless (`-p`)
@ -68,7 +68,7 @@ work while the sub-agent grinds.
cases: "leave it as-is where genuinely unclear; do not guess." cases: "leave it as-is where genuinely unclear; do not guess."
- **Ask for a report file** - per-item results + anything skipped and - **Ask for a report file** - per-item results + anything skipped and
why, so you can verify without re-deriving. why, so you can verify without re-deriving.
- **Tune on ONE item first**, eyeball the result, fix the prompt, *then* - **Tune on ONE item first**, eyeball the result, fix the prompt, _then_
turn it loose on the full batch. A prompt bug replicated across 100 turn it loose on the full batch. A prompt bug replicated across 100
items is 100 cleanups. items is 100 cleanups.
@ -98,7 +98,7 @@ running at once instead of handing the whole thing to one.
loop. loop.
- **Mind the container's memory cap before picking N.** Your whole - **Mind the container's memory cap before picking N.** Your whole
container shares one `MemoryMax` (a few GB by default) with every container shares one `MemoryMax` (a few GB by default) with every
subagent you spawn *and* your own process. A `claude` process plus its subagent you spawn _and_ your own process. A `claude` process plus its
MCP servers can hold several hundred MB to ~1GB depending on the task; MCP servers can hold several hundred MB to ~1GB depending on the task;
spawning a dozen at once on a small container doesn't just slow spawning a dozen at once on a small container doesn't just slow
things down, it can OOM the whole container — taking your own things down, it can OOM the whole container — taking your own

View file

@ -15,7 +15,7 @@ steps in the right order.
## 1. Check for a duplicate before filing ## 1. Check for a duplicate before filing
`hive-forge list` has a server-side full-text `--search` flag that covers `hive-forge list` has a server-side full-text `--search` flag that covers
title *and* body - use it before writing anything: title _and_ body - use it before writing anything:
```sh ```sh
hive-forge list --search "keyword or two" --state all hive-forge list --search "keyword or two" --state all
@ -43,8 +43,8 @@ Prefer naming the broken behavior or missing capability over a specific
error string or one-off symptom - "hive-forge list has no way to filter by error string or one-off symptom - "hive-forge list has no way to filter by
milestone" beats "list command confusing" or "can't find issues for milestone" beats "list command confusing" or "can't find issues for
milestone 11 easily". If you're not sure which framing is more durable, milestone 11 easily". If you're not sure which framing is more durable,
lean toward whatever a colleague hitting the *same underlying gap* through lean toward whatever a colleague hitting the _same underlying gap_ through
a *different* symptom would still recognize. a _different_ symptom would still recognize.
## 3. Body: what's wrong, not how to fix it ## 3. Body: what's wrong, not how to fix it

View file

@ -20,7 +20,7 @@ dimension directly.
hardcoded, so check `repo-labels` first if you don't already know the hardcoded, so check `repo-labels` first if you don't already know the
scopes in use. scopes in use.
- **PRs with no formally requested reviewer** - `hive-forge lint - **PRs with no formally requested reviewer** - `hive-forge lint
no-reviewer` flags PRs with zero requested reviewers at all; add no-reviewer` flags PRs with zero requested reviewers at all; add
`--reviewer <name>` to instead flag PRs where that specific person `--reviewer <name>` to instead flag PRs where that specific person
isn't among the requested reviewers. This checks the forge's actual isn't among the requested reviewers. This checks the forge's actual
reviewer-request state, not a text `@name` mention anywhere in the reviewer-request state, not a text `@name` mention anywhere in the
@ -42,12 +42,13 @@ dimension directly.
Run the checks relevant to what you're verifying rather than assuming Run the checks relevant to what you're verifying rather than assuming
one covers everything - "is triage caught up" usually means unassigned one covers everything - "is triage caught up" usually means unassigned
+ unlabeled at minimum, with no-reviewer added if reviews matter for
your workflow. Each verb supports `--json` if you want to fold the - unlabeled at minimum, with no-reviewer added if reviews matter for
results into something else instead of reading the human-readable output. your workflow. Each verb supports `--json` if you want to fold the
results into something else instead of reading the human-readable output.
This is reactive/on-demand coverage checking, not a scheduled rollup or This is reactive/on-demand coverage checking, not a scheduled rollup or
historical trend - if you need to track compliance *over time* (was historical trend - if you need to track compliance _over time_ (was
triage worse last week than this week), that's a different, bigger triage worse last week than this week), that's a different, bigger
ask than running these checks - don't build that unprompted just ask than running these checks - don't build that unprompted just
because the checks exist. because the checks exist.

View file

@ -6,7 +6,7 @@ description: Take a real screenshot of an HTML/CSS page from inside a container
# Headless Screenshot # Headless Screenshot
"No browser in this container" usually means no browser is "No browser in this container" usually means no browser is
*pre-installed*, not that one is unreachable. `nixpkgs#chromium` is a _pre-installed_, not that one is unreachable. `nixpkgs#chromium` is a
`nix shell` away, and a single headless invocation renders a page and `nix shell` away, and a single headless invocation renders a page and
writes a PNG without a running server, a display, or any scripted writes a PNG without a running server, a display, or any scripted
interaction library. interaction library.
@ -65,7 +65,7 @@ peer agent's GUI-testing capability, try rendering it yourself first:
This is a static render, not a browser automation tool. Verifying an This is a static render, not a browser automation tool. Verifying an
interaction (a click, a hover state, a form submission) needs interaction (a click, a hover state, a form submission) needs
puppeteer/playwright or similar - reach for those only when the puppeteer/playwright or similar - reach for those only when the
question is genuinely about *behavior*, not appearance. A missing question is genuinely about _behavior_, not appearance. A missing
emoji/glyph rendering as a box in the screenshot is usually a font emoji/glyph rendering as a box in the screenshot is usually a font
availability artifact of the headless environment, not a real bug - availability artifact of the headless environment, not a real bug -
don't mistake one for the other when reporting results. don't mistake one for the other when reporting results.
@ -80,7 +80,7 @@ judge icon/glyph layout, not just to note "there'd be an icon here").
- **Tofu boxes instead of emoji.** Plain `nixpkgs#chromium` has no - **Tofu boxes instead of emoji.** Plain `nixpkgs#chromium` has no
color-emoji font, so any 🖥/📡/🔨-style glyph renders as an empty color-emoji font, so any 🖥/📡/🔨-style glyph renders as an empty
box. Add `nixpkgs#noto-fonts-color-emoji` to the `nix shell` - but a box. Add `nixpkgs#noto-fonts-color-emoji` to the `nix shell` - but a
`FONTCONFIG_FILE` that lists *only* the emoji dir throws away the `FONTCONFIG_FILE` that lists _only_ the emoji dir throws away the
base fonts and blanks all normal text instead, which is its own base fonts and blanks all normal text instead, which is its own
false alarm. Merge, don't override: false alarm. Merge, don't override:
``` ```

View file

@ -10,7 +10,7 @@ work in your own style. That framing should hold throughout.
## Before you start ## Before you start
1. **Read the linked issue/task** - understand *why* the change exists, 1. **Read the linked issue/task** - understand _why_ the change exists,
not just what it does. not just what it does.
2. **Check CI status first.** A failing build or test suite is an 2. **Check CI status first.** A failing build or test suite is an
automatic block - don't write a detailed review of code that doesn't automatic block - don't write a detailed review of code that doesn't

View file

@ -28,7 +28,7 @@ requester-as-parent edge written at its `init_config` approval (so
agent, including the bootstrap container (`ruth`) — it's just another agent, including the bootstrap container (`ruth`) — it's just another
root. The manager is reparentable like any other agent; there's no root. The manager is reparentable like any other agent; there's no
"structurally root" carve-out. Its privileges live on its MCP socket, "structurally root" carve-out. Its privileges live on its MCP socket,
not its tree position (see *Manager special-casing today* below). not its tree position (see _Manager special-casing today_ below).
### Reparenting ### Reparenting
@ -88,15 +88,15 @@ umount-old / mount-new / restart-cascade step.
## Planned topology semantics (once ancestor-based enforcement lands) ## Planned topology semantics (once ancestor-based enforcement lands)
| operation | who can do it | | operation | who can do it |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | ----------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `kill` / `start` / `restart` / `update` (any descendant) | any ancestor | | `kill` / `start` / `restart` / `update` (any descendant) | any ancestor |
| `request_init_config` (spawn a new child) | any agent, child added under self | | `request_init_config` (spawn a new child) | any agent, child added under self |
| config change via forge PR (any descendant's config) | any ancestor | | config change via forge PR (any descendant's config) | any ancestor |
| `get_logs` (any descendant) | any ancestor | | `get_logs` (any descendant) | any ancestor |
| moderate reminders (cancel any open thread of a descendant) | any ancestor | | moderate reminders (cancel any open thread of a descendant) | any ancestor |
| `send` / `recv` routing | parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else | | `send` / `recv` routing | parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else |
| `request_update_meta_inputs` (bump meta lock) | root agents only (today: just `manager`) | | `request_update_meta_inputs` (bump meta lock) | root agents only (today: just `manager`) |
"Ancestor" walks `ContainerView.parent` chains; cycles are guarded by a "Ancestor" walks `ContainerView.parent` chains; cycles are guarded by a
visited-set at dispatch time (a malformed `topology.json` can't lock visited-set at dispatch time (a malformed `topology.json` can't lock
@ -138,11 +138,11 @@ other agents don't:
capability. capability.
- **Prompt/tools** — the system prompt uses `<!-- role:agent -->` / - **Prompt/tools** — the system prompt uses `<!-- role:agent -->` /
`<!-- role:manager -->` marker blocks, and a `Flavor::{Agent, `<!-- role:manager -->` marker blocks, and a `Flavor::{Agent,
Manager}` switch picks the MCP tool allow-list claude sees. Both are Manager}` switch picks the MCP tool allow-list claude sees. Both are
already parametrised on a single flavour value, so the planned already parametrised on a single flavour value, so the planned
per-capability-group version (`cap:<group>` prompt blocks + a per-capability-group version (`cap:<group>` prompt blocks + a
matching tool allow-list) is additive rather than a rewrite. matching tool allow-list) is additive rather than a rewrite.
- **State dirs***not* special-cased: `HYPERHIVE_STATE_DIR` is - **State dirs**_not_ special-cased: `HYPERHIVE_STATE_DIR` is
injected uniformly via `systemd.globalEnvironment` for every injected uniformly via `systemd.globalEnvironment` for every
container including the manager, so all token/state paths resolve container including the manager, so all token/state paths resolve
through it the same way everywhere. through it the same way everywhere.

View file

@ -114,7 +114,7 @@ Nothing to run — it is served on the swarm apex
decide whether you can actually open it: decide whether you can actually open it:
- **You are in `admins`** (step 3). The gateway asks authelia whether - **You are in `admins`** (step 3). The gateway asks authelia whether
you have a session; the rule that makes it mean *operator* wants the you have a session; the rule that makes it mean _operator_ wants the
group. Without it you log in and still get bounced. group. Without it you log in and still get bounced.
- **The name resolves to this host.** It is published to the hive's own - **The name resolves to this host.** It is published to the hive's own
resolver and to `/etc/hosts` when `gateway.localHostsEntry` is on; from resolver and to `/etc/hosts` when `gateway.localHostsEntry` is on; from

View file

@ -112,7 +112,7 @@ On a **successful** broker delivery, `forge_notify` marks the thread
read on forge straight away (`PATCH /notifications/threads/{id}`). The read on forge straight away (`PATCH /notifications/threads/{id}`). The
broker inbox is the durable work queue now — each delivered wake is a broker inbox is the durable work queue now — each delivered wake is a
sqlite row with its own ack lifecycle — so the forge unread flag no sqlite row with its own ack lifecycle — so the forge unread flag no
longer needs to track whether the agent has *processed* a longer needs to track whether the agent has _processed_ a
notification. Clearing it on delivery keeps forge's unread set **tiny notification. Clearing it on delivery keeps forge's unread set **tiny
by construction**: at rest it holds only threads that failed to by construction**: at rest it holds only threads that failed to
deliver plus whatever arrived since the last 30s poll. deliver plus whatever arrived since the last 30s poll.
@ -132,19 +132,19 @@ own read-state is the only durable record this design needs.
**Read-before-comment guard doesn't block a fresh wake.** hive-forge's **Read-before-comment guard doesn't block a fresh wake.** hive-forge's
read-before-comment guard (which keys off forge unread-state) does not read-before-comment guard (which keys off forge unread-state) does not
force the agent to view a thread before commenting on it: the broker force the agent to view a thread before commenting on it: the broker
wake already carries the notification body, so *delivery is the read*. wake already carries the notification body, so _delivery is the read_.
An agent that wants the full thread still runs `hive-forge comments` / An agent that wants the full thread still runs `hive-forge comments` /
`view`. `view`.
**In-process dedupe (tiny, ephemeral).** A single-process map (thread **In-process dedupe (tiny, ephemeral).** A single-process map (thread
id → last-delivered `updated_at`) guards the narrow window where a id → last-delivered `updated_at`) guards the narrow window where a
mark-read call *transiently fails* and the thread reappears unread in mark-read call _transiently fails_ and the thread reappears unread in
the next poll before its `updated_at` bumps — so a flaky PATCH doesn't the next poll before its `updated_at` bumps — so a flaky PATCH doesn't
re-fire the wake. It is **not persisted** and resets on restart (forge re-fire the wake. It is **not persisted** and resets on restart (forge
read-state covers the durable case). Each poll prunes it to the ids in read-state covers the durable case). Each poll prunes it to the ids in
the single `limit=UNREAD_FETCH_LIMIT` (50) fetch page, so it can never the single `limit=UNREAD_FETCH_LIMIT` (50) fetch page, so it can never
exceed that many entries (a debug assertion pins the invariant; the exceed that many entries (a debug assertion pins the invariant; the
fetch limit and the bound are the same constant). A failed *delivery* fetch limit and the bound are the same constant). A failed _delivery_
is left unread and out of the map, so it resurfaces next tick. is left unread and out of the map, so it resurfaces next tick.
Self-echo notifications (the agent's own writes, see below) are marked Self-echo notifications (the agent's own writes, see below) are marked
@ -225,13 +225,13 @@ lookahead.
Five shapes, distinguished by the notification's classification: Five shapes, distinguished by the notification's classification:
| Trigger | Wrapper | | Trigger | Wrapper |
| ----------------------------------- | --------------------------------------------------------------------------------- | | ----------------------------------- | ------------------------------------------------------------------------------ |
| Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` | | Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` |
| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` | | Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` |
| New issue / PR | `[new PR #N owner/repo] title\nurl: ...\nassignee: ...` | | New issue / PR | `[new PR #N owner/repo] title\nurl: ...\nassignee: ...` |
| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\nassignee: ...` | | Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\nassignee: ...` |
| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` | | State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` |
The issue/PR's own description is deliberately never embedded here (only a The issue/PR's own description is deliberately never embedded here (only a
comment/review body is — that's the actual new content a notification comment/review body is — that's the actual new content a notification

View file

@ -109,7 +109,7 @@ Forgejo's notifications API is modelled on GitHub's, so one tolerant
parse serves both: `id`, `repository.full_name`, parse serves both: `id`, `repository.full_name`,
`subject {title,url,latest_comment_url}` and `updated_at` line up field `subject {title,url,latest_comment_url}` and `updated_at` line up field
for field. The two real differences are absorbed by lenient for field. The two real differences are absorbed by lenient
deserializers — GitHub sends the thread id as a *string* where Forgejo deserializers — GitHub sends the thread id as a _string_ where Forgejo
sends a number, and says `PullRequest` where Forgejo says `Pull`. Todo sends a number, and says `PullRequest` where Forgejo says `Pull`. Todo
keys are prefixed `gh:<id>` so a github thread id cannot collide with a keys are prefixed `gh:<id>` so a github thread id cannot collide with a
forge one. forge one.
@ -118,16 +118,16 @@ Two host differences worth knowing before touching this code:
- **Auth scheme, not just value.** Forgejo takes - **Auth scheme, not just value.** Forgejo takes
`Authorization: token <t>`; GitHub wants `Bearer <t>` plus `Accept: `Authorization: token <t>`; GitHub wants `Bearer <t>` plus `Accept:
application/vnd.github+json`, `X-GitHub-Api-Version` and a application/vnd.github+json`, `X-GitHub-Api-Version` and a
`User-Agent`. Sending Forgejo's form to GitHub does not error — it `User-Agent`. Sending Forgejo's form to GitHub does not error — it
authenticates as *nobody* and silently drops to the unauthenticated authenticates as _nobody_ and silently drops to the unauthenticated
rate limit. The cheap way to tell the two apart is the rate-limit rate limit. The cheap way to tell the two apart is the rate-limit
header: `x-ratelimit-remaining` near 5000 is an authenticated user, header: `x-ratelimit-remaining` near 5000 is an authenticated user,
near 60 is anonymous. near 60 is anonymous.
- **GitHub sets the cadence.** It returns `X-Poll-Interval` (60s in - **GitHub sets the cadence.** It returns `X-Poll-Interval` (60s in
practice, slower than our own tick) and rate-limits callers who ignore practice, slower than our own tick) and rate-limits callers who ignore
it, so the loop re-arms to the server's interval whenever that is it, so the loop re-arms to the server's interval whenever that is
*slower* than ours. A hint faster than our own tick is not a reason to _slower_ than ours. A hint faster than our own tick is not a reason to
poll harder. poll harder.
⚠️ **This needs the `notifications` scope on the PAT.** A token minted ⚠️ **This needs the `notifications` scope on the PAT.** A token minted

View file

@ -42,7 +42,7 @@ hive-c0re maintains the local clone at
`internal/knowledge` (see `docs/swarm/README.md` § Swarm-wide forge `internal/knowledge` (see `docs/swarm/README.md` § Swarm-wide forge
webhooks). On any push to main, including merge commits, it sends an webhooks). On any push to main, including merge commits, it sends an
event to every hive over the swarm queue and each hive runs `git event to every hive over the swarm queue and each hive runs `git
pull`, so agents see the new content on their next turn. pull`, so agents see the new content on their next turn.
A hive that is offline when the event is sent does not get it on A hive that is offline when the event is sent does not get it on
reconnect — the periodic pull below is what closes that gap. So one reconnect — the periodic pull below is what closes that gap. So one

View file

@ -43,13 +43,13 @@ untouched by any of it.
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
``` ```
| container | netns | IPv4 | listens / reached via | | container | netns | IPv4 | listens / reached via |
| -------------- | ----------------------- | -------------------- | -------------------------------------------------------------------------------------------- | | -------------- | ----------------------- | -------------- | ----------------------------------------------------------------------------------------------- |
| `hive-gateway` | host (shared) | host addresses | nginx `:80`/`:443` (every vhost); dnsmasq `bridgeIp:53` + DHCP `:67` on the bridge | | `hive-gateway` | host (shared) | host addresses | nginx `:80`/`:443` (every vhost); dnsmasq `bridgeIp:53` + DHCP `:67` on the bridge |
| `hive-forge` | host (shared) | host addresses | forgejo `:3000` http, `:2222` git-ssh; fronted by the `forge.<swarm-domain>` vhost | | `hive-forge` | host (shared) | host addresses | forgejo `:3000` http, `:2222` git-ssh; fronted by the `forge.<swarm-domain>` vhost |
| `hive-matrix` | host (shared) | host addresses | tuwunel `:8008` (+ optional federation port); fronted by the matrix vhost | | `hive-matrix` | host (shared) | host addresses | tuwunel `:8008` (+ optional federation port); fronted by the matrix vhost |
| `hive-ci` | private, veth on bridge | DHCP pool | outbound only (runner → forge); no inbound surface | | `hive-ci` | private, veth on bridge | DHCP pool | outbound only (runner → forge); no inbound surface |
| `h-<agent>` | private, veth on bridge | DHCP pool | web UI via UDS `/run/hive-agent/<name>` → nginx sub-path; in-container UI port hashed 81008999 | | `h-<agent>` | private, veth on bridge | DHCP pool | web UI via UDS `/run/hive-agent/<name>` → nginx sub-path; in-container UI port hashed 81008999 |
The flows, end to end: The flows, end to end:
@ -57,7 +57,7 @@ The flows, end to end:
host firewall (udp 67 hole) → dnsmasq pool → lease + router option. host firewall (udp 67 hole) → dnsmasq pool → lease + router option.
- **DNS** — agents and the service containers query `bridgeIp:53`; hive - **DNS** — agents and the service containers query `bridgeIp:53`; hive
zones are answered authoritatively with the bridge IP, everything else zones are answered authoritatively with the bridge IP, everything else
forwards to the host's resolvers (see *Resolver behaviour* below). Each forwards to the host's resolvers (see _Resolver behaviour_ below). Each
container points its own `resolv.conf` there, and one that instead container points its own `resolv.conf` there, and one that instead
inherits the host's resolves no swarm name at all — those records exist inherits the host's resolves no swarm name at all — those records exist
only on the bridge. only on the bridge.
@ -169,7 +169,7 @@ agent containers.
The **host** firewall is the only firewall. The shared-netns infra The **host** firewall is the only firewall. The shared-netns infra
containers (gateway, forge, matrix) set containers (gateway, forge, matrix) set
`networking.firewall.enable = false`: a NixOS firewall inside a `networking.firewall.enable = false`: a NixOS firewall inside a
shared-netns container runs against the *host* ruleset — at container shared-netns container runs against the _host_ ruleset — at container
boot its `firewall-start` flushes the `nixos-fw` chains, rebuilds them boot its `firewall-start` flushes the `nixos-fw` chains, rebuilds them
from the container's (empty) port list, and deletes the host's from the container's (empty) port list, and deletes the host's
`nixos-nat-*` chains without recreating them, silently wiping the `nixos-nat-*` chains without recreating them, silently wiping the
@ -183,7 +183,7 @@ By default agents can only reach the host on 80/443 (+53 DNS), so a
host-side service on another port — e.g. a dev OTLP collector you want host-side service on another port — e.g. a dev OTLP collector you want
agents to reach directly — is unreachable. (hyperhive's own telemetry agents to reach directly — is unreachable. (hyperhive's own telemetry
needs none of this: `otel.enable` opens its collector's port itself, and needs none of this: `otel.enable` opens its collector's port itself, and
`otel.endpoint` is the *upstream*, which no agent ever dials. See `otel.endpoint` is the _upstream_, which no agent ever dials. See
`docs/scheduler/observability.md`.) `docs/scheduler/observability.md`.)
`services.hyperhive.network.exposeHostPorts = [ 4318 ];` opens each `services.hyperhive.network.exposeHostPorts = [ 4318 ];` opens each
@ -206,12 +206,12 @@ Each agent container runs in a private network namespace with a dedicated
veth pair attached to the bridge. The following table summarises what veth pair attached to the bridge. The following table summarises what
the nix side sets up unconditionally: the nix side sets up unconditionally:
| effect | mechanism | | effect | mechanism |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| IP forwarding | `boot.kernel.sysctl."net.ipv4.ip_forward" = 1` | | IP forwarding | `boot.kernel.sysctl."net.ipv4.ip_forward" = 1` |
| Internet NAT | `networking.nat { enable = true; internalInterfaces = [ bridgeName ]; }` — MASQUERADE on packets leaving via any external NIC | | Internet NAT | `networking.nat { enable = true; internalInterfaces = [ bridgeName ]; }` — MASQUERADE on packets leaving via any external NIC |
| Loopback DROP | `networking.firewall.extraInputRules` — drops bridge-subnet → `127.0.0.0/8` traffic; defence-in-depth against routing table leaks | | Loopback DROP | `networking.firewall.extraInputRules` — drops bridge-subnet → `127.0.0.0/8` traffic; defence-in-depth against routing table leaks |
| Gateway access | `networking.firewall.interfaces.<bridge>.allowedTCPPorts = [ 80 443 ]` — lets isolated agents (private netns, veth on bridge) reach nginx on the host | | Gateway access | `networking.firewall.interfaces.<bridge>.allowedTCPPorts = [ 80 443 ]` — lets isolated agents (private netns, veth on bridge) reach nginx on the host |
| c0re signal | `HIVE_NETWORK_BRIDGE`, `HIVE_NETWORK_SUBNET` in `systemd.services.hive-c0re.environment` — both **required**; `hive-c0re` refuses to start without them | | c0re signal | `HIVE_NETWORK_BRIDGE`, `HIVE_NETWORK_SUBNET` in `systemd.services.hive-c0re.environment` — both **required**; `hive-c0re` refuses to start without them |
`HIVE_NETWORK_SUBNET` is the host-side bridge IP + prefix (e.g. `HIVE_NETWORK_SUBNET` is the host-side bridge IP + prefix (e.g.
@ -262,7 +262,7 @@ wiring is runtime:
**Why isolation is safe**: hive-c0re's control-plane sockets are unix **Why isolation is safe**: hive-c0re's control-plane sockets are unix
domain sockets bind-mounted into containers, not network listeners — see domain sockets bind-mounted into containers, not network listeners — see
the *Control plane (no network)* bullet under [Network the _Control plane (no network)_ bullet under [Network
map](#network-map) above. `PRIVATE_NETWORK=1` has no effect on a path map](#network-map) above. `PRIVATE_NETWORK=1` has no effect on a path
that never touches the network stack. that never touches the network stack.

View file

@ -43,8 +43,8 @@ Note that the mesh is gated on `swarm.wireguard.enable`, **not** on
## Pointing a hive at it ## Pointing a hive at it
The block above configures the host that *receives*. Every hive that The block above configures the host that _receives_. Every hive that
*pushes* separately needs to be told where the store is: _pushes_ separately needs to be told where the store is:
```nix ```nix
services.hyperhive.swarm.snapshotStore = { services.hyperhive.swarm.snapshotStore = {
@ -65,9 +65,9 @@ inconsistency:
Note the option lives under `swarm.*` while the receiving host's lives Note the option lives under `swarm.*` while the receiving host's lives
under `services.hyperhive.snapshotStore`. That is the distinction the under `services.hyperhive.snapshotStore`. That is the distinction the
two namespaces carry throughout: `swarm.*` describes *the swarm* as seen two namespaces carry throughout: `swarm.*` describes _the swarm_ as seen
from this host, and a bare `services.hyperhive.<service>` describes *a from this host, and a bare `services.hyperhive.<service>` describes _a
role this host performs*. A store host sets both --- one to run the role this host performs_. A store host sets both --- one to run the
receiver, one only if it also runs a hive that pushes. receiver, one only if it also runs a hive that pushes.
With it set, `hivectl agent <name> subvol snapshot push <label> With it set, `hivectl agent <name> subvol snapshot push <label>
@ -88,7 +88,7 @@ that address provably came from the holder of that private key. A
packet that reaches the receiver has therefore already been packet that reaches the receiver has therefore already been
authenticated by the kernel. authenticated by the kernel.
Layering TLS client certs on top would authenticate *the same fact* a Layering TLS client certs on top would authenticate _the same fact_ a
second time, and add a credential with an expiry --- a migration that second time, and add a credential with an expiry --- a migration that
fails because a renewal quietly didn't happen, discovered on the day fails because a renewal quietly didn't happen, discovered on the day
you need to move an agent. you need to move an agent.
@ -98,14 +98,14 @@ you need to move an agent.
The destination is keyed by **agent**. The destination is keyed by **agent**.
This is not cosmetic. After a migration, an agent's next incremental This is not cosmetic. After a migration, an agent's next incremental
send arrives from a *different* hive than the previous one. Keying by send arrives from a _different_ hive than the previous one. Keying by
hive would split that agent's snapshot chain across two directories, hive would split that agent's snapshot chain across two directories,
and `btrfs send -p` would fail to find its parent --- breaking exactly and `btrfs send -p` would fail to find its parent --- breaking exactly
the case the store exists to serve. the case the store exists to serve.
## What the sender can and cannot choose ## What the sender can and cannot choose
A `btrfs send` stream carries no notion of *which agent* it belongs to, A `btrfs send` stream carries no notion of _which agent_ it belongs to,
and the subvolume name inside it is chosen by the sender. So the and the subvolume name inside it is chosen by the sender. So the
protocol is one `agent <name>` header line, then the raw stream. protocol is one `agent <name>` header line, then the raw stream.
@ -129,7 +129,7 @@ address being set are assertions, not documentation --- bound to
state. state.
Binding is not sufficient on its own. NixOS's firewall is default-deny Binding is not sufficient on its own. NixOS's firewall is default-deny
and filters in netfilter, *before* a packet reaches a bound socket, so and filters in netfilter, _before_ a packet reaches a bound socket, so
the port is opened explicitly --- and scoped to the mesh interface: the port is opened explicitly --- and scoped to the mesh interface:
```nix ```nix
@ -176,8 +176,8 @@ and means it should get the treatment a backup host gets --- restricted
access, and a decision (rather than an omission) on encryption at rest. access, and a decision (rather than an omission) on encryption at rest.
The trap is the label: this box holds backup-grade data while not being The trap is the label: this box holds backup-grade data while not being
called a backup, so it can end up with backup-grade *exposure* and called a backup, so it can end up with backup-grade _exposure_ and
non-backup-grade *controls*. Nobody puts a migration staging area on non-backup-grade _controls_. Nobody puts a migration staging area on
the access-review list. the access-review list.
### What a snapshot contains ### What a snapshot contains
@ -202,7 +202,7 @@ gap is tracked separately.
### Retention ### Retention
Retention lives on the *sending* side (last-N by count, swept Retention lives on the _sending_ side (last-N by count, swept
periodically), not here. Count rather than age is deliberate: a count periodically), not here. Count rather than age is deliberate: a count
is bounded by construction, whereas an age policy silently scales disk is bounded by construction, whereas an age policy silently scales disk
usage with how hot a hive runs. usage with how hot a hive runs.

View file

@ -78,8 +78,8 @@ existing containers can't be started.
### `RestrictAddressFamilies` fails as "Address family not supported by protocol" ### `RestrictAddressFamilies` fails as "Address family not supported by protocol"
A unit whose `RestrictAddressFamilies` omits a family gets `EAFNOSUPPORT` A unit whose `RestrictAddressFamilies` omits a family gets `EAFNOSUPPORT`
(errno 97) back from `socket()`. Clients surface that as *"tcp open error: (errno 97) back from `socket()`. Clients surface that as _"tcp open error:
Address family not supported by protocol"* — the message names the Address family not supported by protocol"_ — the message names the
**protocol** and never the **sandbox**, so it reads like a dead network, a **protocol** and never the **sandbox**, so it reads like a dead network, a
missing route, or an IPv6 problem. missing route, or an IPv6 problem.
@ -98,7 +98,7 @@ re-checks it when the program changes.** A unit that only served a unix
socket when it was written is correct at `[ "AF_UNIX" ]` and silently wrong socket when it was written is correct at `[ "AF_UNIX" ]` and silently wrong
the day someone adds an HTTP client. Check the unit in the same commit as the day someone adds an HTTP client. Check the unit in the same commit as
the client — and when narrowing it, prefer a test that derives the required the client — and when narrowing it, prefer a test that derives the required
families from the code (which fails on the *next* client too) over one that families from the code (which fails on the _next_ client too) over one that
asserts today's list. asserts today's list.
### `register_agent` is idempotent ### `register_agent` is idempotent
@ -120,7 +120,7 @@ operator's host-level `allowUnfree` does **not** propagate in.
Operators don't need to set anything on their side. Operators don't need to set anything on their side.
That same isolation is why an agent can't pick a claude out of a That same isolation is why an agent can't pick a claude out of a
*different* nixpkgs by itself: a container only ever sees the one _different_ nixpkgs by itself: a container only ever sees the one
nixpkgs the meta flake injects, so an `agent.nix` naming the host's nixpkgs the meta flake injects, so an `agent.nix` naming the host's
`nixpkgs-unstable` has nothing to name. A release channel can trail `nixpkgs-unstable` has nothing to name. A release channel can trail
unstable by weeks on this package, which is what unstable by weeks on this package, which is what
@ -140,7 +140,7 @@ an input only because a docs tree has no runtime dependencies.
The `storePath` trap is worth spelling out, because it is not confined The `storePath` trap is worth spelling out, because it is not confined
to options the operator writes: **any** option of type `package` fed a to options the operator writes: **any** option of type `package` fed a
store-path *string* coerces through `lib.toDerivation`, i.e. store-path _string_ coerces through `lib.toDerivation`, i.e.
`builtins.storePath`. `environment.systemPackages` and `builtins.storePath`. `environment.systemPackages` and
`systemd.services.<name>.path` both do it (the latter takes plain `systemd.services.<name>.path` both do it (the latter takes plain
strings like `/run/wrappers` happily, but anything under strings like `/run/wrappers` happily, but anything under

View file

@ -8,7 +8,7 @@ human review that already happened.
Whether a PR can merge, and what counts toward "can", is configured Whether a PR can merge, and what counts toward "can", is configured
per repo in its branch-protection settings — not a fact true of every per repo in its branch-protection settings — not a fact true of every
hive or every repo. The pieces a repo *can* require: hive or every repo. The pieces a repo _can_ require:
- **CI is green** — the repo's required status checks pass on the - **CI is green** — the repo's required status checks pass on the
PR's current head commit, if the repo requires any. PR's current head commit, if the repo requires any.
@ -40,8 +40,8 @@ independently.
Auto-merge isn't "no human ever looked at this." Whoever arms it has Auto-merge isn't "no human ever looked at this." Whoever arms it has
already judged the PR sound at a coarse level — the signal it sends is already judged the PR sound at a coarse level — the signal it sends is
roughly *"apart from maybe minor tweaks a reviewer can still catch, roughly _"apart from maybe minor tweaks a reviewer can still catch,
I think this is fine."* That's the human-in-the-loop step, and it I think this is fine."_ That's the human-in-the-loop step, and it
already happened. No large changes are expected to surface after already happened. No large changes are expected to surface after
that point — a reviewer's job past that point is to flag it if one that point — a reviewer's job past that point is to flag it if one
does, not to assume none ever will. does, not to assume none ever will.

View file

@ -18,11 +18,11 @@ Two things worth knowing before you rely on it:
already has forge access; if you take outside/fork contributions, already has forge access; if you take outside/fork contributions,
gate CI behind Forgejo's fork-PR-approval setting or restrict the gate CI behind Forgejo's fork-PR-approval setting or restrict the
workflow to push-only triggers (forks can't push to your branches). workflow to push-only triggers (forks can't push to your branches).
See *Security* below for the full threat model. See _Security_ below for the full threat model.
- **Watch your disk.** CI builds through the host's nix store with no - **Watch your disk.** CI builds through the host's nix store with no
automatic garbage collection of its own — a busy CI day can fill automatic garbage collection of its own — a busy CI day can fill
`/nix/store` until every job fails with `ENOSPC`. Add the daily + `/nix/store` until every job fails with `ENOSPC`. Add the daily +
disk-pressure GC config from *Host store maintenance* below to your disk-pressure GC config from _Host store maintenance_ below to your
host's NixOS config (not optional if you plan to actually use this). host's NixOS config (not optional if you plan to actually use this).
Everything below this point is implementation detail: exact Everything below this point is implementation detail: exact
@ -36,11 +36,11 @@ defined in [`.forgejo/workflows/ci.yml`](../../.forgejo/workflows/ci.yml). All
three are required checks (forge branch protection) — a hit on any of them three are required checks (forge branch protection) — a hit on any of them
blocks merge. blocks merge.
| Job | What it runs | | Job | What it runs |
| --- | --- | | ---------------------- | ----------------------------------------------------------------------------------------- |
| **nix flake check** | treefmt + rustfmt formatting, `cargo clippy -D warnings`, `cargo test`, module evaluation | | **nix flake check** | treefmt + rustfmt formatting, `cargo clippy -D warnings`, `cargo test`, module evaluation |
| **tracker-tag lint** | flags `#NNN` issue tags in source and comments (`scripts/check-issue-refs.sh`) | | **tracker-tag lint** | flags `#NNN` issue tags in source and comments (`scripts/check-issue-refs.sh`) |
| **comment-block lint** | flags contiguous comment blocks over 30 lines (`scripts/check-comment-blocks.sh`) | | **comment-block lint** | flags contiguous comment blocks over 30 lines (`scripts/check-comment-blocks.sh`) |
`hive-forge ci-rerun --pr N` dispatches a `workflow_dispatch` retrigger `hive-forge ci-rerun --pr N` dispatches a `workflow_dispatch` retrigger
without an empty commit. without an empty commit.
@ -90,7 +90,7 @@ slow); run those manually before pushing Rust changes.
The internal forge is always present (mandatory), so the runner always has a The internal forge is always present (mandatory), so the runner always has a
hive-forge instance to register against — nothing extra to enable beyond hive-forge instance to register against — nothing extra to enable beyond
`services.hyperhive.deploy.forgejo.ci.enable = true` (see *For operators* above). `services.hyperhive.deploy.forgejo.ci.enable = true` (see _For operators_ above).
Optional tuning: `services.hyperhive.deploy.forgejo.ci.name` (runner name in forge Optional tuning: `services.hyperhive.deploy.forgejo.ci.name` (runner name in forge
admin panel), `concurrency` (parallel job capacity), `labels` (workflow admin panel), `concurrency` (parallel job capacity), `labels` (workflow
@ -131,7 +131,7 @@ Gated on `HYPERHIVE_FORGE_CI_ENABLED` (the nix module sets it on `hive-c0re.serv
- The container boots immediately — nothing gates its start on registration. - The container boots immediately — nothing gates its start on registration.
- tmpfiles seeds `/run/hive-ci/runner-token` with `TOKEN=placeholder` so the runner's `EnvironmentFile` always exists. - tmpfiles seeds `/run/hive-ci/runner-token` with `TOKEN=placeholder` so the runner's `EnvironmentFile` always exists.
- `gitea-runner-hive.service` has an `ExecStartPre` precond (ahead of the nix-daemon wait) that **fails fast** unless it is already registered (`.runner` present) or a real, non-placeholder token is in place. `Restart=on-failure` (no start-limit cap) self-heals it: a runner that precond-fails at boot keeps retrying until hive-c0re writes the token (c0re's explicit restart is the primary path; the retry is the safety net). - `gitea-runner-hive.service` has an `ExecStartPre` precond (ahead of the nix-daemon wait) that **fails fast** unless it is already registered (`.runner` present) or a real, non-placeholder token is in place. `Restart=on-failure` (no start-limit cap) self-heals it: a runner that precond-fails at boot keeps retrying until hive-c0re writes the token (c0re's explicit restart is the primary path; the retry is the safety net).
- **Convergence**: because the token write targets the *host* file, even if c0re's restart races the container being down, the container later starts, reads the now-real token, passes the precond, and registers on its own. - **Convergence**: because the token write targets the _host_ file, even if c0re's restart races the container being down, the container later starts, reads the now-real token, passes the precond, and registers on its own.
## Actions checkout mirror ## Actions checkout mirror

View file

@ -19,7 +19,7 @@ changes) is submitted to the global job-DAG queue (`hive-c0re/src/job_queue/`)
as a **DAG of primitive nodes**. One scheduler task drives all DAGs; as a **DAG of primitive nodes**. One scheduler task drives all DAGs;
concurrency comes from the resource classes below, not from multiple workers. concurrency comes from the resource classes below, not from multiple workers.
Special cases like graceful stop, deferred starts, and the meta-update Special cases like graceful stop, deferred starts, and the meta-update
cascade need no bespoke code paths — each is expressed as a DAG *shape* cascade need no bespoke code paths — each is expressed as a DAG _shape_
built from the same primitive nodes. built from the same primitive nodes.
### Two levels: DAG and node ### Two levels: DAG and node
@ -42,54 +42,54 @@ there is no malformed spec to reject.
Nix-heavy — hold one of the `buildSlots` permits for the node's duration: Nix-heavy — hold one of the `buildSlots` permits for the node's duration:
| Node | Wraps | | Node | Wraps |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Prebuild` | `lifecycle::prebuild_toplevel` — build the toplevel out-of-band while the container keeps serving (its meta preamble is the upstream `MetaSync` node). Skipped when the container is already down; `Swap` builds inline instead | | `Prebuild` | `lifecycle::prebuild_toplevel` — build the toplevel out-of-band while the container keeps serving (its meta preamble is the upstream `MetaSync` node). Skipped when the container is already down; `Swap` builds inline instead |
| `Swap` | drop-in rewrite + `nixos-container update` profile-swap (requires the container stopped); the post-swap bookkeeping tail lives in the sibling `RebuildBookkeeping` node | | `Swap` | drop-in rewrite + `nixos-container update` profile-swap (requires the container stopped); the post-swap bookkeeping tail lives in the sibling `RebuildBookkeeping` node |
| `Create` | first-spawn `nixos-container create` proper; assumes the upstream `Provision` node already registered the agent in meta | | `Create` | first-spawn `nixos-container create` proper; assumes the upstream `Provision` node already registered the agent in meta |
| `MetaLock` | meta flake lock bump (`lock_update` / boot-sweep `lock_update_hyperhive`, commit fused — see below); fans out child `Rebuild` DAGs on completion | | `MetaLock` | meta flake lock bump (`lock_update` / boot-sweep `lock_update_hyperhive`, commit fused — see below); fans out child `Rebuild` DAGs on completion |
| `DeployWindow` | resource-holding root of the merge-config-PR deploy subtree — declares the build slot, the lease and the meta window, then completes immediately so its children run under them (see _Approvals_ below) | | `DeployWindow` | resource-holding root of the merge-config-PR deploy subtree — declares the build slot, the lease and the meta window, then completes immediately so its children run under them (see _Approvals_ below) |
| `DeployApply` | the deploy's irreversible half: ff-merge the reviewed PR head, two-phase meta deploy, container rebuild | | `DeployApply` | the deploy's irreversible half: ff-merge the reviewed PR head, two-phase meta deploy, container rebuild |
Cheap — no build slot: Cheap — no build slot:
| Node | Behavior | | Node | Behavior |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MergeVerify` | the deploy's pre-merge gate — PR-head drift check, fetch, `verify_commit` eval. Mutates nothing, so a rejection here needs no compensation | | `MergeVerify` | the deploy's pre-merge gate — PR-head drift check, fetch, `verify_commit` eval. Mutates nothing, so a rejection here needs no compensation |
| `DeployTail` | the deploy's `AfterAny` compensation + bookkeeping tail: (1) rolls `applied/main` back from the parked `refs/hyperhive/rollback/<id>` and aborts the staged meta lock when the deploy never confirmed good; (2) mirrors whichever deploy tag landed to the forge config repo, always, best-effort; (3) posts the failing build log back onto the config PR when the deploy failed. Named for (2)/(3), which run on the success path too — not `AbortDeploy`. Infallible by construction | | `DeployTail` | the deploy's `AfterAny` compensation + bookkeeping tail: (1) rolls `applied/main` back from the parked `refs/hyperhive/rollback/<id>` and aborts the staged meta lock when the deploy never confirmed good; (2) mirrors whichever deploy tag landed to the forge config repo, always, best-effort; (3) posts the failing build log back onto the config PR when the deploy failed. Named for (2)/(3), which run on the success path too — not `AbortDeploy`. Infallible by construction |
| `MetaSync` | the rebuild's meta preamble — rebuild-dir prep, idempotent meta `sync_agents`, optional per-agent relock. Holds the `MetaWindow` resource (below); deliberately its own node so the window never covers `Prebuild`'s multi-minute build | | `MetaSync` | the rebuild's meta preamble — rebuild-dir prep, idempotent meta `sync_agents`, optional per-agent relock. Holds the `MetaWindow` resource (below); deliberately its own node so the window never covers `Prebuild`'s multi-minute build |
| `Provision` | first-spawn pre-create provisioning — proposed/applied repos, state subvolume, meta registration (`sync_agents`); runs ahead of `Create` so the `nixos-container create --flake meta#<name>` ref resolves. Store/meta-only, no container yet | | `Provision` | first-spawn pre-create provisioning — proposed/applied repos, state subvolume, meta registration (`sync_agents`); runs ahead of `Create` so the `nixos-container create --flake meta#<name>` ref resolves. Store/meta-only, no container yet |
| `Reconcile` | idempotent power converge: read `wanted` (below) + observed state; start if `Up` & down (cold-start fallback included), stop if `Offline` & up, else noop | | `Reconcile` | idempotent power converge: read `wanted` (below) + observed state; start if `Up` & down (cold-start fallback included), stop if `Offline` & up, else noop |
| `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down | | `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down |
| `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up | | `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up |
| `StopForUpdate` | mechanical `nixos-container stop` for the profile swap; never touches `wanted`; noop if already stopped | | `StopForUpdate` | mechanical `nixos-container stop` for the profile swap; never touches `wanted`; noop if already stopped |
| `RebuildBookkeeping` | the swap's Ok-only bookkeeping tail — rev marker, forge/matrix sync, manager kick, rescan, meta-inputs snapshot; `AfterOk(Swap)` so it runs only on a successful swap (the `Rebuilt` manager event is emitted by the DAG's `EmitRebuilt` tail node, not here). Split out of `Swap` for dashboard visibility + retry granularity, declares no resources of its own — a coordinated child of the `AgentWindow` brace | | `RebuildBookkeeping` | the swap's Ok-only bookkeeping tail — rev marker, forge/matrix sync, manager kick, rescan, meta-inputs snapshot; `AfterOk(Swap)` so it runs only on a successful swap (the `Rebuilt` manager event is emitted by the DAG's `EmitRebuilt` tail node, not here). Split out of `Swap` for dashboard visibility + retry granularity, declares no resources of its own — a coordinated child of the `AgentWindow` brace |
| `AgentWindow` | pure resource holder — the brace for one agent's rebuild. Declares the build slot + agent lease atomically and holds both for its whole subtree, so `Prebuild` and the `Signal``Drain` quiesce window run concurrently instead of one nested under the other. Performs no work; see _Braces_ | | `AgentWindow` | pure resource holder — the brace for one agent's rebuild. Declares the build slot + agent lease atomically and holds both for its whole subtree, so `Prebuild` and the `Signal``Drain` quiesce window run concurrently instead of one nested under the other. Performs no work; see _Braces_ |
| `Signal` | set the graceful fence + kick, so the harness runs one stop-checkpoint turn | | `Signal` | set the graceful fence + kick, so the harness runs one stop-checkpoint turn |
| `Drain` | await the harness clearing the fence, bounded by the 3-min graceful-stop timeout; resolves ok either way | | `Drain` | await the harness clearing the fence, bounded by the 3-min graceful-stop timeout; resolves ok either way |
| `PauseSignal` | write the pause marker + mark `pause_pending`. No kick, unlike `Signal` — the harness's between-turns poll is already responsive enough, and `Signal`'s kick-message body ("you were just (re)started") would be actively misleading here | | `PauseSignal` | write the pause marker + mark `pause_pending`. No kick, unlike `Signal` — the harness's between-turns poll is already responsive enough, and `Signal`'s kick-message body ("you were just (re)started") would be actively misleading here |
| `PauseDrain` | await the harness reporting `PauseAcknowledged`, bounded timeout; best-effort like `Drain` | | `PauseDrain` | await the harness reporting `PauseAcknowledged`, bounded timeout; best-effort like `Drain` |
| `DestroyContainer` | `nixos-container destroy` + un-registration (drop from the roster, clear the ephemeral runtime dir). Runs downstream of a `Stop`, so deliberately excluded from `takes_container_down` — the container is already down by the time it claims | | `DestroyContainer` | `nixos-container destroy` + un-registration (drop from the roster, clear the ephemeral runtime dir). Runs downstream of a `Stop`, so deliberately excluded from `takes_container_down` — the container is already down by the time it claims |
| `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state/applied dirs. Own node because it's conditional and the irreversible step | | `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state/applied dirs. Own node because it's conditional and the irreversible step |
| `DestroyBookkeeping` | the post-destroy tail — meta sync, fail pending approvals, drop the power intent, notify the manager, rescan, re-emit the tombstone, resync tmpfiles. Same split rationale as `RebuildBookkeeping`/`Swap`. Its `purge` flag only selects the wording of the approval-failure reason and the manager notification — the destructive work is `PurgeState`'s | | `DestroyBookkeeping` | the post-destroy tail — meta sync, fail pending approvals, drop the power intent, notify the manager, rescan, re-emit the tombstone, resync tmpfiles. Same split rationale as `RebuildBookkeeping`/`Swap`. Its `purge` flag only selects the wording of the approval-failure reason and the manager notification — the destructive work is `PurgeState`'s |
| `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG, replacing the old pre-submit side effect. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles | | `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG, replacing the old pre-submit side effect. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles |
| `FinalizeDeploy` | deploy phase 3 — drop the rollback ref, plant `deployed/<id>`, commit the staged `flake.lock`. The first two git steps are fatal on purpose, so a confirmed-good deploy's outcome and the repo's state can't disagree | | `FinalizeDeploy` | deploy phase 3 — drop the rollback ref, plant `deployed/<id>`, commit the staged `flake.lock`. The first two git steps are fatal on purpose, so a confirmed-good deploy's outcome and the repo's state can't disagree |
| `ResolveApproval` | tail of an approval-carrying DAG — resolve the approval row from how the work ended (`AfterAny`, one node emitted per outcome). Agentless: the approval row already names its agent | | `ResolveApproval` | tail of an approval-carrying DAG — resolve the approval row from how the work ended (`AfterAny`, one node emitted per outcome). Agentless: the approval row already names its agent |
| `EmitRebuilt` | tail of a rebuild/perm-change — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent *and* per outcome | | `EmitRebuilt` | tail of a rebuild/perm-change — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome |
| `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload | | `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload |
| `WritePermFile` | commit `tool-groups.json` / `capabilities.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshots | | `WritePermFile` | commit `tool-groups.json` / `capabilities.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshots |
| `Reparent` | `set-parent` / `set-parent-bulk`: apply every `(child, new_parent)` move under one `META_LOCK` commit (`meta::bulk_commit_topology`), send the per-agent move notifications, rescan + diff-emit. Agentless like `MetaLock` — a bulk move can span multiple agents, and a reparent touches the meta repo, not any one container. `moves` is typed `(Ident, Option<Ident>)` pairs, not raw strings. Rides the existing `Template::MetaUpdate` variant rather than a dedicated one — it's internal-only (never reaches the graph wire), so the stand-in only affects `terminal_hook` dispatch (resolves to no hook either way) and history-retention bucketing | | `Reparent` | `set-parent` / `set-parent-bulk`: apply every `(child, new_parent)` move under one `META_LOCK` commit (`meta::bulk_commit_topology`), send the per-agent move notifications, rescan + diff-emit. Agentless like `MetaLock` — a bulk move can span multiple agents, and a reparent touches the meta repo, not any one container. `moves` is typed `(Ident, Option<Ident>)` pairs, not raw strings. Rides the existing `Template::MetaUpdate` variant rather than a dedicated one — it's internal-only (never reaches the graph wire), so the stand-in only affects `terminal_hook` dispatch (resolves to no hook either way) and history-retention bucketing |
| `ForgeSweep` | one-shot boot-time forge user/token sweep for every container (`forge::ensure_all`) as a first-class node, so it shows as real work on the dashboard instead of running invisibly in a bare `tokio::spawn`. Agentless | | `ForgeSweep` | one-shot boot-time forge user/token sweep for every container (`forge::ensure_all`) as a first-class node, so it shows as real work on the dashboard instead of running invisibly in a bare `tokio::spawn`. Agentless |
| `MatrixSweep` | same as `ForgeSweep`, for matrix (`matrix::ensure_all`). The periodic 30-min re-sweep stays a background loop in `main.rs`; only the boot-time instance is a node | | `MatrixSweep` | same as `ForgeSweep`, for matrix (`matrix::ensure_all`). The periodic 30-min re-sweep stays a background loop in `main.rs`; only the boot-time instance is a node |
| `WebhookRegister` | one-shot boot-time Forgejo webhook registration (`internal/knowledge` push→pull, `agent-configs` PR→approval). No-op until the core token, hive domain, and HMAC secret are all available. Agentless | | `WebhookRegister` | one-shot boot-time Forgejo webhook registration (`internal/knowledge` push→pull, `agent-configs` PR→approval). No-op until the core token, hive domain, and HMAC secret are all available. Agentless |
| `KnowledgePull` | one-shot boot-time `/knowledge` pull (`knowledge::pull`), reconciling commits that landed while `hive-c0re` was down. Same rationale as `MatrixSweep`: the periodic hourly re-pull stays a background loop | | `KnowledgePull` | one-shot boot-time `/knowledge` pull (`knowledge::pull`), reconciling commits that landed while `hive-c0re` was down. Same rationale as `MatrixSweep`: the periodic hourly re-pull stays a background loop |
| `WantedPull` | one-shot boot-time pull of the agent set the swarm controller declares for this hive (`wanted::pull`), converging the agents it names. No background loop behind this one — boot is the whole cadence; the deploy event (`swarm_status`) is the fast path, this repairs a missed one. Agentless | | `WantedPull` | one-shot boot-time pull of the agent set the swarm controller declares for this hive (`wanted::pull`), converging the agents it names. No background loop behind this one — boot is the whole cadence; the deploy event (`swarm_status`) is the fast path, this repairs a missed one. Agentless |
There is deliberately **no `GitCommit` node**: `meta.rs` fuses each mutation There is deliberately **no `GitCommit` node**: `meta.rs` fuses each mutation
with its commit under its internal `META_LOCK` mutex, so a standalone commit with its commit under its internal `META_LOCK` mutex, so a standalone commit
node would open a dirty-working-tree window between nodes. node would open a dirty-working-tree window between nodes.
Two further layers protect the meta repo across *windows* that span multiple Two further layers protect the meta repo across _windows_ that span multiple
`META_LOCK` acquisitions — above all the approval deploy's prepare→finalize `META_LOCK` acquisitions — above all the approval deploy's prepare→finalize
span, which keeps a bumped `flake.lock` **staged uncommitted** for the whole span, which keeps a bumped `flake.lock` **staged uncommitted** for the whole
container build: container build:
@ -104,7 +104,7 @@ container build:
because a resource is held by a subtree root across its whole subtree, which because a resource is held by a subtree root across its whole subtree, which
a `MutexGuard` (bounded by one executor fn) cannot — that is what lets a a `MutexGuard` (bounded by one executor fn) cannot — that is what lets a
multi-node deploy own one window. For the same reason the window must stay multi-node deploy own one window. For the same reason the window must stay
*off* long store-only work: the rebuild's meta preamble is its own _off_ long store-only work: the rebuild's meta preamble is its own
`MetaSync` node, a sibling of (never a parent of) `Prebuild`, so the `MetaSync` node, a sibling of (never a parent of) `Prebuild`, so the
toplevel build runs outside the window and `buildSlots > 1` still gives toplevel build runs outside the window and `buildSlots > 1` still gives
concurrent rebuilds across agents. concurrent rebuilds across agents.
@ -119,10 +119,10 @@ container build:
The `stop` / `start` power ops write the durable `wanted` intent via a head The `stop` / `start` power ops write the durable `wanted` intent via a head
`SetWanted` node (not a pre-submit side effect) — it holds the agent lease, `SetWanted` node (not a pre-submit side effect) — it holds the agent lease,
so intent-write + reconcile is atomic per-agent. `restart` is the exception: so intent-write + reconcile is atomic per-agent. `restart` is the exception:
it writes *no* intent (no `SetWanted` head) — it bounces the container and it writes _no_ intent (no `SetWanted` head) — it bounces the container and
lets the tail `Reconcile` converge to the agent's existing `wanted`, so a lets the tail `Reconcile` converge to the agent's existing `wanted`, so a
deliberately-stopped agent is not forced back up by a hive-wide restart. The deliberately-stopped agent is not forced back up by a hive-wide restart. The
hive-wide power ops — `restart`, `stop`, and `start` — take an agent *list*: hive-wide power ops — `restart`, `stop`, and `start` — take an agent _list_:
a hive-wide `hivectl restart` / `stop` / `start` is ONE DAG with a per-agent a hive-wide `hivectl restart` / `stop` / `start` is ONE DAG with a per-agent
subgraph each (independent roots, run concurrently on their own leases), not subgraph each (independent roots, run concurrently on their own leases), not
N separate DAGs. N separate DAGs.
@ -132,12 +132,12 @@ async `lifecycle::is_running` read), so they live in `job_queue/power.rs`,
not the pure/sync `templates.rs`. Per-agent shape rule: `stop`/`start` carry not the pure/sync `templates.rs`. Per-agent shape rule: `stop`/`start` carry
a head `SetWanted` (intent) — `restart` does not; the tail `Reconcile` a head `SetWanted` (intent) — `restart` does not; the tail `Reconcile`
(convergence guarantee — cheap, noops when already converged) is ALWAYS (convergence guarantee — cheap, noops when already converged) is ALWAYS
present; only the *mechanical* nodes (`Signal`/`Drain`/`StopForUpdate`) are present; only the _mechanical_ nodes (`Signal`/`Drain`/`StopForUpdate`) are
state-conditional — skipped for a *down* agent (nothing to quiesce/stop). Keeping `Reconcile` in every shape state-conditional — skipped for a _down_ agent (nothing to quiesce/stop). Keeping `Reconcile` in every shape
closes the TOCTOU window: if an agent flips state between the `is_running` closes the TOCTOU window: if an agent flips state between the `is_running`
read and node exec, the tail `Reconcile` still converges it in-DAG (with read and node exec, the tail `Reconcile` still converges it in-DAG (with
`StopForUpdate`-noop as the backstop) — no reliance on an external reconcile `StopForUpdate`-noop as the backstop) — no reliance on an external reconcile
sweep. `start` folds the per-agent stale-rev upgrade in (a *down + stale* sweep. `start` folds the per-agent stale-rev upgrade in (a _down + stale_
agent's subgraph is a rebuild-then-start). agent's subgraph is a rebuild-then-start).
```text ```text
@ -163,7 +163,7 @@ Notable collapses:
leaves it stopped). leaves it stopped).
- **The swap-failure recovery-start is structural**: `Reconcile` deps on - **The swap-failure recovery-start is structural**: `Reconcile` deps on
`Swap` with the one `AfterAny` edge in the system — it runs after `Swap` `Swap` with the one `AfterAny` edge in the system — it runs after `Swap`
terminal ok *or* fail, bringing a wanted-up agent back on its old config. terminal ok _or_ fail, bringing a wanted-up agent back on its old config.
- **Deferred start is automatic**: `Reconcile` holds no build slot, so the - **Deferred start is automatic**: `Reconcile` holds no build slot, so the
next DAG's `Prebuild` starts as soon as `Swap` frees the slot. next DAG's `Prebuild` starts as soon as `Swap` frees the slot.
- **Graceful stop needs no watcher thread**: `Signal`/`Drain` are cheap, so a - **Graceful stop needs no watcher thread**: `Signal`/`Drain` are cheap, so a
@ -171,7 +171,7 @@ Notable collapses:
drains overlap; each DAG's tail `Reconcile` does the actual stop. drains overlap; each DAG's tail `Reconcile` does the actual stop.
- **The meta-update cascade grows in the same DAG on completion**: - **The meta-update cascade grows in the same DAG on completion**:
`MetaLock`'s executor computes the affected agent set after the bump lands `MetaLock`'s executor computes the affected agent set after the bump lands
and grows one `rebuild` subgraph per agent into its *own* DAG via and grows one `rebuild` subgraph per agent into its _own_ DAG via
`append_subgraph` (rooted on the `MetaLock`, `relock = false` so the cascade `append_subgraph` (rooted on the `MetaLock`, `relock = false` so the cascade
doesn't revert the bump). Not child DAGs — one DAG, no `parent_id`. A failed doesn't revert the bump). Not child DAGs — one DAG, no `parent_id`. A failed
bump appends nothing (no cancel-children dance). Same shape as the startup bump appends nothing (no cancel-children dance). Same shape as the startup
@ -180,9 +180,9 @@ Notable collapses:
### Desired-state (spec vs status) ### Desired-state (spec vs status)
Per-agent power *intent*`wanted: Up | Offline` — is durable as the Per-agent power _intent_`wanted: Up | Offline` — is durable as the
`agent_power` table in the coordinator DB (`hive-c0re/src/stores/power.rs`). `agent_power` table in the coordinator DB (`hive-c0re/src/stores/power.rs`).
`container_view` remains the observed *status*; `Reconcile` nodes converge the `container_view` remains the observed _status_; `Reconcile` nodes converge the
two. Setting `wanted` is never a queued node: the power layer two. Setting `wanted` is never a queued node: the power layer
(`job_queue/power.rs`) writes the row synchronously, then inserts the DAG (`job_queue/power.rs`) writes the row synchronously, then inserts the DAG
whose `Reconcile` reads the fresh value — rapid toggles are last-writer-wins. whose `Reconcile` reads the fresh value — rapid toggles are last-writer-wins.
@ -237,15 +237,15 @@ than to one DAG shape it happens to appear in. A **brace** is the one sanctioned
exception: a pure-resource-holder root that declares on behalf of a subtree exception: a pure-resource-holder root that declares on behalf of a subtree
coordinated with itself, whose members then declare nothing. coordinated with itself, whose members then declare nothing.
It is forced rather than stylistic. Declaring a resource means *"I need this It is forced rather than stylistic. Declaring a resource means _"I need this
exclusively"*, and the agent lease is single-unit — so **two siblings that both exclusively"_, and the agent lease is single-unit — so **two siblings that both
declared it could never run concurrently.** For a subtree whose whole point is declared it could never run concurrently.** For a subtree whose whole point is
concurrency (`Prebuild` beside the `Signal``Drain` quiesce window), declaring concurrency (`Prebuild` beside the `Signal``Drain` quiesce window), declaring
the requirement truthfully on every node and running those nodes in parallel are the requirement truthfully on every node and running those nodes in parallel are
mutually exclusive. One holder above them speaks for the subtree. mutually exclusive. One holder above them speaks for the subtree.
This is the opposite of the failure the declare-your-own rule exists to prevent, This is the opposite of the failure the declare-your-own rule exists to prevent,
not a relapse into it: there the requirement was *implicit*, inferred from a not a relapse into it: there the requirement was _implicit_, inferred from a
node's kind and true only by accident of placement. Here it is explicit, on one node's kind and true only by accident of placement. Here it is explicit, on one
node, with the omission below it documented on the brace itself. node, with the omission below it documented on the brace itself.
@ -257,7 +257,7 @@ Two consequences worth knowing:
leave a gap another DAG could claim the agent in, mid-bounce. A brace supplies leave a gap another DAG could claim the agent in, mid-bounce. A brace supplies
that continuity directly, so the nesting is no longer load-bearing. that continuity directly, so the nesting is no longer load-bearing.
- **Observability is unaffected.** `running_transients` keys off a node's - **Observability is unaffected.** `running_transients` keys off a node's
*payload* agent, not off a declared lease edge, so every child still lights its _payload_ agent, not off a declared lease edge, so every child still lights its
own dashboard pill and still reports its own `takes_container_down` to the own dashboard pill and still reports its own `takes_container_down` to the
crash watcher. A brace itself reports `false`: it parents the stopping nodes crash watcher. A brace itself reports `false`: it parents the stopping nodes
but does not stop anything, and claiming otherwise would widen crash but does not stop anything, and claiming otherwise would widen crash
@ -323,12 +323,12 @@ cancelled-while-queued, which fails the approval instead of dangling it).
### Wire shape ### Wire shape
`RebuildQueueChanged { seq }` (event name kept) — **a bare trigger, no `RebuildQueueChanged { seq }` (event name kept) — **a bare trigger, no
payload.** It says *the queue changed*; a client that wants to know how payload.** It says _the queue changed_; a client that wants to know how
re-fetches `GET /api/jobq/graph`. re-fetches `GET /api/jobq/graph`.
That endpoint serves the graph generically (`hive-jobq-wire`): every node That endpoint serves the graph generically (`hive-jobq-wire`): every node
carries `id`, `parent`, `deps`, `state`, `label` (the node kind's own wire carries `id`, `parent`, `deps`, `state`, `label` (the node kind's own wire
string — the kind *is* the phase label, there is no separate sub-step string — the kind _is_ the phase label, there is no separate sub-step
string) and free-form `data` for what only some kinds have (`agent`, string) and free-form `data` for what only some kinds have (`agent`,
`approval_id`, `inputs`, `build_log_id`). Group roots ride as ordinary `approval_id`, `inputs`, `build_log_id`). Group roots ride as ordinary
nodes, so a group's state is just the root's own `state`. nodes, so a group's state is just the root's own `state`.
@ -341,7 +341,7 @@ live-log panel off the running node.
The event carries no payload by design: shipping a typed projection of The event carries no payload by design: shipping a typed projection of
the whole queue in the event itself would be a second rendering of the the whole queue in the event itself would be a second rendering of the
same graph that has to be kept in agreement by hand with the endpoint same graph that has to be kept in agreement by hand with the endpoint
every consumer actually reads. Telling a client *when* to refetch is every consumer actually reads. Telling a client _when_ to refetch is
the event's whole job. the event's whole job.
--- ---
@ -362,7 +362,7 @@ On startup, `auto_update::run` classifies every agent by rev freshness (the
per-agent `.{name}.hyperhive-rev` marker under `/var/lib/hyperhive/applied/` per-agent `.{name}.hyperhive-rev` marker under `/var/lib/hyperhive/applied/`
vs the current flake path) and persisted `wanted` intent, then: vs the current flake path) and persisted `wanted` intent, then:
1. **Config path** — when *any* marker is stale, submit one `Boot` 1. **Config path** — when _any_ marker is stale, submit one `Boot`
DAG: a `MetaLock` (hyperhive input bump, non-fatal) that grows an in-DAG DAG: a `MetaLock` (hyperhive input bump, non-fatal) that grows an in-DAG
`Rebuild` subgraph for each stale agent whose `wanted = Up` (topology-sorted, `Rebuild` subgraph for each stale agent whose `wanted = Up` (topology-sorted,
parents first). Stale but wanted-offline agents get no boot-time nix work — their parents first). Stale but wanted-offline agents get no boot-time nix work — their
@ -453,7 +453,7 @@ graft's completion then plants the deploy tag — so "did the agent come back
up?" is answered by `Reconcile` succeeding, the same way it is for every up?" is answered by `Reconcile` succeeding, the same way it is for every
other rebuild, instead of by a fused inline start. other rebuild, instead of by a fused inline start.
The grafted nodes land *inside* `DeployWindow`'s subtree, so they re-enter The grafted nodes land _inside_ `DeployWindow`'s subtree, so they re-enter
the meta window and build slot it already holds rather than deadlocking the meta window and build slot it already holds rather than deadlocking
against it. against it.
@ -514,7 +514,7 @@ The same drop-in carries `CPUWeight=` / `IOWeight=` from
`agentCpuWeight` / `agentIoWeight`. Those are a different kind of `agentCpuWeight` / `agentIoWeight`. Those are a different kind of
setting: the quota and the memory max are **hard caps** that throttle setting: the quota and the memory max are **hard caps** that throttle
an agent even on a completely idle host, while the weights are cgroup an agent even on a completely idle host, while the weights are cgroup
v2 **relative shares** that only decide who yields *under contention*. v2 **relative shares** that only decide who yields _under contention_.
A low-weight container still gets the whole machine when nothing else A low-weight container still gets the whole machine when nothing else
wants it. wants it.
@ -529,7 +529,7 @@ Two things to know about the weights:
- They are **hive-wide** — unlike the caps there is no per-agent - They are **hive-wide** — unlike the caps there is no per-agent
override in `meta/resource-limits.json`, so every agent carries the override in `meta/resource-limits.json`, so every agent carries the
same value and the weight does *not* rank agents against each other. same value and the weight does _not_ rank agents against each other.
What `80` buys is that agents yield to everything **not** on this What `80` buys is that agents yield to everything **not** on this
drop-in path: host services (nginx and dnsmasq among them) and the drop-in path: host services (nginx and dnsmasq among them) and the
infra containers (`hive-ci`, `hive-forge`, `hive-gateway`, infra containers (`hive-ci`, `hive-forge`, `hive-gateway`,

View file

@ -2,7 +2,7 @@
Every container operation — rebuild, first-spawn, a config-PR deploy, Every container operation — rebuild, first-spawn, a config-PR deploy,
power changes — runs through one shared job queue. This page explains power changes — runs through one shared job queue. This page explains
what the job queue *is*, as a general idea, independent of what any one what the job queue _is_, as a general idea, independent of what any one
subsystem uses it for. For the hive-c0re-specific step catalogue and the subsystem uses it for. For the hive-c0re-specific step catalogue and the
engineering internals (scheduler, leases, resource windows) see engineering internals (scheduler, leases, resource windows) see
[`coordinator.md`](coordinator.md) instead. [`coordinator.md`](coordinator.md) instead.
@ -23,7 +23,7 @@ Two ideas are all there is to it:
that's the whole reason things queue instead of all firing at once. that's the whole reason things queue instead of all firing at once.
The engine's whole job is: whenever a step's ordering and resource needs The engine's whole job is: whenever a step's ordering and resource needs
are both satisfied, run it. It has no opinion on what the steps *do* are both satisfied, run it. It has no opinion on what the steps _do_
that's supplied by whoever builds the graph. hive-c0re is the one thing that's supplied by whoever builds the graph. hive-c0re is the one thing
building graphs on it today, but nothing about the engine is specific to building graphs on it today, but nothing about the engine is specific to
containers or rebuilds; there's nothing stopping another subsystem from containers or rebuilds; there's nothing stopping another subsystem from
@ -38,14 +38,14 @@ nested under it are that job's steps, in order (occasionally a couple run
side by side). A step shows one of: side by side). A step shows one of:
| Glyph | Meaning | | Glyph | Meaning |
| ----- | -------------------------------------------------------- | | ----- | ------------------------------------------------------- |
| `⏸` | queued, waiting its turn | | `⏸` | queued, waiting its turn |
| `▶` | running | | `▶` | running |
| `◐` | its own work is done, waiting on a step nested under it | | `◐` | its own work is done, waiting on a step nested under it |
| `✔` | finished successfully | | `✔` | finished successfully |
| `✖` | failed | | `✖` | failed |
| `⊘` | cancelled | | `⊘` | cancelled |
| `·` | skipped (not needed for this run) | | `·` | skipped (not needed for this run) |
A step that isn't needed for a given run shows as `·` rather than being A step that isn't needed for a given run shows as `·` rather than being
left out of the tree entirely, so the same kind of operation keeps a left out of the tree entirely, so the same kind of operation keeps a

View file

@ -46,7 +46,7 @@ no longer survives independently of anything host-side.
**It has no application-level auth.** The receiver takes any OTLP that reaches **It has no application-level auth.** The receiver takes any OTLP that reaches
it; what bounds who can reach it is the firewall — `exposeHostPorts` opens the it; what bounds who can reach it is the firewall — `exposeHostPorts` opens the
port on the bridge interface only. So "unauthenticated to a bridge address" port on the bridge interface only. So "unauthenticated to a bridge address"
means *reachable from an agent container*, not *presents a credential*. means _reachable from an agent container_, not _presents a credential_.
The consequence, stated because it is a choice rather than an oversight: **any The consequence, stated because it is a choice rather than an oversight: **any
agent can push arbitrary OTLP, and it is forwarded on under the operator's agent can push arbitrary OTLP, and it is forwarded on under the operator's
@ -56,10 +56,10 @@ smuggled out in resource attributes on an otherwise-legitimate export.
That is a **different risk from the one the collector fixes**, and strictly That is a **different risk from the one the collector fixes**, and strictly
smaller than what preceded it: before, every agent held the upstream credential smaller than what preceded it: before, every agent held the upstream credential
itself, so it could do all of the above *and* use the token anywhere else. The itself, so it could do all of the above _and_ use the token anywhere else. The
collector removes the token and keeps the pipe. Agents are inside the trust collector removes the token and keeps the pipe. Agents are inside the trust
boundary (`docs/trust-boundary/security.md`: capability = accepted risk), so an agent being boundary (`docs/trust-boundary/security.md`: capability = accepted risk), so an agent being
able to *send* is an accepted extension of that boundary — but it is not able to _send_ is an accepted extension of that boundary — but it is not
closed by this design, and nothing here should be read as closing it. closed by this design, and nothing here should be read as closing it.
**The `agent` label is self-reported, and no planned authentication changes **The `agent` label is self-reported, and no planned authentication changes
@ -73,13 +73,13 @@ is getting a credential:
- **agent→collector** (this section's hop) stays open on the bridge. Nothing - **agent→collector** (this section's hop) stays open on the bridge. Nothing
downstream can tell one agent's export from another's. downstream can tell one agent's export from another's.
- **hive→swarm** is where the planned ingest auth goes. The swarm tier stamps - **hive→swarm** is where the planned ingest auth goes. The swarm tier stamps
`hive=` from the connection it authenticated, so *that* label becomes `hive=` from the connection it authenticated, so _that_ label becomes
unforgeable. unforgeable.
So a verified `hive` is reachable and a verified `agent` is not — and that falls So a verified `hive` is reachable and a verified `agent` is not — and that falls
out of the topology rather than being a gap someone forgot to close. The swarm out of the topology rather than being a gap someone forgot to close. The swarm
runs one collector, and the mechanism gives it no finer grain: a bearer-token runs one collector, and the mechanism gives it no finer grain: a bearer-token
check never reveals *which* token matched, and a receiver reads request metadata check never reveals _which_ token matched, and a receiver reads request metadata
rather than the claims it authenticated with. rather than the claims it authenticated with.
If you need per-agent numbers you can act on, take them from the agent's own If you need per-agent numbers you can act on, take them from the agent's own
@ -100,10 +100,10 @@ two-tier architecture, the security model, and how the options interact.
Telemetry crosses two collectors, and which one you configure depends on what Telemetry crosses two collectors, and which one you configure depends on what
the host is: the host is:
| | runs where | receives from | does | | | runs where | receives from | does |
|---|---|---|---| | ------------------------------------ | ---------------------- | --------------------------------- | --------------------------------------------------------------------- |
| **hive tier**`otel.enable` | every hive with agents | that hive's agents, on the bridge | forwards to the swarm tier. Holds no credential, picks no destination | | **hive tier**`otel.enable` | every hive with agents | that hive's agents, on the bridge | forwards to the swarm tier. Holds no credential, picks no destination |
| **swarm tier**`deploy.swarm-otel` | once per swarm | every hive's collector | writes the swarm's store and exports upstream | | **swarm tier**`deploy.swarm-otel` | once per swarm | every hive's collector | writes the swarm's store and exports upstream |
An all-local host runs both, and needs nothing said about the hop between them. An all-local host runs both, and needs nothing said about the hop between them.
@ -119,16 +119,16 @@ services.hyperhive.otel = {
every agent needs the credential to authenticate — and the harness delivers every agent needs the credential to authenticate — and the harness delivers
that token into the agent's own `~/.claude/settings.json`, a file the agent can that token into the agent's own `~/.claude/settings.json`, a file the agent can
read. `0600` protects it from other containers, not from the agent itself. As read. `0600` protects it from other containers, not from the agent itself. As
long as the direct path stays *selectable*, that hole stays selectable; an long as the direct path stays _selectable_, that hole stays selectable; an
option that can reintroduce it is a hole with extra steps. option that can reintroduce it is a hole with extra steps.
**Why the tiers stay separate on one box.** They are not collapsed when **Why the tiers stay separate on one box.** They are not collapsed when
co-located: an all-local hive is a statement about *where* processes run, not co-located: an all-local hive is a statement about _where_ processes run, not
about the shape of the deployment. A boundary that disappears locally is one about the shape of the deployment. A boundary that disappears locally is one
the local deployment stops testing. the local deployment stops testing.
**`endpoint` keeps meaning "where telemetry goes upstream."** Neither tier **`endpoint` keeps meaning "where telemetry goes upstream."** Neither tier
redefines it — the agent-facing value is *derived* redefines it — the agent-facing value is _derived_
(`http://<bridgeIp>:<collector.port>`), so an existing deployment's `endpoint` (`http://<bridgeIp>:<collector.port>`), so an existing deployment's `endpoint`
keeps working unchanged. The bridge port is contributed to `exposeHostPorts` keeps working unchanged. The bridge port is contributed to `exposeHostPorts`
automatically; there is nothing to open by hand. automatically; there is nothing to open by hand.
@ -160,9 +160,9 @@ swarm tier and no metrics appearing for that hive.
⚠️ **`endpoint` must be valid for `protocol`.** The upstream exporter follows ⚠️ **`endpoint` must be valid for `protocol`.** The upstream exporter follows
`otel.protocol` (`grpc` → the gRPC exporter, otherwise OTLP/HTTP), and the gRPC `otel.protocol` (`grpc` → the gRPC exporter, otherwise OTLP/HTTP), and the gRPC
exporter takes an *address*: `https://host/path` is a legal exporter takes an _address_: `https://host/path` is a legal
`OTEL_EXPORTER_OTLP_ENDPOINT` for HTTP but fails as gRPC with *"missing port in `OTEL_EXPORTER_OTLP_ENDPOINT` for HTTP but fails as gRPC with _"missing port in
address"*. The collector's config is validated at build time, so a mismatch is address"_. The collector's config is validated at build time, so a mismatch is
a build error naming the reason rather than telemetry silently going nowhere. a build error naming the reason rather than telemetry silently going nowhere.
## Network access ## Network access
@ -176,7 +176,7 @@ services.hyperhive.network.exposeHostPorts = [ 5432 ];
``` ```
and point whatever consumes it at `10.42.0.1:5432` rather than loopback: inside and point whatever consumes it at `10.42.0.1:5432` rather than loopback: inside
a container, loopback is the *container*. The bridge IP is the host's address on a container, loopback is the _container_. The bridge IP is the host's address on
the `hive-br0` bridge. The service must also bind an address the bridge can the `hive-br0` bridge. The service must also bind an address the bridge can
reach — a `127.0.0.1`-only listener stays unreachable no matter what the reach — a `127.0.0.1`-only listener stays unreachable no matter what the
firewall allows. See `docs/networking/network.md::Reaching host services` for details. firewall allows. See `docs/networking/network.md::Reaching host services` for details.
@ -196,12 +196,12 @@ yourself from a tool call or `hivectl shell`.
Every agent's export therefore includes these resource attributes Every agent's export therefore includes these resource attributes
automatically: automatically:
| Attribute | Value | | Attribute | Value |
|-----------|-------| | -------------- | ------------------------------------------------------------ |
| `service.name` | `hyperhive-agent` (constant) | | `service.name` | `hyperhive-agent` (constant) |
| `agent` | agent logical name (e.g. `iris`) | | `agent` | agent logical name (e.g. `iris`) |
| `hive` | hive display name (`services.hyperhive.hiveName`) | | `hive` | hive display name (`services.hyperhive.hiveName`) |
| `swarm` | swarm display name (`services.hyperhive.swarm.name`, if set) | | `swarm` | swarm display name (`services.hyperhive.swarm.name`, if set) |
Additional labels can be appended via `extraResourceAttributes` (see option Additional labels can be appended via `extraResourceAttributes` (see option
reference above); custom per-data-point labels can be passed with reference above); custom per-data-point labels can be passed with
@ -222,14 +222,14 @@ exists, so off-the-shelf OTEL/Grafana container dashboards work. Resource
`service.name = hyperhive-c0re`; each data point is tagged `container.name` `service.name = hyperhive-c0re`; each data point is tagged `container.name`
(= the `h-<agent>` machine) and the hive `agent` label: (= the `h-<agent>` machine) and the hive `agent` label:
| Metric | Unit | Kind | Source | | Metric | Unit | Kind | Source |
|--------|------|------|--------| | ----------------------------------- | ---- | ------- | ----------------------------------------------------------------------------------------------------------- |
| `container.cpu.time` | `s` | counter | cumulative `cpu.stat` `usage_usec` → seconds | | `container.cpu.time` | `s` | counter | cumulative `cpu.stat` `usage_usec` → seconds |
| `container.memory.usage` | `By` | gauge | `memory.current` | | `container.memory.usage` | `By` | gauge | `memory.current` |
| `hyperhive.container.memory.limit` | `By` | gauge | `memory.max` (custom — semconv has no `.limit` metric; omitted when unlimited) | | `hyperhive.container.memory.limit` | `By` | gauge | `memory.max` (custom — semconv has no `.limit` metric; omitted when unlimited) |
| `hyperhive.container.memory.peak` | `By` | gauge | `memory.peak` (custom — no semconv metric; omitted if unavailable) | | `hyperhive.container.memory.peak` | `By` | gauge | `memory.peak` (custom — no semconv metric; omitted if unavailable) |
| `hyperhive.container.storage.usage` | `By` | gauge | state dir + writable rootfs (custom — semconv only has `disk.io`; omitted until the slow disk sampler runs) | | `hyperhive.container.storage.usage` | `By` | gauge | state dir + writable rootfs (custom — semconv only has `disk.io`; omitted until the slow disk sampler runs) |
| `hyperhive.container.cpu.percent` | `%` | gauge | host-normalised percent (custom — the value the dashboard LOAD tab shows, no `rate()` needed) | | `hyperhive.container.cpu.percent` | `%` | gauge | host-normalised percent (custom — the value the dashboard LOAD tab shows, no `rate()` needed) |
The `hyperhive.`-prefixed metrics have no semconv equivalent (memory The `hyperhive.`-prefixed metrics have no semconv equivalent (memory
limit + peak, on-disk footprint, and an instantaneous cpu percent kept limit + peak, on-disk footprint, and an instantaneous cpu percent kept
@ -252,14 +252,14 @@ turn, its own outcome classification, the loose-ends backlog, and session
boundaries. Token usage, cost, and tool-call counts are **not** duplicated boundaries. Token usage, cost, and tool-call counts are **not** duplicated
here; that's already covered by Claude's own export. here; that's already covered by Claude's own export.
| Metric | Unit | Kind | Attributes | | Metric | Unit | Kind | Attributes |
|--------|------|------|------------| | -------------------------------------- | ---- | --------- | ----------------------------------------------------------------------------------------------------------- |
| `hyperhive.agent.turn.duration` | `ms` | histogram | `wake_from`, `result_kind`, `model` | | `hyperhive.agent.turn.duration` | `ms` | histogram | `wake_from`, `result_kind`, `model` |
| `hyperhive.agent.turn.count` | — | counter | `wake_from`, `result_kind`, `model` | | `hyperhive.agent.turn.count` | — | counter | `wake_from`, `result_kind`, `model` |
| `hyperhive.agent.session.count` | — | counter | `model` (incremented once per fresh, non-`--continue`'d session) | | `hyperhive.agent.session.count` | — | counter | `model` (incremented once per fresh, non-`--continue`'d session) |
| `hyperhive.agent.loose_ends.threads` | — | gauge | none | | `hyperhive.agent.loose_ends.threads` | — | gauge | none |
| `hyperhive.agent.loose_ends.reminders` | — | gauge | none | | `hyperhive.agent.loose_ends.reminders` | — | gauge | none |
| `hyperhive.agent.claude_md.lines` | — | gauge | none — recorded from the `CLAUDE.md`-size watch's own ~15-minute tick, **not** per turn like the rows above | | `hyperhive.agent.claude_md.lines` | — | gauge | none — recorded from the `CLAUDE.md`-size watch's own ~15-minute tick, **not** per turn like the rows above |
Resource attributes (`service.name`, `agent`, `hive`, `swarm`) come from the Resource attributes (`service.name`, `agent`, `hive`, `swarm`) come from the
same container-wide `OTEL_RESOURCE_ATTRIBUTES` as everything else in this same container-wide `OTEL_RESOURCE_ATTRIBUTES` as everything else in this
@ -277,11 +277,11 @@ that hosts no agents still reports, and "this hive is quiet" is
distinguishable from "this hive is gone". Select them with distinguishable from "this hive is gone". Select them with
`{hive!="",agent=""}`. `{hive!="",agent=""}`.
| Metric | Unit | Kind | Meaning | | Metric | Unit | Kind | Meaning |
|--------|------|------|---------| | ------------------------- | ---- | ----- | -------------------------------------------------------------------------------------------------------------------- |
| `process.uptime` | `s` | gauge | seconds since this hive's `hive-c0re` started exporting; a restart reads as a drop to ~0 | | `process.uptime` | `s` | gauge | seconds since this hive's `hive-c0re` started exporting; a restart reads as a drop to ~0 |
| `hyperhive.hive.degraded` | `1` | gauge | `1` while the hive reports itself unhealthy — the same verdict `/health/ready` gives and the swarm status view shows | | `hyperhive.hive.degraded` | `1` | gauge | `1` while the hive reports itself unhealthy — the same verdict `/health/ready` gives and the swarm status view shows |
| `hyperhive.hive.warnings` | `1` | gauge | how many warnings are currently raised, split by a `level` attribute (`warn`, `crit`) | | `hyperhive.hive.warnings` | `1` | gauge | how many warnings are currently raised, split by a `level` attribute (`warn`, `crit`) |
Both levels are reported every cycle, `0` included, so a healthy hive is Both levels are reported every cycle, `0` included, so a healthy hive is
visible as zeros rather than as missing series. visible as zeros rather than as missing series.
@ -303,10 +303,10 @@ Forgejo's own native `/metrics` endpoint has no equivalent: it exposes
counts of durable rows (issues, comments, repos), and neither a commit nor a counts of durable rows (issues, comments, repos), and neither a commit nor a
push is stored anywhere as a row to count. push is stored anywhere as a row to count.
| Metric | Unit | Kind | Attributes | | Metric | Unit | Kind | Attributes |
|--------|------|------|------------| | ---------------------------- | ---- | ------- | ------------------- |
| `hyperhive.vcs.commit.count` | — | counter | `repo` (`org/repo`) | | `hyperhive.vcs.commit.count` | — | counter | `repo` (`org/repo`) |
| `hyperhive.vcs.push.count` | — | counter | `repo` (`org/repo`) | | `hyperhive.vcs.push.count` | — | counter | `repo` (`org/repo`) |
A push with zero commits (a branch delete, or a force-push that doesn't add A push with zero commits (a branch delete, or a force-push that doesn't add
new commits) still increments `push.count`; `commit.count` only advances new commits) still increments `push.count`; `commit.count` only advances

View file

@ -7,7 +7,7 @@ the additional config needed when the swarm spans multiple hosts.
For the full option reference rather than prose: `services.hyperhive.swarm.*` For the full option reference rather than prose: `services.hyperhive.swarm.*`
(swarm-wide facts, identical on every host) and `services.hyperhive.deploy.*` (swarm-wide facts, identical on every host) and `services.hyperhive.deploy.*`
(this host's own deployment decisions — does *this* machine run grafana, (this host's own deployment decisions — does _this_ machine run grafana,
the swarm controller, authelia, …) are separate generated pages, `nix the swarm controller, authelia, …) are separate generated pages, `nix
build .#docs-swarm` / `.#docs-deploy` or the website's `/options/swarm.html` build .#docs-swarm` / `.#docs-deploy` or the website's `/options/swarm.html`
/ `/options/deploy.html`. / `/options/deploy.html`.
@ -20,7 +20,7 @@ build .#docs-swarm` / `.#docs-deploy` or the website's `/options/swarm.html`
- **swarm** — one or more hives whose operators have declared them - **swarm** — one or more hives whose operators have declared them
as peers. Agents can be qualified as `agent@hive-domain`. as peers. Agents can be qualified as `agent@hive-domain`.
- **peer hive** — any hive in `services.hyperhive.swarm.hives` other - **peer hive** — any hive in `services.hyperhive.swarm.hives` other
than this one. Peers are *derived*, not declared: the directory lists than this one. Peers are _derived_, not declared: the directory lists
every hive including yourself, and `hiveName` says which one you are. every hive including yourself, and `hiveName` says which one you are.
## Hive identity config ## Hive identity config
@ -47,7 +47,7 @@ and deploys — an eval failure asking the operator to write the address
down is the cheaper outcome. **Upgrading past this release means setting down is the cheaper outcome. **Upgrading past this release means setting
both once.** both once.**
`domain` is required too, but you no longer *write* it: it is read from `domain` is required too, but you no longer _write_ it: it is read from
this hive's own entry in the directory, whose `domain` defaults to this hive's own entry in the directory, whose `domain` defaults to
`<name>.<swarm.domain>`. So a conventional swarm states no addresses at `<name>.<swarm.domain>`. So a conventional swarm states no addresses at
all, and a hive addressed by something else states it in the one place all, and a hive addressed by something else states it in the one place
@ -70,7 +70,7 @@ form qualified labels (`iris@pr1ma.example.com`).
`swarm.name` is purely display — it surfaces in the dashboard chrome `swarm.name` is purely display — it surfaces in the dashboard chrome
header and per-agent system prompts, and federated hives at different header and per-agent system prompts, and federated hives at different
domains can share one. `hiveName` surfaces in the same places but is domains can share one. `hiveName` surfaces in the same places but is
*not* only display: it is the leftmost label of the hive's domain. That _not_ only display: it is the leftmost label of the hive's domain. That
`swarm.name` sits under `swarm` and `hiveName` does not is the whole `swarm.name` sits under `swarm` and `hiveName` does not is the whole
distinction — one names this hive, the other names the group it belongs distinction — one names this hive, the other names the group it belongs
to. to.
@ -120,14 +120,14 @@ services.hyperhive.swarm.hives = {
``` ```
One attrset describing **every** hive in the swarm, **including this One attrset describing **every** hive in the swarm, **including this
one**, keyed by that hive's `hiveName`. It is meant to be *identical on one**, keyed by that hive's `hiveName`. It is meant to be _identical on
every host* — write it once, share it, and each host reads it correctly every host_ — write it once, share it, and each host reads it correctly
because `services.hyperhive.hiveName` says which entry is itself. because `services.hyperhive.hiveName` says which entry is itself.
Empty (the default) means this host isn't in a swarm. Once non-empty it Empty (the default) means this host isn't in a swarm. Once non-empty it
**must** contain an entry for `hiveName`; eval fails naming the missing **must** contain an entry for `hiveName`; eval fails naming the missing
hive. That assertion is load-bearing rather than pedantic — "my peers" hive. That assertion is load-bearing rather than pedantic — "my peers"
is derived as *everything that isn't me*, so a directory that doesn't is derived as _everything that isn't me_, so a directory that doesn't
contain you derives every hive as a peer and you peer with yourself. contain you derives every hive as a peer and you peer with yourself.
`domain` is required per entry and deliberately undefaulted: it is `domain` is required per entry and deliberately undefaulted: it is
@ -138,7 +138,7 @@ evaluates cleanly points at a real machine that isn't the one you meant.
> inside a swarm comes from the swarm root ([`ca.md`](ca.md)): every > inside a swarm comes from the swarm root ([`ca.md`](ca.md)): every
> hive chains to it, so one anchor replaces per-hive pinning entirely. > hive chains to it, so one anchor replaces per-hive pinning entirely.
> What that genuinely drops is trusting a hive whose root this swarm > What that genuinely drops is trusting a hive whose root this swarm
> does *not* own — another swarm's, or one keeping its own CA. That is > does _not_ own — another swarm's, or one keeping its own CA. That is
> a cross-swarm problem and wants a mechanism designed for it. (An > a cross-swarm problem and wants a mechanism designed for it. (An
> earlier `certFingerprint` field existed for exactly that gap, pinning > earlier `certFingerprint` field existed for exactly that gap, pinning
> a peer's TLS leaf for hive-c0re's own peer HTTPS checks — removed > a peer's TLS leaf for hive-c0re's own peer HTTPS checks — removed
@ -162,7 +162,7 @@ evaluates cleanly points at a real machine that isn't the one you meant.
⚠️ **That container currently trusts no swarm-internal CA**, so a ⚠️ **That container currently trusts no swarm-internal CA**, so a
self-signed gateway certificate does not federate. The swarm root self-signed gateway certificate does not federate. The swarm root
can't simply be listed there: `security.pki.certificateFiles` is can't simply be listed there: `security.pki.certificateFiles` is
read when the system is *built*, and the root is a runtime file (its read when the system is _built_, and the root is a runtime file (its
key must never enter the store), so there is no build-time name for key must never enter the store), so there is no build-time name for
it. Bridging that needs a runtime mechanism and is tracked as its own it. Bridging that needs a runtime mechanism and is tracked as its own
issue. Until then, federation needs CA-issued certs (ACME). See issue. Until then, federation needs CA-issued certs (ACME). See
@ -184,7 +184,7 @@ swarm.hives = { … }; swarm.hives = { … }; # byte-identical
``` ```
That is the point of the shape, and it removes a class of bug rather That is the point of the shape, and it removes a class of bug rather
than saving typing: a per-host peer list let two hosts hold *different* than saving typing: a per-host peer list let two hosts hold _different_
facts about the same third hive — a stale endpoint, a rotated facts about the same third hive — a stale endpoint, a rotated
fingerprint — with nothing to detect the disagreement. One entry per fingerprint — with nothing to detect the disagreement. One entry per
hive makes it unrepresentable. hive makes it unrepresentable.
@ -277,7 +277,7 @@ port}` tells this hive where the swarm's `btrfs receive` endpoint is, so
`hivectl agent <name> subvol snapshot push` has somewhere to stream to. `hivectl agent <name> subvol snapshot push` has somewhere to stream to.
It is genuinely swarm-scoped rather than per-peer — a swarm has exactly It is genuinely swarm-scoped rather than per-peer — a swarm has exactly
one store, because the receiver keys destinations by *agent* so a one store, because the receiver keys destinations by _agent_ so a
migrating agent keeps one unbroken incremental chain. See migrating agent keeps one unbroken incremental chain. See
[snapshot-store.md](../networking/snapshot-store.md). [snapshot-store.md](../networking/snapshot-store.md).
@ -305,12 +305,12 @@ A hive publishes only once it has been given the three
`swarm.statusPublish` coordinates below. A hive that has not reads `swarm.statusPublish` coordinates below. A hive that has not reads
`never_reported` — it is not broken, it just has nothing to say upward. `never_reported` — it is not broken, it just has nothing to say upward.
| freshness | what to do about it | | freshness | what to do about it |
|---|---| | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `fresh` | nothing — reported within `staleAfterSeconds` | | `fresh` | nothing — reported within `staleAfterSeconds` |
| `stale` | the hive stopped reporting. Its last payload is still shown, so check `age_seconds` and the payload for what it managed to say | | `stale` | the hive stopped reporting. Its last payload is still shown, so check `age_seconds` and the payload for what it managed to say |
| `never_reported` | this hive has never reported at all — normally a deployment that hasn't happened, not an outage | | `never_reported` | this hive has never reported at all — normally a deployment that hasn't happened, not an outage |
| `unknown` | something is publishing under a name that is not in `swarm.hives` — a typo in the roster, or a hive that was removed and is still running | | `unknown` | something is publishing under a name that is not in `swarm.hives` — a typo in the roster, or a hive that was removed and is still running |
Every row also carries `last_seen_unix` and `age_seconds` if you want to Every row also carries `last_seen_unix` and `age_seconds` if you want to
apply your own threshold. The timestamp is the one the queue recorded on apply your own threshold. The timestamp is the one the queue recorded on
@ -328,11 +328,11 @@ Three options, on the **hive**, set together or not at all — a
half-configured hive is an eval error rather than one that quietly never half-configured hive is an eval error rather than one that quietly never
reports: reports:
| option | what to set it to | | option | what to set it to |
|---|---| | ------------------ | ------------------------------------------------------ |
| `natsUrl` | where the swarm queue listens, as this hive reaches it | | `natsUrl` | where the swarm queue listens, as this hive reaches it |
| `tokenEndpoint` | the swarm IdP's `/api/oidc/token` | | `tokenEndpoint` | the swarm IdP's `/api/oidc/token` |
| `clientSecretFile` | path to this hive's client secret, plaintext | | `clientSecretFile` | path to this hive's client secret, plaintext |
On a host that runs the queue and the IdP itself, all three default to On a host that runs the queue and the IdP itself, all three default to
the local ones and there is nothing to set. Any other hive needs them the local ones and there is nothing to set. Any other hive needs them
@ -364,8 +364,8 @@ on the `agent-configs` org, both under
`https://<swarm.domain>/webhook/forge/`. `https://<swarm.domain>/webhook/forge/`.
The controller **interprets** a delivery and sends hives a specific The controller **interprets** a delivery and sends hives a specific
message — *the knowledge repo changed*, *deploy agent `foo` at rev message — _the knowledge repo changed_, _deploy agent `foo` at rev
`abc123`* — rather than forwarding forge payloads for each hive to `abc123`_ — rather than forwarding forge payloads for each hive to
re-derive. Approval happens once, at the swarm level: a hive receives a re-derive. Approval happens once, at the swarm level: a hive receives a
decision, not an event to adjudicate. decision, not an event to adjudicate.

View file

@ -12,9 +12,9 @@ O(n²) hand-pinning into one anchor per swarm.
What differs is who puts the artifacts on disk: What differs is who puts the artifacts on disk:
| | swarm root | this hive's CA | | | swarm root | this hive's CA |
| --- | --- | --- | | ---------------------------------------- | --------------------------------------------- | ---------------------------------------------- |
| default | operator-provided | operator-provided, else self-signed as before | | default | operator-provided | operator-provided, else self-signed as before |
| `autoConfigure = true` (all on one host) | generated by `swarm-ca.service` on first boot | issued by `hive-tls-ca.service` under the root | | `autoConfigure = true` (all on one host) | generated by `swarm-ca.service` on first boot | issued by `hive-tls-ca.service` under the root |
`services.hyperhive.swarm.ca.autoConfigure` selects between them, and is `services.hyperhive.swarm.ca.autoConfigure` selects between them, and is
@ -49,13 +49,13 @@ swarm-services leaf can, and this host signs that one only under
and every client sees a name mismatch. and every client sees a name mismatch.
So `hive-tls` emits an eval-time **warning** naming the uncovered names. So `hive-tls` emits an eval-time **warning** naming the uncovered names.
It is deliberately not an assertion: this module can see what *it* is It is deliberately not an assertion: this module can see what _it_ is
able to issue, but not an operator-installed sub-CA in able to issue, but not an operator-installed sub-CA in
`swarm.ca.stateDir`, an external ACME setup, or a certificate delivered `swarm.ca.stateDir`, an external ACME setup, or a certificate delivered
by any other means. If you have arranged one, the warning is expected by any other means. If you have arranged one, the warning is expected
and can be ignored. Otherwise either install the sub-CA, or pin the and can be ignored. Otherwise either install the sub-CA, or pin the
names back under the hive domain — a supported migration, since the names back under the hive domain — a supported migration, since the
sub-CA is constrained to the *configured* names and the swarm root sub-CA is constrained to the _configured_ names and the swarm root
carries no name constraints at all. carries no name constraints at all.
## Constraints on the material ## Constraints on the material
@ -96,7 +96,7 @@ CA has nothing to install — `peer-config` says so instead.
Handing a peer this hive's own `ca.pem` would not work even as a Handing a peer this hive's own `ca.pem` would not work even as a
one-off: once a hive CA is an intermediate under the swarm root, it is one-off: once a hive CA is an intermediate under the swarm root, it is
no longer something a verifier can build a chain *to* — OpenSSL will not no longer something a verifier can build a chain _to_ — OpenSSL will not
terminate a chain at a trusted non-self-signed certificate without terminate a chain at a trusted non-self-signed certificate without
`-partial_chain`. That is why the tool distributes the root, not a `-partial_chain`. That is why the tool distributes the root, not a
per-hive file. per-hive file.
@ -127,7 +127,7 @@ that has not restarted yet still validates. Removing `ca-previous.pem`
is a deliberate later step: how long is long enough is a property of the is a deliberate later step: how long is long enough is a property of the
deployment, not something the unit can know. deployment, not something the unit can know.
A marker file (`.swarm-ca-adopted`) records that this ran. Its *absence* A marker file (`.swarm-ca-adopted`) records that this ran. Its _absence_
is the trigger, so adoption fires once per hive rather than being is the trigger, so adoption fires once per hive rather than being
re-decided on every activation. re-decided on every activation.
@ -157,7 +157,7 @@ this: it self-signs exactly as it always has.
The root **key** is a runtime file for the reason in The root **key** is a runtime file for the reason in
[Constraints on the material](#constraints-on-the-material). [Constraints on the material](#constraints-on-the-material).
The root **certificate** is a runtime file as a *consequence* — it lives The root **certificate** is a runtime file as a _consequence_ — it lives
beside the key under `swarm.ca.stateDir` — and that has a cost worth beside the key under `swarm.ca.stateDir` — and that has a cost worth
naming, because it is not obvious and it bites at a distance: naming, because it is not obvious and it bites at a distance:
@ -185,7 +185,7 @@ Two consumers, and only one of them is fine:
an OpenSSL-linked binary. an OpenSSL-linked binary.
> ⚠️ **Concatenate; never point `SSL_CERT_FILE` at the anchor alone.** > ⚠️ **Concatenate; never point `SSL_CERT_FILE` at the anchor alone.**
> `openssl-probe` uses it *instead of* the default store, so naming > `openssl-probe` uses it _instead of_ the default store, so naming
> just the bundle would drop every public CA and break federation with > just the bundle would drop every public CA and break federation with
> the wider matrix network — a much bigger outage than the one being > the wider matrix network — a much bigger outage than the one being
> fixed. The same caveat applies to `hive-forge` (Go) for the same > fixed. The same caveat applies to `hive-forge` (Go) for the same

View file

@ -29,34 +29,34 @@ container that starts after it.
Every row below is read against one of these. This is a different cut Every row below is read against one of these. This is a different cut
from the [deployment shapes](services.md#deployment-shapes) --- those from the [deployment shapes](services.md#deployment-shapes) --- those
say *where services run*, these say *who is responsible for a secret say _where services run_, these say _who is responsible for a secret
file being there* --- so the two lists do not line up one-to-one, and file being there_ --- so the two lists do not line up one-to-one, and
neither is a renaming of the other. neither is a renaming of the other.
| topology | what it means | who places secrets | | topology | what it means | who places secrets |
|---|---|---| | ------------------ | ---------------------------------------------------------- | ---------------------------------------------------------------------------- |
| **all-local** | one host runs the swarm's shared services and its own hive | nobody — each secret is generated where it is read, or copied by a host unit | | **all-local** | one host runs the swarm's shared services and its own hive | nobody — each secret is generated where it is read, or copied by a host unit |
| **swarm-managed** | the swarm's services run on a host with `swarmctl` | `swarmctl` writes what it owns; the rest is still generated in place | | **swarm-managed** | the swarm's services run on a host with `swarmctl` | `swarmctl` writes what it owns; the rest is still generated in place |
| **hive elsewhere** | a hive that federates with a swarm it does not host | the operator provides the file and names it in config | | **hive elsewhere** | a hive that federates with a swarm it does not host | the operator provides the file and names it in config |
## Swarm-level — one of each per swarm ## Swarm-level — one of each per swarm
| secret | generated by | lives at | hive elsewhere | | secret | generated by | lives at | hive elsewhere |
|---|---|---|---| | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| swarm root CA cert | `swarm-ca.nix` first-boot unit, when `autoConfigure` is set | `/var/lib/swarm-ca/root.pem` | operator copies the **cert** in; it is public | | swarm root CA cert | `swarm-ca.nix` first-boot unit, when `autoConfigure` is set | `/var/lib/swarm-ca/root.pem` | operator copies the **cert** in; it is public |
| swarm root CA key | same unit | `/var/lib/swarm-ca/root-key.pem`, `0600` | stays on whichever host holds it — see the constraint below | | swarm root CA key | same unit | `/var/lib/swarm-ca/root-key.pem`, `0600` | stays on whichever host holds it — see the constraint below |
| swarm-services sub-CA (cert + key) | `swarm-ca.nix`, signed by the root | `/var/lib/swarm-ca/services-ca{,-key}.pem` | issued where the root lives | | swarm-services sub-CA (cert + key) | `swarm-ca.nix`, signed by the root | `/var/lib/swarm-ca/services-ca{,-key}.pem` | issued where the root lives |
| authelia session, JWT and storage-encryption keys | authelia's first-boot unit, in-container | `/var/lib/authelia-swarm/{session,jwt,storage-encryption}.key` | generated in place; nothing outside that container reads them | | authelia session, JWT and storage-encryption keys | authelia's first-boot unit, in-container | `/var/lib/authelia-swarm/{session,jwt,storage-encryption}.key` | generated in place; nothing outside that container reads them |
| authelia OIDC HMAC key | same unit | `/var/lib/authelia-swarm/oidc-hmac.key` | same | | authelia OIDC HMAC key | same unit | `/var/lib/authelia-swarm/oidc-hmac.key` | same |
| authelia OIDC issuer key (RSA) | same unit | `/var/lib/authelia-swarm/oidc-issuer.key` | same — relying parties verify against the **public** half at `/jwks.json` | | authelia OIDC issuer key (RSA) | same unit | `/var/lib/authelia-swarm/oidc-issuer.key` | same — relying parties verify against the **public** half at `/jwks.json` |
| OIDC client secret, plaintext half | `authelia crypto hash generate --random` | `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | operator provides the file and names it in whichever option reads it — `sso.clientSecretFile` for a service, `otel.clientSecretFile` for the hive's telemetry collector | | OIDC client secret, plaintext half | `authelia crypto hash generate --random` | `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | operator provides the file and names it in whichever option reads it — `sso.clientSecretFile` for a service, `otel.clientSecretFile` for the hive's telemetry collector |
| OIDC client secret, digest half | the same mint | `oidc-clients/<id>.digest` | authelia's own half; merged at runtime via `settingsFiles` | | OIDC client secret, digest half | the same mint | `oidc-clients/<id>.digest` | authelia's own half; merged at runtime via `settingsFiles` |
| the swarm collector's copy of its OIDC secret | `swarm-otel-oidc-secret.service` copies it from authelia's tree, **when authelia runs on this host** | `/var/lib/swarm-otel-oidc/<id>.secret` inside the `swarm-otel` container | operator provides the file and names it in `swarm.otel.clientSecretFile` — the collector need not share a host with authelia | | the swarm collector's copy of its OIDC secret | `swarm-otel-oidc-secret.service` copies it from authelia's tree, **when authelia runs on this host** | `/var/lib/swarm-otel-oidc/<id>.secret` inside the `swarm-otel` container | operator provides the file and names it in `swarm.otel.clientSecretFile` — the collector need not share a host with authelia |
| authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia | | authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia |
| wireguard private key | **the operator**`wg genkey` | whatever `swarm.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you | | wireguard private key | **the operator**`wg genkey` | whatever `swarm.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you |
| queue auth-callout nkeys (user seed + account seed) | `swarm-nats-callout-keys` first-boot unit, when `nats.autoGenerateCallout` is set | `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed`, `0600` | operator mints both with `nk` and names them in `nats.calloutUserSeedFile` / `nats.calloutIssuerSeedFile` | | queue auth-callout nkeys (user seed + account seed) | `swarm-nats-callout-keys` first-boot unit, when `nats.autoGenerateCallout` is set | `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed`, `0600` | operator mints both with `nk` and names them in `nats.calloutUserSeedFile` / `nats.calloutIssuerSeedFile` |
| the secret store's own contents | openbao, on first `bao operator init`**an operator action, not a unit** | inside the `swarm-bao` container, at its own `/var/lib/openbao`, kept across rebuilds by `ephemeral = false`. ⚠️ **Not a host path**: `nixos-container destroy swarm-bao` takes the raft data with it, so back up the container's tree, not `/var/lib/`. Only the store's TLS material (`/var/lib/swarm-bao-tls`) and its PKCS11 token (`/var/lib/swarm-bao-token`) are host-level | n/a — there is one store; a hive elsewhere is a *client* of it and holds none of this | | the secret store's own contents | openbao, on first `bao operator init`**an operator action, not a unit** | inside the `swarm-bao` container, at its own `/var/lib/openbao`, kept across rebuilds by `ephemeral = false`. ⚠️ **Not a host path**: `nixos-container destroy swarm-bao` takes the raft data with it, so back up the container's tree, not `/var/lib/`. Only the store's TLS material (`/var/lib/swarm-bao-tls`) and its PKCS11 token (`/var/lib/swarm-bao-token`) are host-level | n/a — there is one store; a hive elsewhere is a _client_ of it and holds none of this |
| the secret store's unseal material | the HSM/TPM under `deploy.bao.seal = "pkcs11"`; openbao itself under `"shamir"` | in the token; or held by whoever ran `bao operator init`, which is what `"shamir"` means and why it is stated rather than inferred | n/a — only the host running the store seals anything | | the secret store's unseal material | the HSM/TPM under `deploy.bao.seal = "pkcs11"`; openbao itself under `"shamir"` | in the token; or held by whoever ran `bao operator init`, which is what `"shamir"` means and why it is stated rather than inferred | n/a — only the host running the store seals anything |
The three keys authelia mints for itself are generated in-container precisely The three keys authelia mints for itself are generated in-container precisely
because nothing outside that container ever reads them. **That is the test worth because nothing outside that container ever reads them. **That is the test worth
@ -81,7 +81,7 @@ The copy is only made when authelia is enabled **on this host** and something
published is being scraped; otherwise no secret is needed and none is placed. published is being scraped; otherwise no secret is needed and none is placed.
⚠️ **Do not read that delivery unit as the only way this collector is fed.** ⚠️ **Do not read that delivery unit as the only way this collector is fed.**
Whether it authenticates follows the *credential*, never another service's Whether it authenticates follows the _credential_, never another service's
placement: a swarm collector may run on a host that holds neither store and no placement: a swarm collector may run on a host that holds neither store and no
authelia, and then the secret is an ordinary operator-provided file named in authelia, and then the secret is an ordinary operator-provided file named in
`services.hyperhive.swarm.otel.clientSecretFile` — the same shape as the hive `services.hyperhive.swarm.otel.clientSecretFile` — the same shape as the hive
@ -126,13 +126,13 @@ when the system builds. The server names the offending file and refuses to run.
## Hive-level — one of each per hive ## Hive-level — one of each per hive
| secret | generated by | lives at | | secret | generated by | lives at |
|---|---|---| | ---------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| hive CA cert + key | `hive-tls.nix` first-boot unit | `<deploy.hive-controller.tls.stateDir>/ca.pem`, `ca-key.pem` (`0600`) | | hive CA cert + key | `hive-tls.nix` first-boot unit | `<deploy.hive-controller.tls.stateDir>/ca.pem`, `ca-key.pem` (`0600`) |
| hive leaf certs | `hive-tls.nix`, signed by the hive CA | `<deploy.hive-controller.tls.stateDir>/<name>.pem` | | hive leaf certs | `hive-tls.nix`, signed by the hive CA | `<deploy.hive-controller.tls.stateDir>/<name>.pem` |
| matrix registration token | a host activation script, on first boot | `/var/lib/hyperhive/matrix-register-token` (`0600`) | | matrix registration token | a host activation script, on first boot | `/var/lib/hyperhive/matrix-register-token` (`0600`) |
| the forge's copy of its OIDC secret | `hive-forge-oidc-secret.service` copies it from authelia's tree | `/var/lib/forgejo-oidc/<id>.secret` inside the forge container | | the forge's copy of its OIDC secret | `hive-forge-oidc-secret.service` copies it from authelia's tree | `/var/lib/forgejo-oidc/<id>.secret` inside the forge container |
| the homeserver's copy of its OIDC secret | `hive-matrix-oidc-secret.service`, same shape | `/var/lib/tuwunel-oidc/<id>.secret`, handed to tuwunel through `LoadCredential` | | the homeserver's copy of its OIDC secret | `hive-matrix-oidc-secret.service`, same shape | `/var/lib/tuwunel-oidc/<id>.secret`, handed to tuwunel through `LoadCredential` |
Both delivery units wait for authelia's first boot to mint the secret — a Both delivery units wait for authelia's first boot to mint the secret — a
bounded wait, 120s — and then **fail loudly** rather than skipping. A silent skip bounded wait, 120s — and then **fail loudly** rather than skipping. A silent skip
@ -141,7 +141,7 @@ layers from its cause.
The store's **first reader** is the matrix registration token, and it is worth The store's **first reader** is the matrix registration token, and it is worth
saying why that one: it is an opaque 32-byte value with no second file and no saying why that one: it is an opaque 32-byte value with no second file and no
format. Authelia's OIDC secret needs a `.secret` *and* a matching `.digest`, so format. Authelia's OIDC secret needs a `.secret` _and_ a matching `.digest`, so
starting there would have meant debugging "can a reader authenticate and get starting there would have meant debugging "can a reader authenticate and get
bytes back" and "did we write authelia's file format right" at once, with an bytes back" and "did we write authelia's file format right" at once, with an
SSO outage as the failure mode. SSO outage as the failure mode.
@ -182,7 +182,7 @@ possible at all. Two things follow:
containers through a bind-mounted bundle assembled at boot instead. containers through a bind-mounted bundle assembled at boot instead.
- **On any topology other than all-local, placing that key is an operations - **On any topology other than all-local, placing that key is an operations
decision**, not something this module tree makes for you. A hive that hosts no decision**, not something this module tree makes for you. A hive that hosts no
swarm services needs only the root's *cert*, to trust what others issue. swarm services needs only the root's _cert_, to trust what others issue.
## Adding a secret ## Adding a secret

View file

@ -10,7 +10,7 @@ services.hyperhive.deploy.allSwarmServices = true;
``` ```
**`deploy.allSwarmServices` is what "the swarm's shared services run **`deploy.allSwarmServices` is what "the swarm's shared services run
here" means: every once-per-swarm service that is *optional* takes its here" means: every once-per-swarm service that is _optional_ takes its
`enable` from it.** That is the whole rule, stated once — the per-service `enable` from it.** That is the whole rule, stated once — the per-service
sections below do not repeat it, so a service that stops deriving is a sections below do not repeat it, so a service that stops deriving is a
visible difference rather than one more paragraph saying the same thing. visible difference rather than one more paragraph saying the same thing.
@ -23,7 +23,7 @@ which wins, so "all local except X" needs no further option.
**Both default to off**, and that is deliberate: a host cannot tell **Both default to off**, and that is deliberate: a host cannot tell
whether it is meant to be the swarm's service host, so this is an whether it is meant to be the swarm's service host, so this is an
operator saying so rather than something inferred. With them off, a hive operator saying so rather than something inferred. With them off, a hive
is a *client* of those services — it configures how to reach them and is a _client_ of those services — it configures how to reach them and
runs none of them. runs none of them.
The forge is the exception, and not because it is per-hive: it is The forge is the exception, and not because it is per-hive: it is
@ -112,11 +112,11 @@ services.hyperhive.deploy.grafana.enable = false;
⚠️ **This starts a database that grows for as long as the swarm runs.** ⚠️ **This starts a database that grows for as long as the swarm runs.**
See `retentionPeriod` below before leaving it at its default. See `retentionPeriod` below before leaving it at its default.
| Option | When you'd touch it | | Option | When you'd touch it |
|---|---| | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `deploy.victoriametrics.retentionPeriod` | Default `5y`. Lower it once you have measured how fast this swarm actually fills a disk — the default is deliberately generous because too-short silently discards history you cannot get back. | | `deploy.victoriametrics.retentionPeriod` | Default `5y`. Lower it once you have measured how fast this swarm actually fills a disk — the default is deliberately generous because too-short silently discards history you cannot get back. |
| `swarm.grafana.oidc.role` | Default `Admin` for everyone who logs in. Lower to `Viewer`/`Editor` if the swarm grows operators who should not be able to reconfigure Grafana. | | `swarm.grafana.oidc.role` | Default `Admin` for everyone who logs in. Lower to `Viewer`/`Editor` if the swarm grows operators who should not be able to reconfigure Grafana. |
| `swarm.grafana.datasourceUrl` | Only if you front VictoriaMetrics with something else. It defaults to the store on this host, which is the only thing it can reach. | | `swarm.grafana.datasourceUrl` | Only if you front VictoriaMetrics with something else. It defaults to the store on this host, which is the only thing it can reach. |
**Logging in.** Grafana is behind swarm SSO, so the accounts are the **Logging in.** Grafana is behind swarm SSO, so the accounts are the
authelia ones — there is no separate Grafana password, and the local authelia ones — there is no separate Grafana password, and the local
@ -138,16 +138,16 @@ the hive's daemons and infra containers, and the harness units inside every
agent container. The collector below is what writes to it. agent container. The collector below is what writes to it.
**Reading them.** Open Grafana, pick **Explore**, and choose the **Reading them.** Open Grafana, pick **Explore**, and choose the
`VictoriaLogs` datasource — it is provisioned for you. Grafana's *Logs `VictoriaLogs` datasource — it is provisioned for you. Grafana's _Logs
Drilldown* app is deliberately not installed: it only supports Loki, and Drilldown_ app is deliberately not installed: it only supports Loki, and
no setting here changes that, so Explore is the log browser for this no setting here changes that, so Explore is the log browser for this
swarm. swarm.
| Option | When you'd touch it | | Option | When you'd touch it |
|---|---| | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `deploy.victorialogs.retentionPeriod` | Default `30d`, far shorter than the metrics store's — logs are bulkier per unit of value and are usually read within days of being written. Raise it if you need to answer questions about last quarter. | | `deploy.victorialogs.retentionPeriod` | Default `30d`, far shorter than the metrics store's — logs are bulkier per unit of value and are usually read within days of being written. Raise it if you need to answer questions about last quarter. |
| `swarm.victorialogs.domain` | Only to rename it. | | `swarm.victorialogs.domain` | Only to rename it. |
| `swarm.victorialogs.port` | Only if something else on the services host already claims `9428`. | | `swarm.victorialogs.port` | Only if something else on the services host already claims `9428`. |
Like the metrics store, it binds loopback and takes no credential of its Like the metrics store, it binds loopback and takes no credential of its
own: the gateway vhost is the only way in, and the collector is the only own: the gateway vhost is the only way in, and the collector is the only
@ -173,10 +173,10 @@ loopback URL an operator has to redirect. There is nothing to set on a hive
that does not run the swarm's services; the name resolves through the that does not run the swarm's services; the name resolves through the
gateway either way. gateway either way.
| Option | When you'd touch it | | Option | When you'd touch it |
|---|---| | ------------------- | --------------------------------------------------------------------------------------- |
| `swarm.otel.domain` | Only to rename it — the default already resolves correctly for every hive in the swarm. | | `swarm.otel.domain` | Only to rename it — the default already resolves correctly for every hive in the swarm. |
| `swarm.otel.port` | Only if something else on the services host already claims `4319`. | | `swarm.otel.port` | Only if something else on the services host already claims `4319`. |
With neither `otel.endpoint` nor the store enabled, this collector is With neither `otel.endpoint` nor the store enabled, this collector is
refused at eval — a tier that receives samples and drops them looks refused at eval — a tier that receives samples and drops them looks
@ -184,4 +184,3 @@ healthy while losing data.
Agent-side configuration, and what a hive's own collector does, are in Agent-side configuration, and what a hive's own collector does, are in
[`../scheduler/observability.md`](../scheduler/observability.md). [`../scheduler/observability.md`](../scheduler/observability.md).

View file

@ -157,12 +157,12 @@ evaluation error.
The registration half is identical; what each service does with the The registration half is identical; what each service does with the
result is not. result is not.
| | forge | matrix | | | forge | matrix |
|---|---|---| | ------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| how it learns the config | a oneshot calls `forgejo admin auth`, writing a login-source row into its database | tuwunel reads a `[[global.identity_provider]]` entry from its config file | | how it learns the config | a oneshot calls `forgejo admin auth`, writing a login-source row into its database | tuwunel reads a `[[global.identity_provider]]` entry from its config file |
| how it reads the secret | a path inside its container | the same path, handed on by `LoadCredential` | | how it reads the secret | a path inside its container | the same path, handed on by `LoadCredential` |
| callback URL | `<root>/user/oauth2/<source>/callback` | `<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`, a shape tuwunel fixes rather than accepts | | callback URL | `<root>/user/oauth2/<source>/callback` | `<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`, a shape tuwunel fixes rather than accepts |
| cost of a malformed entry | the login source is missing | the homeserver can refuse to start | | cost of a malformed entry | the login source is missing | the homeserver can refuse to start |
Two consequences worth stating plainly: Two consequences worth stating plainly:
@ -213,13 +213,13 @@ friendly "SSO is unavailable" page; that page is a file, so it returns
HTML instead of the failure that actually happened: HTML instead of the failure that actually happened:
- `/api/authz/auth-request` — nginx `auth_request` treats **any 2xx as - `/api/authz/auth-request` — nginx `auth_request` treats **any 2xx as
success**, so a down provider means *access granted* success**, so a down provider means _access granted_
- `/api/oidc/introspection` — a token check that answers `200` - `/api/oidc/introspection` — a token check that answers `200`
- `/api/oidc/token`, `/.well-known/openid-configuration` — a client - `/api/oidc/token`, `/.well-known/openid-configuration` — a client
parsing an error page as its JSON document parsing an error page as its JSON document
So authelia's `/api/` and `/.well-known/` prefixes are routed **without** So authelia's `/api/` and `/.well-known/` prefixes are routed **without**
error interception. The split is by *audience*, not by an enumerated path error interception. The split is by _audience_, not by an enumerated path
list: a human gets the page, every machine caller gets the status. list: a human gets the page, every machine caller gets the status.
Enumerating endpoints individually would leave the next one added Enumerating endpoints individually would leave the next one added
silently intercepted. silently intercepted.

View file

@ -4,7 +4,7 @@ The swarm's own web surface, served by the gateway on the **swarm apex**
(`services.hyperhive.swarm.domain`) and readable only by operators. (`services.hyperhive.swarm.domain`) and readable only by operators.
Distinct from the per-hive dashboard, which lives on the hive domain and Distinct from the per-hive dashboard, which lives on the hive domain and
answers for one host. This one is the view *across* hives. answers for one host. This one is the view _across_ hives.
## Enabling ## Enabling
@ -15,7 +15,7 @@ services.hyperhive.deploy.swarm-ui.enable = true; # defaults to deploy.swarm-c
Derived from the controller rather than from `allSwarmServices`: Derived from the controller rather than from `allSwarmServices`:
the UI is a view onto the controller's state and reaches it over that the UI is a view onto the controller's state and reaches it over that
daemon's socket, so the host that runs the controller is the host that daemon's socket, so the host that runs the controller is the host that
can serve the UI. A hive that merely *uses* a swarm has nothing to serve. can serve the UI. A hive that merely _uses_ a swarm has nothing to serve.
The UI answers on `services.hyperhive.swarm.domain` and nothing else. It The UI answers on `services.hyperhive.swarm.domain` and nothing else. It
shares that name with the swarm-controller it fronts — one service to a shares that name with the swarm-controller it fronts — one service to a
@ -29,8 +29,8 @@ reader and to a certificate — so there is no separate option to pin.
## 🔑 You must be in the `admins` group ## 🔑 You must be in the `admins` group
**This is the step that separates "protected" from "locked out".** The **This is the step that separates "protected" from "locked out".** The
vhost's `auth_request` asks authelia *"is there a session"*; the rule vhost's `auth_request` asks authelia _"is there a session"_; the rule
that makes it mean *"is this an operator"* is an `access_control` entry that makes it mean _"is this an operator"_ is an `access_control` entry
requiring `group:admins`. An account without that group authenticates requiring `group:admins`. An account without that group authenticates
fine and still gets bounced. fine and still gets bounced.
@ -41,7 +41,7 @@ swarmctl user add <you> --group admins
`admins` deliberately, not a new word: [`../getting-started/setup.md`](../getting-started/setup.md) has `admins` deliberately, not a new word: [`../getting-started/setup.md`](../getting-started/setup.md) has
told every operator to create exactly that group since the bootstrap step told every operator to create exactly that group since the bootstrap step
existed, so an account made by following the guide already passes. This existed, so an account made by following the guide already passes. This
is the first rule that *consumes* a group name — inventing a second one is the first rule that _consumes_ a group name — inventing a second one
would have meant those accounts silently failing a check they were would have meant those accounts silently failing a check they were
supposed to pass. supposed to pass.
@ -50,7 +50,7 @@ An account created without any group needs re-adding with the flag —
what changes. what changes.
Why a group and not a list of usernames: agents are getting authelia Why a group and not a list of usernames: agents are getting authelia
accounts of their own (matrix SSO), and *authenticated* would then accounts of their own (matrix SSO), and _authenticated_ would then
include every agent in the hive. The group is the only thing standing include every agent in the hive. The group is the only thing standing
between "an operator's page" and "anyone with a session". between "an operator's page" and "anyone with a session".
@ -68,10 +68,10 @@ Adding a swarm service name means touching two things. Missing the
second ships as a different flavour of "works from the host, broken from second ships as a different flavour of "works from the host, broken from
a container": a container":
| site | file | | site | file |
| --- | --- | | ---------------------------- | --------------------------------------------------------------- |
| vhost + `gateway.localNames` | the service's own module (e.g. `nix/host-modules/swarm-ui.nix`) | | vhost + `gateway.localNames` | the service's own module (e.g. `nix/host-modules/swarm-ui.nix`) |
| **certificate name** | `nix/host-modules/swarm.nix` (`serviceDomains`) | | **certificate name** | `nix/host-modules/swarm.nix` (`serviceDomains`) |
The DNS record and the local-dev `/etc/hosts` entry need no separate The DNS record and the local-dev `/etc/hosts` entry need no separate
edit: both are derived from `services.hyperhive.gateway.localNames`, edit: both are derived from `services.hyperhive.gateway.localNames`,
@ -83,7 +83,7 @@ matrix discovery); a swarm service declares its own vhost next to its
own options, the way `swarm-ui.nix` and `swarm-authelia.nix` do. own options, the way `swarm-ui.nix` and `swarm-authelia.nix` do.
⚠️ The certificate one is the least obvious and the most visible when ⚠️ The certificate one is the least obvious and the most visible when
missed. `serviceDomains` is *both* the services sub-CA's missed. `serviceDomains` is _both_ the services sub-CA's
`nameConstraints` set and the leaf's SAN list, and the apex is a `nameConstraints` set and the leaf's SAN list, and the apex is a
**sibling** of `forge.<swarm>` / `chat.<swarm>` / `auth.<swarm>`, not a **sibling** of `forge.<swarm>` / `chat.<swarm>` / `auth.<swarm>`, not a
parent — no CA in the hierarchy issues for it implicitly. Left out, the parent — no CA in the hierarchy issues for it implicitly. Left out, the

View file

@ -1,6 +1,6 @@
# Tools # Tools
`hivectl` is *your* tool — the operator's own host CLI. Everything `hivectl` is _your_ tool — the operator's own host CLI. Everything
else here documents the tool surface your **agents** get inside their else here documents the tool surface your **agents** get inside their
containers (the MCP tools an agent's own claude session can call). containers (the MCP tools an agent's own claude session can call).
You never call these directly, but they're the reference for what an You never call these directly, but they're the reference for what an

View file

@ -20,15 +20,15 @@ output. Handle it on a future turn — unless `wait_seconds` already
delivered the terminal result inline, in which case no todo is created delivered the terminal result inline, in which case no todo is created
(see `status` below). (see `status` below).
* `timeout_secs` — kill the task after N seconds and mark it - `timeout_secs` — kill the task after N seconds and mark it
`timed_out`. Omit for no timeout (runs until natural exit). `timed_out`. Omit for no timeout (runs until natural exit).
* `wait_seconds` — inline poll before returning (capped at 30). - `wait_seconds` — inline poll before returning (capped at 30).
When the task finishes within the window the full status is When the task finishes within the window the full status is
returned immediately and no todo is created; when the window expires returned immediately and no todo is created; when the window expires
the task keeps running and the normal `task started: id=<id>` the task keeps running and the normal `task started: id=<id>`
response is returned. **Defaults to 3** — pass `wait_seconds: 0` response is returned. **Defaults to 3** — pass `wait_seconds: 0`
to disable inline waiting and always get the immediate response. to disable inline waiting and always get the immediate response.
* `name` — optional caller-chosen task id. When set it replaces the - `name` — optional caller-chosen task id. When set it replaces the
auto-generated hex id, so it surfaces in `status(<name>)` lookups and auto-generated hex id, so it surfaces in `status(<name>)` lookups and
the loose-ends list — a memorable label instead of an opaque id. A name the loose-ends list — a memorable label instead of an opaque id. A name
is **reusable once its previous task has finished**; submitting a is **reusable once its previous task has finished**; submitting a

View file

@ -172,7 +172,7 @@ resume drains the backlog rather than dropping it. Points worth knowing:
- **Sticky.** The marker lives on the persistent harness mount, so a - **Sticky.** The marker lives on the persistent harness mount, so a
paused agent stays paused across a container restart — and pausing a paused agent stays paused across a container restart — and pausing a
*stopped* agent makes it come up parked. _stopped_ agent makes it come up parked.
- **Not a DAG.** Unlike `restart`/`stop`, there's no container operation - **Not a DAG.** Unlike `restart`/`stop`, there's no container operation
to sequence, so it applies immediately with nothing to wait on. to sequence, so it applies immediately with nothing to wait on.
- **Stopping a paused agent is still fast.** The graceful-stop - **Stopping a paused agent is still fast.** The graceful-stop
@ -201,7 +201,7 @@ they go into a systemd drop-in verbatim, and a typo there makes the
unit fail to start. unit fail to start.
**Declarative, not incremental**: each invocation replaces the agent's **Declarative, not incremental**: each invocation replaces the agent's
whole entry. `set-limits sock --memory-max 8G` leaves `sock` with *only* whole entry. `set-limits sock --memory-max 8G` leaves `sock` with _only_
a memory override, reverting any previously-set CPU quota to the hive a memory override, reverting any previously-set CPU quota to the hive
default. To avoid a forgotten flag silently wiping an override, a bare default. To avoid a forgotten flag silently wiping an override, a bare
`set-limits <name>` with no flags is rejected — clearing requires the `set-limits <name>` with no flags is rejected — clearing requires the
@ -236,7 +236,7 @@ Bare `choom` starts a fresh blank session. `--resume <value>` passes
through as `claude --resume <value>` to rejoin a prior session by its through as `claude --resume <value>` to rejoin a prior session by its
session id — the flag name deliberately matches the claude flag it maps session id — the flag name deliberately matches the claude flag it maps
to. (choom never uses claude's `--continue`: that's a bare flag that to. (choom never uses claude's `--continue`: that's a bare flag that
takes no argument and resumes the cwd's *latest* session, i.e. the takes no argument and resumes the cwd's _latest_ session, i.e. the
harness's; a value after it would be consumed as the first prompt, harness's; a value after it would be consumed as the first prompt,
silently poking the live harness session.) A value is required when the silently poking the live harness session.) A value is required when the
flag is given. Either way choom never collides with the harness's live flag is given. Either way choom never collides with the harness's live

View file

@ -74,7 +74,7 @@ agents after the approval resolves.
| `kill` / `start` / `restart` / `update` | No | Direct children | | `kill` / `start` / `restart` / `update` | No | Direct children |
| `list_containers` | No | All descendants | | `list_containers` | No | All descendants |
| `request_init_config` | Yes (InitConfig) | New direct child only | | `request_init_config` | Yes (InitConfig) | New direct child only |
| `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) | | `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) |
## See also ## See also

View file

@ -4,12 +4,12 @@ This document contains the help content for the `swarmctl` command-line program.
**Command Overview:** **Command Overview:**
* [`swarmctl`↴](#swarmctl) - [`swarmctl`↴](#swarmctl)
* [`swarmctl user`↴](#swarmctl-user) - [`swarmctl user`↴](#swarmctl-user)
* [`swarmctl user add`↴](#swarmctl-user-add) - [`swarmctl user add`↴](#swarmctl-user-add)
* [`swarmctl user update`↴](#swarmctl-user-update) - [`swarmctl user update`↴](#swarmctl-user-update)
* [`swarmctl user list`↴](#swarmctl-user-list) - [`swarmctl user list`↴](#swarmctl-user-list)
* [`swarmctl completions`↴](#swarmctl-completions) - [`swarmctl completions`↴](#swarmctl-completions)
## `swarmctl` ## `swarmctl`
@ -19,17 +19,15 @@ swarm-level operator CLI
###### **Subcommands:** ###### **Subcommands:**
* `user` — Manage subjects in the swarm's SSO provider - `user` — Manage subjects in the swarm's SSO provider
* `completions` — Generate a shell completion script for `swarmctl` and print it to stdout - `completions` — Generate a shell completion script for `swarmctl` and print it to stdout
###### **Options:** ###### **Options:**
* `--authelia-bin <PATH>` — authelia binary used to hash passwords. The argon2 parameters must match the verifier's, so this has to be the *configured* package rather than whatever is on `PATH` - `--authelia-bin <PATH>` — authelia binary used to hash passwords. The argon2 parameters must match the verifier's, so this has to be the _configured_ package rather than whatever is on `PATH`
* `--users-file <PATH>` — Host-side path of authelia's users database — i.e. the path inside the container, prefixed with the container's root. - `--users-file <PATH>` — Host-side path of authelia's users database — i.e. the path inside the container, prefixed with the container's root.
This is the only user store: it is read before every change and written in place, and `swarm-authelia-bridge` writes the same file.
This is the only user store: it is read before every change and written in place, and `swarm-authelia-bridge` writes the same file.
## `swarmctl user` ## `swarmctl user`
@ -39,11 +37,9 @@ Manage subjects in the swarm's SSO provider
###### **Subcommands:** ###### **Subcommands:**
* `add` — Add a user, generating a password for them - `add` — Add a user, generating a password for them
* `update` — Change an existing user's attributes - `update` — Change an existing user's attributes
* `list` — List every user in authelia's users database - `list` — List every user in authelia's users database
## `swarmctl user add` ## `swarmctl user add`
@ -53,15 +49,13 @@ Add a user, generating a password for them
###### **Arguments:** ###### **Arguments:**
* `<USERNAME>` — Login name. Conservative ASCII only — it is a YAML map key and reaches access-control rules and logs - `<USERNAME>` — Login name. Conservative ASCII only — it is a YAML map key and reaches access-control rules and logs
###### **Options:** ###### **Options:**
* `--display-name <TEXT>` — Name shown in the SSO UI. Defaults to the username - `--display-name <TEXT>` — Name shown in the SSO UI. Defaults to the username
* `--email <ADDRESS>` - `--email <ADDRESS>`
* `--group <GROUP>` — Repeatable - `--group <GROUP>` — Repeatable
## `swarmctl user update` ## `swarmctl user update`
@ -73,16 +67,14 @@ Every flag is optional and they compose, so one call can set several things at o
###### **Arguments:** ###### **Arguments:**
* `<USERNAME>` — Login name of an existing user - `<USERNAME>` — Login name of an existing user
###### **Options:** ###### **Options:**
* `--display-name <TEXT>` — Name shown in the SSO UI - `--display-name <TEXT>` — Name shown in the SSO UI
* `--email <ADDRESS>` - `--email <ADDRESS>`
* `--add-group <GROUP>` — Repeatable. Adding a group the user is already in is not an error - `--add-group <GROUP>` — Repeatable. Adding a group the user is already in is not an error
* `--remove-group <GROUP>` — Repeatable. Fails if the user is not in the group — a revocation that reports success without revoking is the failure nobody re-checks - `--remove-group <GROUP>` — Repeatable. Fails if the user is not in the group — a revocation that reports success without revoking is the failure nobody re-checks
## `swarmctl user list` ## `swarmctl user list`
@ -92,8 +84,6 @@ Read-only: it never writes the file. Shows every subject in it, including agent
**Usage:** `swarmctl user list` **Usage:** `swarmctl user list`
## `swarmctl completions` ## `swarmctl completions`
Generate a shell completion script for `swarmctl` and print it to stdout. Generate a shell completion script for `swarmctl` and print it to stdout.
@ -106,16 +96,13 @@ Dispatched before `PathArgs::resolve()` for the same reason as `markdown-docs`:
###### **Arguments:** ###### **Arguments:**
* `<SHELL>` — Shell to emit completions for - `<SHELL>` — Shell to emit completions for
Possible values: `bash`, `elvish`, `fish`, `powershell`, `zsh` Possible values: `bash`, `elvish`, `fish`, `powershell`, `zsh`
<hr/> <hr/>
<small><i> <small><i>
This document was generated automatically by This document was generated automatically by
<a href="https://crates.io/crates/clap-markdown"><code>clap-markdown</code></a>. <a href="https://crates.io/crates/clap-markdown"><code>clap-markdown</code></a>.
</i></small> </i></small>

View file

@ -99,11 +99,11 @@ exact same path, so the group grant always holds.
`/run/hive-agent/<name>/` is shared by **three principals that share no `/run/hive-agent/<name>/` is shared by **three principals that share no
group**, which is why its mode is what it is: group**, which is why its mode is what it is:
| principal | reaches | needs | | principal | reaches | needs |
|---|---|---| | ------------------- | ---------------------------------------- | ------------ |
| the agent's harness | binds + unlinks `agent.sock`, `web.sock` | owner, `rwx` | | the agent's harness | binds + unlinks `agent.sock`, `web.sock` | owner, `rwx` |
| `hive-c0re` | dials `agent.sock` (todo wakes) | traverse | | `hive-c0re` | dials `agent.sock` (todo wakes) | traverse |
| the gateway's nginx | dials `web.sock` | traverse | | the gateway's nginx | dials `web.sock` | traverse |
The last two land in "other", so the dir is **`0751`, owned by the The last two land in "other", so the dir is **`0751`, owned by the
agent's container uid/gid** — `o=--x` is traverse without listing, and agent's container uid/gid** — `o=--x` is traverse without listing, and
@ -111,13 +111,13 @@ both sockets are `0666`, which is all a dialer needs.
**Ownership is declared, not repaired.** The tmpfiles.d entry written by **Ownership is declared, not repaired.** The tmpfiles.d entry written by
`SyncAgentTmpfiles` names the uid/gid directly. Do not add a chown `SyncAgentTmpfiles` names the uid/gid directly. Do not add a chown
alongside it: `d` re-applies on every boot *and* every agent alongside it: `d` re-applies on every boot _and_ every agent
spawn/destroy, so ownership set afterwards is reverted the next time any spawn/destroy, so ownership set afterwards is reverted the next time any
agent changes — which is exactly how this dir spent a long time at agent changes — which is exactly how this dir spent a long time at
`0777 root root` while a privileged chown appeared to be fixing it. `0777 root root` while a privileged chown appeared to be fixing it.
The mode is load-bearing, not cosmetic. Write permission on a The mode is load-bearing, not cosmetic. Write permission on a
*directory* is what confers the right to unlink its entries, whoever owns _directory_ is what confers the right to unlink its entries, whoever owns
them, and the sticky bit is the only thing that would restrain that (it them, and the sticky bit is the only thing that would restrain that (it
is not set here). A world-writable socket dir therefore lets anything is not set here). A world-writable socket dir therefore lets anything
able to reach the path delete an agent's socket and bind its own — and able to reach the path delete an agent's socket and bind its own — and
@ -129,11 +129,11 @@ nginx reaches all of `/run/hive-agent` as a plain host path. Dropping
them and the rest of the host. That costs no network isolation: nginx them and the rest of the host. That costs no network isolation: nginx
binds the host's `:80`/`:443` and reaches `localhost` upstreams, which a binds the host's `:80`/`:443` and reaches `localhost` upstreams, which a
netns would have to be opened up for anyway. netns would have to be opened up for anyway.
🔑 It does mean nothing *implicitly* scopes the privileged reload verb — 🔑 It does mean nothing _implicitly_ scopes the privileged reload verb —
see [`docs/trust-boundary/security.md`](security.md#hive-c0re-privilege-separation) for see [`docs/trust-boundary/security.md`](security.md#hive-c0re-privilege-separation) for
how `PrivRequest::ReloadGatewayNginx`'s containment works. how `PrivRequest::ReloadGatewayNginx`'s containment works.
⚠️ Contrast `/shared`, which *is* sticky world-writable (`1777`): it has ⚠️ Contrast `/shared`, which _is_ sticky world-writable (`1777`): it has
many legitimate writers, so sticky is the best available answer there. many legitimate writers, so sticky is the best available answer there.
This dir has exactly one writer, so it needs no world write at all. This dir has exactly one writer, so it needs no world write at all.
@ -141,7 +141,7 @@ This dir has exactly one writer, so it needs no world write at all.
`hivectl` drives the whole hive — spawn / kill / destroy / rebuild / `hivectl` drives the whole hive — spawn / kill / destroy / rebuild /
deploy — over the **host admin socket** `/run/hyperhive/host.sock`, deploy — over the **host admin socket** `/run/hyperhive/host.sock`,
socket-activated by the `hive-c0re.socket` unit. That socket *is* the socket-activated by the `hive-c0re.socket` unit. That socket _is_ the
full-control surface, so who can connect to it is a real trust full-control surface, so who can connect to it is a real trust
boundary. boundary.

View file

@ -207,22 +207,22 @@ for every operation that genuinely requires root.
**Narrow interface** — `PrivRequest` variants map 1:1 to specific **Narrow interface** — `PrivRequest` variants map 1:1 to specific
known operations; there is no arbitrary command pass-through: known operations; there is no arbitrary command pass-through:
| Operation | What it runs | | Operation | What it runs |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------- | | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `StartContainer` / `StopContainer` | `nixos-container start/stop <name>` | | `StartContainer` / `StopContainer` | `nixos-container start/stop <name>` |
| `KillContainer` | `machinectl kill <machine> --signal=SIGKILL` (`nixos-container` has no kill verb) | | `KillContainer` | `machinectl kill <machine> --signal=SIGKILL` (`nixos-container` has no kill verb) |
| `CreateContainer` / `UpdateContainer` | `nixos-container create/update <name> --flake <ref>` | | `CreateContainer` / `UpdateContainer` | `nixos-container create/update <name> --flake <ref>` |
| `DestroyContainer` | `nixos-container destroy <name>` | | `DestroyContainer` | `nixos-container destroy <name>` |
| `ListContainers` | `nixos-container list` | | `ListContainers` | `nixos-container list` |
| `ReadContainerJournal` | `journalctl -M <container> -n <n> [filters...]` | | `ReadContainerJournal` | `journalctl -M <container> -n <n> [filters...]` |
| `ReloadGatewayNginx` | `systemctl reload/start/reset-failed nginx` (host unit; the unit name is hard-coded, not a parameter) | | `ReloadGatewayNginx` | `systemctl reload/start/reset-failed nginx` (host unit; the unit name is hard-coded, not a parameter) |
| `WriteNspawnFlags` | write `/etc/nixos-containers/<container>.conf` (bind-mount list + network isolation vars) | | `WriteNspawnFlags` | write `/etc/nixos-containers/<container>.conf` (bind-mount list + network isolation vars) |
| `WriteResourceLimits` | write `CPUQuota=`/`MemoryMax=`/`CPUWeight=`/`IOWeight=` systemd drop-in for agent container | | `WriteResourceLimits` | write `CPUQuota=`/`MemoryMax=`/`CPUWeight=`/`IOWeight=` systemd drop-in for agent container |
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy | | `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy |
| `DaemonReload` | `systemctl daemon-reload` | | `DaemonReload` | `systemctl daemon-reload` |
| `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin <args>` | | `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin <args>` |
| `WriteAgentForgeToken` / `WriteAgentMatrixToken` | write `0600` credential file into agent state dir | | `WriteAgentForgeToken` / `WriteAgentMatrixToken` | write `0600` credential file into agent state dir |
| `RestartMatrixDaemon` | `systemctl --machine=h-<name> restart hive-matrix-daemon.service` | | `RestartMatrixDaemon` | `systemctl --machine=h-<name> restart hive-matrix-daemon.service` |
**Container allowlist** — every request is validated against an **Container allowlist** — every request is validated against an
allowlist before any operation: only names matching the agent-name allowlist before any operation: only names matching the agent-name

View file

@ -73,7 +73,7 @@ percentage-of-window ctx badge.
harness-owned session title (`turn::session_title()`, default harness-owned session title (`turn::session_title()`, default
`hive-session`, override `HIVE_SESSION_TITLE`). The durable `hive-session`, override `HIVE_SESSION_TITLE`). The durable
`hive_claude::InfiniteSession` (built once by the serve loop via `hive_claude::InfiniteSession` (built once by the serve loop via
`turn::make_session`, then reused) `--resume <title>`s it; the *first* use `turn::make_session`, then reused) `--resume <title>`s it; the _first_ use
(bootstrap, post-archive, post-purge) misses and the session re-runs the (bootstrap, post-archive, post-purge) misses and the session re-runs the
same prompt once with `--name <title>` to mint it. That single self-heal same prompt once with `--name <title>` to mint it. That single self-heal
rule is the whole identity system — there is **no** scraped session-id rule is the whole identity system — there is **no** scraped session-id
@ -85,7 +85,7 @@ context (it won't carry our title). claude stores sessions in
`~/.claude/projects/<cwd-slug>/<uuid>.jsonl` (bind-mounted persistently); `~/.claude/projects/<cwd-slug>/<uuid>.jsonl` (bind-mounted persistently);
`--name` writes the title into the file as a `custom-title` event, which `--name` writes the title into the file as a `custom-title` event, which
is what `--resume <title>` resolves against. We never pass bare is what `--resume <title>` resolves against. We never pass bare
`--continue` (it resumes the *latest* session in the cwd — the hijack `--continue` (it resumes the _latest_ session in the cwd — the hijack
vector). Auto-compact, auto-memory, and dynamic workflows (the `/workflows` vector). Auto-compact, auto-memory, and dynamic workflows (the `/workflows`
feature) are disabled via the managed settings at feature) are disabled via the managed settings at
`/etc/claude-code/managed-settings.json`: hyperhive owns compaction `/etc/claude-code/managed-settings.json`: hyperhive owns compaction
@ -94,12 +94,12 @@ feature) are disabled via the managed settings at
harness's autonomous turns. harness's autonomous turns.
**Session reset** is available via `POST /api/new-session` (or **Session reset** is available via `POST /api/new-session` (or
`/new-session` slash command). It does *not* touch the session inline — `/new-session` slash command). It does _not_ touch the session inline —
that would race a mid-write claude process. Instead `Bus::request_session_reset()` that would race a mid-write claude process. Instead `Bus::request_session_reset()`
sets a one-shot flag consumed at the next turn boundary by `drive_turn`, sets a one-shot flag consumed at the next turn boundary by `drive_turn`,
which **archives** the current session: the backing `<uuid>.jsonl` is which **archives** the current session: the backing `<uuid>.jsonl` is
renamed to `<uuid>.jsonl.archived` (dropped out of claude's `*.jsonl` renamed to `<uuid>.jsonl.archived` (dropped out of claude's `*.jsonl`
resolution glob, history preserved on disk, only the file carrying *our* resolution glob, history preserved on disk, only the file carrying _our_
title — any `choom` session sharing the cwd is left alone). The next title — any `choom` session sharing the cwd is left alone). The next
turn's `--resume <title>` then misses and self-heals into a fresh session. turn's `--resume <title>` then misses and self-heals into a fresh session.
@ -111,19 +111,19 @@ at `/etc/claude-code/managed-settings.json`); hyperhive owns it. The
window with two triggers baked into its `run`: window with two triggers baked into its `run`:
- **Reactive** — claude-code prints `Prompt is too long`. The session is - **Reactive** — claude-code prints `Prompt is too long`. The session is
*already* past the window, so no turn can run on it — the session _already_ past the window, so no turn can run on it — the session
`/compact`s straight away and retries the same wake-up prompt once. No `/compact`s straight away and retries the same wake-up prompt once. No
notes-checkpoint turn is possible here: the detail is gone. If the retry notes-checkpoint turn is possible here: the detail is gone. If the retry
*still* overflows, `run` surfaces `Error::PromptTooLong`; `drive_turn` _still_ overflows, `run` surfaces `Error::PromptTooLong`; `drive_turn`
then archives the session (session lifecycle stays hive-side) and the then archives the session (session lifecycle stays hive-side) and the
serve loop requeues the message so it redelivers into a fresh session serve loop requeues the message so it redelivers into a fresh session
(see [Turn outcomes](README.md#turn-outcomes) — the wake prompt itself is tiny, so (see [Turn outcomes](README.md#turn-outcomes) — the wake prompt itself is tiny, so
the overflow was the accumulated context the archive clears). the overflow was the accumulated context the archive clears).
- **Proactive** — a turn finishes cleanly but the last inference's context - **Proactive** — a turn finishes cleanly but the last inference's context
size crossed the policy watermark. While the session is still healthy it size crossed the policy watermark. While the session is still healthy it
runs one synthetic *notes-checkpoint* turn (`CHECKPOINT_PROMPT` runs one synthetic _notes-checkpoint_ turn (`CHECKPOINT_PROMPT`
"context is filling up, flush durable state into `/state` now") and "context is filling up, flush durable state into `/state` now") and
*then* `/compact`s, so the agent can persist in-flight state before the _then_ `/compact`s, so the agent can persist in-flight state before the
detail collapses into a summary. detail collapses into a summary.
The **when** is a `hive_claude::CompactionPolicy` injected by the harness: The **when** is a `hive_claude::CompactionPolicy` injected by the harness:
@ -166,7 +166,7 @@ still applies.
session (same mechanism as the operator reset — rename `<uuid>.jsonl` session (same mechanism as the operator reset — rename `<uuid>.jsonl`
`.archived`) so the next turn's `--resume <title>` misses and starts `.archived`) so the next turn's `--resume <title>` misses and starts
fresh. Unlike proactive compaction the session is dropped entirely, not fresh. Unlike proactive compaction the session is dropped entirely, not
compacted — and *no* preceding checkpoint turn runs, because any turn compacted — and _no_ preceding checkpoint turn runs, because any turn
before the reset would just re-warm the cache and defeat the purpose. before the reset would just re-warm the cache and defeat the purpose.
Set `HIVE_AUTO_RESET_WATERMARK_TOKENS=0` to disable. Auto-reset and the Set `HIVE_AUTO_RESET_WATERMARK_TOKENS=0` to disable. Auto-reset and the
operator reset are mutually exclusive per turn (both archive → fresh operator reset are mutually exclusive per turn (both archive → fresh
@ -236,11 +236,11 @@ needs to `chown` a bind mount), once at startup:
**`hive_identity` / `swarm_identity` shape.** Each carries a **`hive_identity` / `swarm_identity` shape.** Each carries a
leading space + backticked name (` on hive \`pr1ma\``, leading space + backticked name (` on hive \`pr1ma\``,
` in swarm \`constellat1on\``) when the corresponding env var ` in swarm \`constellat1on\``) when the corresponding env var
is set, otherwise empty string. The independence lets the is set, otherwise empty string. The independence lets the
template drop one or both into the opener prose without template drop one or both into the opener prose without
breaking single-hive deployments that never set the option; breaking single-hive deployments that never set the option;
the renderer also treats `Some("")` from a caller as `None` so the renderer also treats `Some("")`from a caller as`None` so
empty-string env vars and missing env vars round-trip the empty-string env vars and missing env vars round-trip the
same way. same way.
@ -251,4 +251,3 @@ telemetry-to-bus bridge — lives in `hive-agent`'s `turn` module; see its
The actual claude spawn, stream classification, and the The actual claude spawn, stream classification, and the
reactive/proactive compaction loop are in the `hive-claude` crate. reactive/proactive compaction loop are in the `hive-claude` crate.
Login-wait lives in `hive-agent`'s `login` module. Login-wait lives in `hive-agent`'s `login` module.

View file

@ -75,11 +75,11 @@ Declares extra navigation links that appear in the per-agent page
header alongside the built-in forge / config / container links. Each header alongside the built-in forge / config / container links. Each
entry has: entry has:
| Field | Required | Description | | Field | Required | Description |
|-------|----------|-------------| | ------- | -------- | ----------------------------------------------------------------------------------------- |
| `label` | yes | Display text shown in the icon strip tooltip and meta-nav. | | `label` | yes | Display text shown in the icon strip tooltip and meta-nav. |
| `url` | yes | Absolute URL — may include a different port (the dashboard renders it as a plain anchor). | | `url` | yes | Absolute URL — may include a different port (the dashboard renders it as a plain anchor). |
| `icon` | no | Emoji or short glyph prefix. Defaults to empty string. | | `icon` | no | Emoji or short glyph prefix. Defaults to empty string. |
The list is written to `<state>/hyperhive-dashboard-links.json` by a The list is written to `<state>/hyperhive-dashboard-links.json` by a
one-shot systemd unit at container boot. The harness's own web UI one-shot systemd unit at container boot. The harness's own web UI
@ -213,6 +213,7 @@ hyperhive.claudePluginsAutoUpdate = false; # default
> to list `skill-creator@claude-plugins-official` and `base@hyperhive` > to list `skill-creator@claude-plugins-official` and `base@hyperhive`
> explicitly alongside its own entries — likewise for the two default > explicitly alongside its own entries — likewise for the two default
> entries in `claudeMarketplaces`. > entries in `claudeMarketplaces`.
- **`claudePluginsAutoUpdate`** — when `true`, runs - **`claudePluginsAutoUpdate`** — when `true`, runs
`claude plugin marketplace update` before installing plugins to pull `claude plugin marketplace update` before installing plugins to pull
the latest index. Disabled by default to keep boot times short and the latest index. Disabled by default to keep boot times short and
@ -265,7 +266,7 @@ is a no-op without the other:
(outside the nix store, one `KEY=value` per line, systemd (outside the nix store, one `KEY=value` per line, systemd
`EnvironmentFile` syntax) supplying the credentials `claude` itself `EnvironmentFile` syntax) supplying the credentials `claude` itself
reads from the environment — typically `ANTHROPIC_API_KEY` and reads from the environment — typically `ANTHROPIC_API_KEY` and
`ANTHROPIC_BASE_URL`. Loaded as an *optional* `EnvironmentFile` `ANTHROPIC_BASE_URL`. Loaded as an _optional_ `EnvironmentFile`
(leading `-`), so setting the option before the file exists doesn't (leading `-`), so setting the option before the file exists doesn't
strand the harness at boot. strand the harness at boot.
@ -299,4 +300,3 @@ Switching an already-provisioned OAuth agent to `useApiKey` leaves
cleaned up automatically. Cost shape also changes: subscription pricing cleaned up automatically. Cost shape also changes: subscription pricing
→ per-request billing with no built-in monthly cap, worth knowing before → per-request billing with no built-in monthly cap, worth knowing before
pointing a busy agent at a metered backend. pointing a busy agent at a metered backend.

View file

@ -106,7 +106,7 @@ already in flight.
`{state_dir}/hyperhive-status`. Pass `""` to clear. `{state_dir}/hyperhive-status`. Pass `""` to clear.
- `get_agent_meta` — fetch identity + status metadata for an agent: - `get_agent_meta` — fetch identity + status metadata for an agent:
`{ name, hyperhive_rev, running, status_text, status_set_at, `{ name, hyperhive_rev, running, status_text, status_set_at,
hive_name?, swarm_name?, matrix_accounts? }`. `matrix_accounts` is a hive_name?, swarm_name?, matrix_accounts? }`. `matrix_accounts` is a
list of matrix identities the agent can act as (`name`, `user_id?`, list of matrix identities the agent can act as (`name`, `user_id?`,
`homeserver`); omitted for agents with no matrix provisioning. Omit `homeserver`); omitted for agents with no matrix provisioning. Omit
`name` to query self. `name` to query self.
@ -229,4 +229,3 @@ built-in shell there stays within the existing trust boundary. The bash MCP serv
`WebFetch` / `WebSearch` are off by default; enable the `web_tools` `WebFetch` / `WebSearch` are off by default; enable the `web_tools`
tool group in the P3RM1SS10NS tab and rebuild the agent to enable them. tool group in the P3RM1SS10NS tab and rebuild the agent to enable them.

View file

@ -9,7 +9,7 @@ Both are SPAs — `GET /` returns a static shell, `/api/state`
returns JSON, JS renders. No full-page reloads. returns JSON, JS renders. No full-page reloads.
This doc has been split for readability. Pick the section you need — This doc has been split for readability. Pick the section you need —
or, if you're the operator looking for how to actually *use* the or, if you're the operator looking for how to actually _use_ the
dashboard rather than how it's built, start at dashboard rather than how it's built, start at
**[web-ui/README.md](web-ui/README.md)** instead. **[web-ui/README.md](web-ui/README.md)** instead.

View file

@ -18,7 +18,7 @@ of your time. It's a single page with exactly four tabs:
- **SW4RM** — every agent, live. This is the default tab and the one - **SW4RM** — every agent, live. This is the default tab and the one
you'll check most. you'll check most.
- **Y3R C4LL** — anything waiting on *you*: pending approvals. If an - **Y3R C4LL** — anything waiting on _you_: pending approvals. If an
agent needs a decision from you, it's here. agent needs a decision from you, it's here.
- **P3RM1SS10NS** — what tools and system-level access each agent has. - **P3RM1SS10NS** — what tools and system-level access each agent has.
- **SCH3DUL3S** — scheduled prompts and agent self-reminders. - **SCH3DUL3S** — scheduled prompts and agent self-reminders.
@ -47,7 +47,7 @@ and quick links (stats, screen, forge profile). Click the name to open
its terminal and watch it work in real time. its terminal and watch it work in real time.
**Approve something an agent is waiting on.** Y3R C4LL is the one tab **Approve something an agent is waiting on.** Y3R C4LL is the one tab
worth checking regularly — it's everything that needs *you*: approvals worth checking regularly — it's everything that needs _you_: approvals
for config changes. The tab's count pill tells you at a glance if for config changes. The tab's count pill tells you at a glance if
anything's pending. anything's pending.

View file

@ -2,7 +2,7 @@
Scope: **swarm-ui only** (the swarm-level Preact app — not the per-hive Scope: **swarm-ui only** (the swarm-level Preact app — not the per-hive
dashboard, which has its own older visual language). This doc is the dashboard, which has its own older visual language). This doc is the
*why*: the principles behind how swarm-ui looks and behaves, and the _why_: the principles behind how swarm-ui looks and behaves, and the
concrete rules that follow from them. It deliberately doesn't show what concrete rules that follow from them. It deliberately doesn't show what
things look like — that's `ComponentsPage` (`/components`), the living, things look like — that's `ComponentsPage` (`/components`), the living,
always-current demo of every primitive in `src/ui/`. Code can't go stale always-current demo of every primitive in `src/ui/`. Code can't go stale
@ -21,14 +21,14 @@ repeated here.
distinction, not decoration for its own sake. distinction, not decoration for its own sake.
- **Whimsy** — small, delightful touches are welcome (the per-hive - **Whimsy** — small, delightful touches are welcome (the per-hive
dashboard's home-page matrix-rain background, `packages/dashboard/src/ dashboard's home-page matrix-rain background, `packages/dashboard/src/
home.js`, is the reference example — currently in the dashboard home.js`, is the reference example — currently in the dashboard
package, not swarm-ui itself, but the pattern it sets applies here package, not swarm-ui itself, but the pattern it sets applies here
too). Whimsy still has to clear the accessibility bar below (motion, too). Whimsy still has to clear the accessibility bar below (motion,
in particular). swarm-ui's own reference example: `Panel`'s optional in particular). swarm-ui's own reference example: `Panel`'s optional
`icon` prop (`src/ui/panel/Panel.tsx`), a small emoji glyph in a `icon` prop (`src/ui/panel/Panel.tsx`), a small emoji glyph in a
panel's header, chosen per panel with no default — grew out of a panel's header, chosen per panel with no default — grew out of a
one-off emoji dropped into a single page's copy, which wasn't whimsy one-off emoji dropped into a single page's copy, which wasn't whimsy
in this sense (a *consistent*, reusable touch) until it became a real in this sense (a _consistent_, reusable touch) until it became a real
prop every panel can opt into. `aria-hidden`, since it's decorative — prop every panel can opt into. `aria-hidden`, since it's decorative —
the title text is still the actual label. the title text is still the actual label.
- **Efficient navigation** — minimize clicks/hops for a common task. - **Efficient navigation** — minimize clicks/hops for a common task.
@ -62,7 +62,7 @@ smooth them instead.
The mechanical contract (base16 slots, semantic vars, what a page's CSS The mechanical contract (base16 slots, semantic vars, what a page's CSS
is and isn't allowed to reference) lives in `docs/web-ui/css-vars.md` is and isn't allowed to reference) lives in `docs/web-ui/css-vars.md`
read that for the how. This section is the *policy* layered on top: read that for the how. This section is the _policy_ layered on top:
- **User-theming compatible by construction.** The whole point of the - **User-theming compatible by construction.** The whole point of the
base16/`colors.css` swap contract is that a user's own theme (stylix base16/`colors.css` swap contract is that a user's own theme (stylix
@ -132,7 +132,7 @@ just bare column headers floating over nothing.
touch target (`2.75em` ≈ 44px, WCAG 2.5.5) by default, so this isn't touch target (`2.75em` ≈ 44px, WCAG 2.5.5) by default, so this isn't
something each page has to remember. something each page has to remember.
- **Phone is a second-class citizen, not an unsupported one.** Things - **Phone is a second-class citizen, not an unsupported one.** Things
must not *break* at phone width, but don't over-invest in phone must not _break_ at phone width, but don't over-invest in phone
optimization beyond that. swarm-ui is installable as a PWA, so a optimization beyond that. swarm-ui is installable as a PWA, so a
phone can check status or do basic interactions. phone can check status or do basic interactions.
- **A narrow viewport isn't only a phone** — a tiling-window-manager - **A narrow viewport isn't only a phone** — a tiling-window-manager

View file

@ -21,33 +21,33 @@
// `tower_http::ServeDir` fallback; the layout above keeps every URL // `tower_http::ServeDir` fallback; the layout above keeps every URL
// the HTML references reachable without rewriting paths. // the HTML references reachable without rewriting paths.
import { build } from 'esbuild'; import { build } from "esbuild";
import { mkdirSync, copyFileSync, rmSync } from 'node:fs'; import { mkdirSync, copyFileSync, rmSync } from "node:fs";
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from "node:path";
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const src = (p) => resolve(here, 'src', p); const src = (p) => resolve(here, "src", p);
const dist = (p) => resolve(here, 'dist', p); const dist = (p) => resolve(here, "dist", p);
const staticDir = (p) => resolve(here, 'dist', 'static', p); const staticDir = (p) => resolve(here, "dist", "static", p);
rmSync(dist(''), { recursive: true, force: true }); rmSync(dist(""), { recursive: true, force: true });
mkdirSync(staticDir(''), { recursive: true }); mkdirSync(staticDir(""), { recursive: true });
// The stats page's own bundle — chart.js/auto, its own deps. // The stats page's own bundle — chart.js/auto, its own deps.
await build({ await build({
entryPoints: [src('stats.js')], entryPoints: [src("stats.js")],
outdir: staticDir(''), outdir: staticDir(""),
bundle: true, bundle: true,
format: 'esm', format: "esm",
platform: 'browser', platform: "browser",
target: ['es2022'], target: ["es2022"],
sourcemap: true, sourcemap: true,
logLevel: 'info', logLevel: "info",
// `@hive/shared/modal.js` imports its shadow-DOM component CSS as raw // `@hive/shared/modal.js` imports its shadow-DOM component CSS as raw
// text (see dashboard/build.mjs's matching comment) — same reasoning // text (see dashboard/build.mjs's matching comment) — same reasoning
// applies here. // applies here.
loader: { '.css': 'text' }, loader: { ".css": "text" },
}); });
// The main agent page (hyperhive#3685's Preact rewrite — app.js, // The main agent page (hyperhive#3685's Preact rewrite — app.js,
@ -58,17 +58,17 @@ await build({
// modal.js`) and JSX transpilation, same split swarm-ui's own build // modal.js`) and JSX transpilation, same split swarm-ui's own build
// already makes. // already makes.
await build({ await build({
entryPoints: [src('main.tsx')], entryPoints: [src("main.tsx")],
outfile: staticDir('main.js'), outfile: staticDir("main.js"),
bundle: true, bundle: true,
format: 'esm', format: "esm",
platform: 'browser', platform: "browser",
target: ['es2022'], target: ["es2022"],
sourcemap: true, sourcemap: true,
logLevel: 'info', logLevel: "info",
jsx: 'automatic', jsx: "automatic",
jsxImportSource: 'preact', jsxImportSource: "preact",
loader: { '.css': 'css' }, loader: { ".css": "css" },
}); });
// Bundle the CSS. `colors.css` re-exports the standalone base16 palette // Bundle the CSS. `colors.css` re-exports the standalone base16 palette
@ -76,18 +76,18 @@ await build({
// it, no bundle rebuild); `theme.css` is the semantic derivation layer; // it, no bundle rebuild); `theme.css` is the semantic derivation layer;
// `agent.css`'s @import lines pull in shared base.css + terminal.css from // `agent.css`'s @import lines pull in shared base.css + terminal.css from
// the @hive/shared workspace dep. // the @hive/shared workspace dep.
for (const entry of ['colors.css', 'theme.css', 'agent.css']) { for (const entry of ["colors.css", "theme.css", "agent.css"]) {
await build({ await build({
entryPoints: [src(entry)], entryPoints: [src(entry)],
outfile: staticDir(entry), outfile: staticDir(entry),
bundle: true, bundle: true,
loader: { '.css': 'css' }, loader: { ".css": "css" },
logLevel: 'info', logLevel: "info",
}); });
} }
for (const html of ['index.html', 'stats.html', 'screen.html']) { for (const html of ["index.html", "stats.html", "screen.html"]) {
copyFileSync(src(html), dist(html)); copyFileSync(src(html), dist(html));
} }
console.log('agent build ok →', dist('')); console.log("agent build ok →", dist(""));

View file

@ -4,55 +4,61 @@
// `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with // `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with
// no credentials has no other web-UI path back online, so this isn't // no credentials has no other web-UI path back online, so this isn't
// optional polish. // optional polish.
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from "preact/hooks";
import type { BadgeTone } from '@hive/shared/badge.js'; import type { BadgeTone } from "@hive/shared/badge.js";
import { SettingsMenu } from '@hive/shared/settings-menu.js'; import { SettingsMenu } from "@hive/shared/settings-menu.js";
import { useApplyThemeOverride } from '@hive/shared/theme-apply.js'; import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
import { useApplyMotionOverride } from '@hive/shared/motion-apply.js'; import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
import { Header } from './components/Header.js'; import { Header } from "./components/Header.js";
import { MetaNav } from './components/MetaNav.js'; import { MetaNav } from "./components/MetaNav.js";
import { ExpandDetailsSetting } from './components/ExpandDetailsSetting.js'; import { ExpandDetailsSetting } from "./components/ExpandDetailsSetting.js";
import { StatusChips } from './components/StatusChips.js'; import { StatusChips } from "./components/StatusChips.js";
import { LiveStream, type LiveStreamHandle } from './components/LiveStream.js'; import { LiveStream, type LiveStreamHandle } from "./components/LiveStream.js";
import { HeaderPill } from './components/HeaderPill.js'; import { HeaderPill } from "./components/HeaderPill.js";
import { SidePanel } from './components/SidePanel.js'; import { SidePanel } from "./components/SidePanel.js";
import { InboxPanel } from './components/InboxPanel.js'; import { InboxPanel } from "./components/InboxPanel.js";
import { TodosPanel } from './components/TodosPanel.js'; import { TodosPanel } from "./components/TodosPanel.js";
import { TermInput } from './components/TermInput.js'; import { TermInput } from "./components/TermInput.js";
import { LoginFlow } from './components/LoginFlow.js'; import { LoginFlow } from "./components/LoginFlow.js";
import { useAgentState } from './hooks/useAgentState.js'; import { useAgentState } from "./hooks/useAgentState.js";
import { useTodos } from './hooks/useTodos.js'; import { useTodos } from "./hooks/useTodos.js";
import { fmtAge, fmtTokens } from './lib/format.js'; import { fmtAge, fmtTokens } from "./lib/format.js";
import { resolveDashboardBase } from './lib/dashboardBase.js'; import { resolveDashboardBase } from "./lib/dashboardBase.js";
import { submitPauseResume } from './lib/pauseAction.js'; import { submitPauseResume } from "./lib/pauseAction.js";
import { postModel, postEffort } from './lib/modelEffort.js'; import { postModel, postEffort } from "./lib/modelEffort.js";
import { postCancelTurn } from './lib/termActions.js'; import { postCancelTurn } from "./lib/termActions.js";
import type { TokenUsage } from './types.js'; import type { TokenUsage } from "./types.js";
type OpenPanel = 'inbox' | 'todos' | null; type OpenPanel = "inbox" | "todos" | null;
// Storage keys this page owns for `@hive/shared`'s settings mechanism — // Storage keys this page owns for `@hive/shared`'s settings mechanism —
// kept here, not derived, so `useApplyThemeOverride`/`useApplyMotionOverride` // kept here, not derived, so `useApplyThemeOverride`/`useApplyMotionOverride`
// (mounted once below) and `<SettingsMenu>` (in `pills`, further down) // (mounted once below) and `<SettingsMenu>` (in `pills`, further down)
// always agree on which browser-local key they're reading/writing. // always agree on which browser-local key they're reading/writing.
const THEME_KEY = 'agent:theme-override'; const THEME_KEY = "agent:theme-override";
const MOTION_KEY = 'agent:motion-override'; const MOTION_KEY = "agent:motion-override";
const ALIVE_LABELS: Record<string, { glyph: string; text: string; tone: BadgeTone }> = { const ALIVE_LABELS: Record<
online: { glyph: '●', text: 'alive', tone: 'positive' }, string,
rate_limited: { glyph: '⊘', text: 'rate limited', tone: 'warning' }, { glyph: string; text: string; tone: BadgeTone }
needs_login_idle: { glyph: '◌', text: 'needs login', tone: 'warning' }, > = {
needs_login_in_progress: { glyph: '◌', text: 'logging in', tone: 'warning' }, online: { glyph: "●", text: "alive", tone: "positive" },
rate_limited: { glyph: "⊘", text: "rate limited", tone: "warning" },
needs_login_idle: { glyph: "◌", text: "needs login", tone: "warning" },
needs_login_in_progress: { glyph: "◌", text: "logging in", tone: "warning" },
}; };
const STATE_LABELS: Record<string, { glyph: string; text: string; tone: BadgeTone }> = { const STATE_LABELS: Record<
idle: { glyph: '💤', text: 'idle', tone: 'neutral' }, string,
thinking: { glyph: '🧠', text: 'thinking', tone: 'accent' }, { glyph: string; text: string; tone: BadgeTone }
compacting: { glyph: '📦', text: 'compacting', tone: 'warning' }, > = {
idle: { glyph: "💤", text: "idle", tone: "neutral" },
thinking: { glyph: "🧠", text: "thinking", tone: "accent" },
compacting: { glyph: "📦", text: "compacting", tone: "warning" },
}; };
const STATE_TOOLTIPS: Record<string, string> = { const STATE_TOOLTIPS: Record<string, string> = {
offline: 'harness unreachable or claude not logged in', offline: "harness unreachable or claude not logged in",
idle: 'turn loop running, no claude invocation in flight', idle: "turn loop running, no claude invocation in flight",
thinking: 'claude is executing the current turn', thinking: "claude is executing the current turn",
compacting: "operator-triggered /compact running on the persistent session", compacting: "operator-triggered /compact running on the persistent session",
}; };
@ -61,7 +67,11 @@ const STATE_TOOLTIPS: Record<string, string> = {
// title breakdown, not folded into the headline number). // title breakdown, not folded into the headline number).
function tokenTotal(u: TokenUsage | null): number | null { function tokenTotal(u: TokenUsage | null): number | null {
if (!u) return null; if (!u) return null;
return (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0); return (
(u.input_tokens ?? 0) +
(u.cache_read_input_tokens ?? 0) +
(u.cache_creation_input_tokens ?? 0)
);
} }
export function Root() { export function Root() {
@ -96,13 +106,15 @@ export function Root() {
state.qualified_label && state.qualified_label !== state.label state.qualified_label && state.qualified_label !== state.label
? state.qualified_label ? state.qualified_label
: state.label; : state.label;
document.title = state.hive_name ? `${state.label} // ${state.hive_name}` : `${tab} // hyperhive`; document.title = state.hive_name
? `${state.label} // ${state.hive_name}`
: `${tab} // hyperhive`;
}, [state?.label, state?.qualified_label, state?.hive_name]); }, [state?.label, state?.qualified_label, state?.hive_name]);
const termInput = ( const termInput = (
<footer class="agent-composer"> <footer class="agent-composer">
<TermInput <TermInput
label={state?.label ?? '…'} label={state?.label ?? "…"}
online={state?.status === 'online'} online={state?.status === "online"}
onLocalNote={(text) => liveStreamRef.current?.pushNote(text)} onLocalNote={(text) => liveStreamRef.current?.pushNote(text)}
onClear={() => liveStreamRef.current?.clear()} onClear={() => liveStreamRef.current?.clear()}
/> />
@ -111,8 +123,20 @@ export function Root() {
const pills = ( const pills = (
<> <>
<HeaderPill kind="inbox" icon="📬" label="inbox" count={state?.inbox.length ?? 0} onClick={() => setOpenPanel('inbox')} /> <HeaderPill
<HeaderPill kind="todos" icon="📋" label="todos" count={todos.length} onClick={() => setOpenPanel('todos')} /> kind="inbox"
icon="📬"
label="inbox"
count={state?.inbox.length ?? 0}
onClick={() => setOpenPanel("inbox")}
/>
<HeaderPill
kind="todos"
icon="📋"
label="todos"
count={todos.length}
onClick={() => setOpenPanel("todos")}
/>
{/* Needs a loaded snapshot for its data (links/forge_public_url, {/* Needs a loaded snapshot for its data (links/forge_public_url,
* dashboard_port) omitted until the first /api/state resolves, * dashboard_port) omitted until the first /api/state resolves,
* same as the old page's populateOverflowMenu (now deleted * same as the old page's populateOverflowMenu (now deleted
@ -135,17 +159,28 @@ export function Root() {
// component — SidePanel's slide/fade CSS transitions only fire on a // component — SidePanel's slide/fade CSS transitions only fire on a
// class change after mount, not on an already-open initial render, so // class change after mount, not on an already-open initial render, so
// mount-on-open would skip both the open AND the close animation. // mount-on-open would skip both the open AND the close animation.
const panelTitle = openPanel === 'inbox' ? `inbox · ${state?.inbox.length ?? 0}` : openPanel === 'todos' ? `todos · ${todos.length}` : ''; const panelTitle =
openPanel === "inbox"
? `inbox · ${state?.inbox.length ?? 0}`
: openPanel === "todos"
? `todos · ${todos.length}`
: "";
const panel = ( const panel = (
<SidePanel open={openPanel !== null} title={panelTitle} onClose={() => setOpenPanel(null)}> <SidePanel
{openPanel === 'inbox' ? ( open={openPanel !== null}
title={panelTitle}
onClose={() => setOpenPanel(null)}
>
{openPanel === "inbox" ? (
<InboxPanel <InboxPanel
rows={state?.inbox ?? []} rows={state?.inbox ?? []}
label={state?.label ?? ''} label={state?.label ?? ""}
dashboardBase={state ? resolveDashboardBase(state.dashboard_port) : ''} dashboardBase={
state ? resolveDashboardBase(state.dashboard_port) : ""
}
onCleared={refresh} onCleared={refresh}
/> />
) : openPanel === 'todos' ? ( ) : openPanel === "todos" ? (
<TodosPanel todos={todos} onCleared={refreshTodos} /> <TodosPanel todos={todos} onCleared={refreshTodos} />
) : null} ) : null}
</SidePanel> </SidePanel>
@ -179,13 +214,22 @@ export function Root() {
); );
} }
const alive = ALIVE_LABELS[state.status] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone }; const alive = ALIVE_LABELS[state.status] ?? {
glyph: "○",
text: "offline",
tone: "negative" as BadgeTone,
};
// Mirrors app.js's refreshState: any non-'online' status forces the // Mirrors app.js's refreshState: any non-'online' status forces the
// turn-state badge to 'offline' regardless of the server's last-known // turn-state badge to 'offline' regardless of the server's last-known
// turn_state (login/rate-limit takes visual priority over a stale // turn_state (login/rate-limit takes visual priority over a stale
// idle/thinking reading from before the harness went offline). // idle/thinking reading from before the harness went offline).
const effectiveTurnState = state.status === 'online' ? state.turn_state : 'offline'; const effectiveTurnState =
const turnDef = STATE_LABELS[effectiveTurnState] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone }; state.status === "online" ? state.turn_state : "offline";
const turnDef = STATE_LABELS[effectiveTurnState] ?? {
glyph: "○",
text: "offline",
tone: "negative" as BadgeTone,
};
const stateAge = fmtAge(now - state.turn_state_since * 1000); const stateAge = fmtAge(now - state.turn_state_since * 1000);
// Consolidated status badge (mara: "why do we have separate alive // Consolidated status badge (mara: "why do we have separate alive
// badge? ... one badge that shows thinking / idle / paused"). // badge? ... one badge that shows thinking / idle / paused").
@ -195,10 +239,20 @@ export function Root() {
// thinking regardless of its last turn_state. Online + running → // thinking regardless of its last turn_state. Online + running →
// turn-state + age, same as before. // turn-state + age, same as before.
const primaryStatus = const primaryStatus =
state.status !== 'online' state.status !== "online"
? { glyph: alive.glyph, text: alive.text, tone: alive.tone, tooltip: undefined as string | undefined } ? {
glyph: alive.glyph,
text: alive.text,
tone: alive.tone,
tooltip: undefined as string | undefined,
}
: state.paused : state.paused
? { glyph: '⏸', text: 'paused', tone: 'warning' as BadgeTone, tooltip: undefined } ? {
glyph: "⏸",
text: "paused",
tone: "warning" as BadgeTone,
tooltip: undefined,
}
: { : {
glyph: turnDef.glyph, glyph: turnDef.glyph,
text: `${turnDef.text} · ${stateAge}`, text: `${turnDef.text} · ${stateAge}`,
@ -212,7 +266,10 @@ export function Root() {
<> <>
<Header <Header
label={state.label} label={state.label}
hiveLabel={[state.swarm_name, state.hive_name].filter(Boolean).join(' / ') || null} hiveLabel={
[state.swarm_name, state.hive_name].filter(Boolean).join(" / ") ||
null
}
pills={pills} pills={pills}
> >
<StatusChips <StatusChips
@ -234,22 +291,32 @@ export function Root() {
costLabel={cost !== null ? fmtTokens(cost) : undefined} costLabel={cost !== null ? fmtTokens(cost) : undefined}
paused={state.paused} paused={state.paused}
onTogglePause={() => { onTogglePause={() => {
submitPauseResume(resolveDashboardBase(state.dashboard_port), state.label, state.paused ? 'resume' : 'pause').then( submitPauseResume(
() => refresh(), resolveDashboardBase(state.dashboard_port),
); state.label,
state.paused ? "resume" : "pause",
).then(() => refresh());
}} }}
thinking={effectiveTurnState === 'thinking'} thinking={effectiveTurnState === "thinking"}
onCancelTurn={() => onCancelTurn={() =>
postCancelTurn().then((r) => { postCancelTurn().then((r) => {
if (!r.ok) liveStreamRef.current?.pushNote(`✗ /cancel failed${r.detail ? ': ' + r.detail : ''}`); if (!r.ok)
liveStreamRef.current?.pushNote(
`✗ /cancel failed${r.detail ? ": " + r.detail : ""}`,
);
refresh(); refresh();
}) })
} }
/> />
</Header> </Header>
<main className="agent-main"> <main className="agent-main">
{state.status === 'needs_login_idle' || state.status === 'needs_login_in_progress' ? ( {state.status === "needs_login_idle" ||
<LoginFlow status={state.status} session={state.session} onRefresh={refresh} /> state.status === "needs_login_in_progress" ? (
<LoginFlow
status={state.status}
session={state.session}
onRefresh={refresh}
/>
) : null} ) : null}
<LiveStream ref={liveStreamRef} /> <LiveStream ref={liveStreamRef} />
</main> </main>

View file

@ -30,7 +30,11 @@
--agent-frost-blur: blur(12px) saturate(140%); --agent-frost-blur: blur(12px) saturate(140%);
} }
html, body { height: 100%; margin: 0; } html,
body {
height: 100%;
margin: 0;
}
/* Legacy in-page layout retained for the sibling stats page /* Legacy in-page layout retained for the sibling stats page
(`stats.html`) which doesn't apply `body.agent-shell` and stays (`stats.html`) which doesn't apply `body.agent-shell` and stays
@ -58,7 +62,9 @@ body:not(.agent-shell):not(.screen-shell) {
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--purple) 45%, transparent)); filter: drop-shadow(
0 0 6px color-mix(in srgb, var(--purple) 45%, transparent)
);
} }
body.agent-shell { body.agent-shell {
@ -69,9 +75,11 @@ body.agent-shell {
/* Subtle radial accent to give the otherwise-flat full-screen /* Subtle radial accent to give the otherwise-flat full-screen
surface some depth and reinforce the vibec0re mood. */ surface some depth and reinforce the vibec0re mood. */
background: background:
radial-gradient(ellipse 80% 60% at 50% 0%, radial-gradient(
color-mix(in srgb, var(--purple) 6%, transparent) 0%, ellipse 80% 60% at 50% 0%,
transparent 60%), color-mix(in srgb, var(--purple) 6%, transparent) 0%,
transparent 60%
),
var(--bg); var(--bg);
} }
@ -158,7 +166,8 @@ body.agent-shell {
align-self: center; align-self: center;
} }
h2, h3 { h2,
h3 {
color: var(--purple); color: var(--purple);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.15em; letter-spacing: 0.15em;
@ -190,7 +199,10 @@ h2, h3 {
padding: 0.1em 0.35em; padding: 0.1em 0.35em;
border-radius: 3px; border-radius: 3px;
text-shadow: 0 0 4px color-mix(in srgb, var(--cyan) 40%, transparent); text-shadow: 0 0 4px color-mix(in srgb, var(--cyan) 40%, transparent);
transition: color 0.15s ease, text-shadow 0.15s ease, background 0.15s ease; transition:
color 0.15s ease,
text-shadow 0.15s ease,
background 0.15s ease;
} }
.agent-nav-link:hover { .agent-nav-link:hover {
color: var(--fg); color: var(--fg);
@ -230,7 +242,9 @@ h2, h3 {
width: calc(100% - 3em); width: calc(100% - 3em);
z-index: 10; z-index: 10;
} }
.agent-status-overlay:empty { display: none; } .agent-status-overlay:empty {
display: none;
}
.agent-status-overlay > * { .agent-status-overlay > * {
background: var(--bg-elev); background: var(--bg-elev);
border: 1px solid var(--purple-dim); border: 1px solid var(--purple-dim);
@ -263,15 +277,31 @@ h2, h3 {
border-top: 0; border-top: 0;
padding-top: 0; padding-top: 0;
} }
.meta { color: var(--muted); font-size: 0.85em; } .meta {
.status-online { color: var(--green); text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent); } color: var(--muted);
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent); } font-size: 0.85em;
code { background: color-mix(in srgb, var(--purple) 12%, transparent); padding: 0.05em 0.3em; border-radius: 2px; } }
.status-online {
color: var(--green);
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent);
}
.status-needs-login {
color: var(--amber);
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent);
}
code {
background: color-mix(in srgb, var(--purple) 12%, transparent);
padding: 0.05em 0.3em;
border-radius: 2px;
}
a { a {
color: var(--cyan); color: var(--cyan);
text-shadow: 0 0 4px color-mix(in srgb, var(--cyan) 50%, transparent); text-shadow: 0 0 4px color-mix(in srgb, var(--cyan) 50%, transparent);
} }
a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan) 90%, transparent); } a:hover {
color: var(--fg);
text-shadow: 0 0 12px color-mix(in srgb, var(--cyan) 90%, transparent);
}
.btn { .btn {
font-family: inherit; font-family: inherit;
font-size: 1em; font-size: 1em;
@ -284,15 +314,25 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan)
} }
.btn { .btn {
text-shadow: 0 0 4px currentColor; text-shadow: 0 0 4px currentColor;
transition: box-shadow 0.15s ease, text-shadow 0.15s ease; transition:
box-shadow 0.15s ease,
text-shadow 0.15s ease;
} }
.btn:hover { .btn:hover {
background: color-mix(in srgb, var(--fg) 6%, transparent); background: color-mix(in srgb, var(--fg) 6%, transparent);
text-shadow: 0 0 10px currentColor; text-shadow: 0 0 10px currentColor;
box-shadow: 0 0 10px -2px currentColor; box-shadow: 0 0 10px -2px currentColor;
} }
.btn-login { color: var(--amber); border-color: var(--amber); } .btn-login {
.btn-cancel { color: var(--red); border-color: var(--red); font-size: 0.85em; padding: 0.15em 0.6em; } color: var(--amber);
border-color: var(--amber);
}
.btn-cancel {
color: var(--red);
border-color: var(--red);
font-size: 0.85em;
padding: 0.15em 0.6em;
}
/* Orphaned rules left here as a tombstone so a search for the /* Orphaned rules left here as a tombstone so a search for the
class name finds them. Live consumers gone: class name finds them. Live consumers gone:
- `.btn-rebuild` was a per-agent header chip, then briefly an - `.btn-rebuild` was a per-agent header chip, then briefly an
@ -301,26 +341,41 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan)
outright now, no replacement. outright now, no replacement.
- `.btn-send` was a green send-button variant the dashboard's - `.btn-send` was a green send-button variant the dashboard's
compose form that used it is retired. */ compose form that used it is retired. */
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; } .sendform {
display: flex;
gap: 0.6em;
margin-top: 0.5em;
}
.sendform input { .sendform input {
font-family: inherit; font-size: 1em; font-family: inherit;
font-size: 1em;
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
color: var(--fg); color: var(--fg);
border: 1px solid var(--purple-dim); border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em; padding: 0.4em 0.6em;
flex: 1; flex: 1;
} }
.sendform input:focus { outline: 1px solid var(--purple); } .sendform input:focus {
.loginform { display: flex; gap: 0.6em; margin-top: 0.5em; align-items: stretch; } outline: 1px solid var(--purple);
}
.loginform {
display: flex;
gap: 0.6em;
margin-top: 0.5em;
align-items: stretch;
}
.loginform input { .loginform input {
font-family: inherit; font-size: 1em; font-family: inherit;
font-size: 1em;
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
color: var(--fg); color: var(--fg);
border: 1px solid var(--purple-dim); border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em; padding: 0.4em 0.6em;
flex: 1; flex: 1;
} }
.loginform input:focus { outline: 1px solid var(--purple); } .loginform input:focus {
outline: 1px solid var(--purple);
}
/* Show / hide toggle for the masked OAuth-code input. Quiet by /* Show / hide toggle for the masked OAuth-code input. Quiet by
default (muted border + transparent bg), lights amber on hover default (muted border + transparent bg), lights amber on hover
@ -338,7 +393,10 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan)
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; transition:
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
} }
.loginform-reveal:hover, .loginform-reveal:hover,
.loginform-reveal:focus-visible { .loginform-reveal:focus-visible {
@ -373,8 +431,12 @@ pre.diff {
letter-spacing: 0.05em; letter-spacing: 0.05em;
list-style: none; list-style: none;
} }
.agent-inbox > summary::marker { content: ''; } .agent-inbox > summary::marker {
.agent-inbox[open] > summary > span::before { content: ''; } content: "";
}
.agent-inbox[open] > summary > span::before {
content: "";
}
.agent-inbox ul { .agent-inbox ul {
list-style: none; list-style: none;
padding: 0.4em 0.8em; padding: 0.4em 0.8em;
@ -392,9 +454,18 @@ pre.diff {
padding: 0.4em 0; padding: 0.4em 0;
display: block; display: block;
} }
.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; margin-left: 0.5em; } .agent-inbox .inbox-ts {
.agent-inbox .inbox-from { color: var(--amber); } color: var(--muted);
.agent-inbox .inbox-sep { color: var(--muted); margin-left: 0.4em; } font-size: 0.9em;
margin-left: 0.5em;
}
.agent-inbox .inbox-from {
color: var(--amber);
}
.agent-inbox .inbox-sep {
color: var(--muted);
margin-left: 0.4em;
}
/* Todos flyout per-row checkbox (bulk mark-done) sits inline before the /* Todos flyout per-row checkbox (bulk mark-done) sits inline before the
existing `.inbox-from` label, same row. */ existing `.inbox-from` label, same row. */
.agent-inbox .todo-cb { .agent-inbox .todo-cb {
@ -411,9 +482,16 @@ pre.diff {
border-left: 2px solid var(--purple-dim); border-left: 2px solid var(--purple-dim);
} }
/* Running bash-tasks flyout rows (buildBashTasksList). */ /* Running bash-tasks flyout rows (buildBashTasksList). */
.agent-inbox .bash-task-status { font-weight: bold; font-size: 0.9em; } .agent-inbox .bash-task-status {
.agent-inbox .bash-task-running { color: var(--green); } font-weight: bold;
.agent-inbox .bash-task-pending { color: var(--blue); } font-size: 0.9em;
}
.agent-inbox .bash-task-running {
color: var(--green);
}
.agent-inbox .bash-task-pending {
color: var(--blue);
}
.agent-inbox .bash-task-cmd { .agent-inbox .bash-task-cmd {
font-family: monospace; font-family: monospace;
font-size: 0.92em; font-size: 0.92em;
@ -423,7 +501,10 @@ pre.diff {
border-left: 2px solid var(--border); border-left: 2px solid var(--border);
margin-left: 0.4em; margin-left: 0.4em;
} }
.agent-inbox .inbox-reply-tag { color: var(--muted); font-size: 0.85em; } .agent-inbox .inbox-reply-tag {
color: var(--muted);
font-size: 0.85em;
}
/* Bulk-action header row: "mark all read" above the recent-messages /* Bulk-action header row: "mark all read" above the recent-messages
list in the inbox flyout, and "select all / select none / mark list in the inbox flyout, and "select all / select none / mark
@ -473,8 +554,12 @@ pre.diff {
.effort-chip { .effort-chip {
border-color: var(--purple-dim); border-color: var(--purple-dim);
} }
.model-chip { color: var(--cyan); } .model-chip {
.effort-chip { color: var(--amber); } color: var(--cyan);
}
.effort-chip {
color: var(--amber);
}
/* Context-window badge. Mirrors Claude Code's bottom-right "N tokens" /* Context-window badge. Mirrors Claude Code's bottom-right "N tokens"
chip single primary number (total prompt tokens in use), full chip single primary number (total prompt tokens in use), full
breakdown on hover. Sized/coloured like a peer of model-chip so breakdown on hover. Sized/coloured like a peer of model-chip so
@ -489,13 +574,28 @@ pre.diff {
/* Harness reachability badge. Markup carries `hive-pill` alongside /* Harness reachability badge. Markup carries `hive-pill` alongside
`status-badge` (shared pill.css supplies the shape); colour `status-badge` (shared pill.css supplies the shape); colour
communicates the actual reachability state. */ communicates the actual reachability state. */
.status-badge.status-loading { color: var(--muted); border-color: var(--purple-dim); } .status-badge.status-loading {
.status-badge.status-online { color: var(--green); border-color: var(--green); color: var(--muted);
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent); } border-color: var(--purple-dim);
.status-badge.status-rate-limited { color: var(--red); border-color: var(--red); }
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 55%, transparent); } .status-badge.status-online {
.status-badge.status-needs-login { color: var(--amber); border-color: var(--amber); } color: var(--green);
.status-badge.status-offline { color: var(--muted); border-color: var(--muted); } border-color: var(--green);
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent);
}
.status-badge.status-rate-limited {
color: var(--red);
border-color: var(--red);
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 55%, transparent);
}
.status-badge.status-needs-login {
color: var(--amber);
border-color: var(--amber);
}
.status-badge.status-offline {
color: var(--muted);
border-color: var(--muted);
}
/* Orphaned tombstone `.btn-dashlink` chip that lived beside the /* Orphaned tombstone `.btn-dashlink` chip that lived beside the
title moved into the overflow menu, then into `MetaNav`'s `🔗` title moved into the overflow menu, then into `MetaNav`'s `🔗`
popover (the ` dashboard` item) once the overflow menu itself was popover (the ` dashboard` item) once the overflow menu itself was
@ -512,26 +612,34 @@ pre.diff {
supplies the shape); this rule is only the transition + state- supplies the shape); this rule is only the transition + state-
specific colour below. */ specific colour below. */
.state-badge { .state-badge {
transition: color 280ms ease, border-color 280ms ease, transition:
box-shadow 280ms ease, background 280ms ease; color 280ms ease,
border-color 280ms ease,
box-shadow 280ms ease,
background 280ms ease;
} }
.state-badge.state-loading { .state-badge.state-loading {
color: var(--muted); border-color: var(--purple-dim); color: var(--muted);
border-color: var(--purple-dim);
} }
.state-badge.state-offline { .state-badge.state-offline {
color: var(--muted); border-color: var(--muted); color: var(--muted);
border-color: var(--muted);
} }
.state-badge.state-idle { .state-badge.state-idle {
color: var(--cyan); border-color: var(--cyan); color: var(--cyan);
border-color: var(--cyan);
text-shadow: 0 0 6px color-mix(in srgb, var(--cyan) 55%, transparent); text-shadow: 0 0 6px color-mix(in srgb, var(--cyan) 55%, transparent);
} }
.state-badge.state-thinking { .state-badge.state-thinking {
color: var(--amber); border-color: var(--amber); color: var(--amber);
border-color: var(--amber);
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 65%, transparent); text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 65%, transparent);
animation: badge-pulse 1.8s ease-in-out infinite; animation: badge-pulse 1.8s ease-in-out infinite;
} }
.state-badge.state-compacting { .state-badge.state-compacting {
color: var(--purple); border-color: var(--purple); color: var(--purple);
border-color: var(--purple);
text-shadow: 0 0 6px color-mix(in srgb, var(--purple) 65%, transparent); text-shadow: 0 0 6px color-mix(in srgb, var(--purple) 65%, transparent);
animation: badge-pulse 1.8s ease-in-out infinite; animation: badge-pulse 1.8s ease-in-out infinite;
} }
@ -539,9 +647,21 @@ pre.diff {
animation: state-flash 600ms ease-out; animation: state-flash 600ms ease-out;
} }
@keyframes state-flash { @keyframes state-flash {
0% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; } 0% {
60% { box-shadow: 0 0 18px -4px currentColor, 0 0 4px 0 currentColor; } box-shadow:
100% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; } 0 0 0 0 currentColor,
0 0 0 0 currentColor;
}
60% {
box-shadow:
0 0 18px -4px currentColor,
0 0 4px 0 currentColor;
}
100% {
box-shadow:
0 0 0 0 currentColor,
0 0 0 0 currentColor;
}
} }
/* Full-screen overrides for the shared terminal rules. The base /* Full-screen overrides for the shared terminal rules. The base
`.terminal-wrap` (in shared/src/terminal.css) ships a crust-on- `.terminal-wrap` (in shared/src/terminal.css) ships a crust-on-
@ -571,7 +691,9 @@ pre.diff {
focus restore) clear of the floats too. */ focus restore) clear of the floats too. */
padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em); padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em);
padding-bottom: calc(var(--agent-composer-h) + 0.8em); padding-bottom: calc(var(--agent-composer-h) + 0.8em);
scroll-padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em); scroll-padding-top: calc(
var(--agent-header-real-h, var(--agent-header-h)) + 0.8em
);
scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em); scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em);
overflow: auto; overflow: auto;
} }
@ -635,10 +757,21 @@ pre.diff {
line-height: 1.4; line-height: 1.4;
min-height: 1.4em; min-height: 1.4em;
} }
.term-input textarea::placeholder { color: var(--muted); } .term-input textarea::placeholder {
.term-input .submit-hint { color: var(--muted); font-size: 0.8em; flex: 0 0 auto; } color: var(--muted);
.term-input.disabled .prompt { color: var(--muted); text-shadow: none; } }
.term-input.disabled textarea { color: var(--muted); } .term-input .submit-hint {
color: var(--muted);
font-size: 0.8em;
flex: 0 0 auto;
}
.term-input.disabled .prompt {
color: var(--muted);
text-shadow: none;
}
.term-input.disabled textarea {
color: var(--muted);
}
/* side panel (singleton drawer) /* side panel (singleton drawer)
Inbox + loose-ends details open here instead of expanding inline. The Inbox + loose-ends details open here instead of expanding inline. The
@ -658,7 +791,9 @@ pre.diff {
body.side-panel-resizing { body.side-panel-resizing {
user-select: none; user-select: none;
} }
body.side-panel-resizing * { cursor: ew-resize !important; } body.side-panel-resizing * {
cursor: ew-resize !important;
}
/* Inbox / loose-ends lists rendered into the side-panel body: strip /* Inbox / loose-ends lists rendered into the side-panel body: strip
the inbox-only chrome (background, border-left) here and let the the inbox-only chrome (background, border-left) here and let the
panel body's own padding own the framing. */ panel body's own padding own the framing. */
@ -774,9 +909,16 @@ hive-side-panel .agent-inbox ul {
font-size: 0.95rem; font-size: 0.95rem;
font-weight: normal; font-weight: normal;
} }
.stats-card .chart-wrap { position: relative; height: 220px; } .stats-card .chart-wrap {
.stats-card.wide { grid-column: 1 / -1; } position: relative;
.stats-card.wide .chart-wrap { height: 260px; } height: 220px;
}
.stats-card.wide {
grid-column: 1 / -1;
}
.stats-card.wide .chart-wrap {
height: 260px;
}
/* ─── /screen page ─── */ /* ─── /screen page ─── */
/* Full-screen VNC canvas viewer (screen.html). All rules scoped to /* Full-screen VNC canvas viewer (screen.html). All rules scoped to
@ -802,8 +944,14 @@ body.screen-shell {
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
} }
.tbtn.active { color: var(--green); border-color: var(--green); } .tbtn.active {
.tbtn:disabled { opacity: 0.4; cursor: default; } color: var(--green);
border-color: var(--green);
}
.tbtn:disabled {
opacity: 0.4;
cursor: default;
}
/* Connection status chip at the right end of the screen page header. */ /* Connection status chip at the right end of the screen page header. */
#status { #status {
@ -811,13 +959,19 @@ body.screen-shell {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--subtext0); color: var(--subtext0);
} }
#status.connected { color: var(--green); } #status.connected {
#status.error { color: var(--red); } color: var(--green);
}
#status.error {
color: var(--red);
}
/* RFB debug log — fixed panel at the bottom, hidden by default. */ /* RFB debug log — fixed panel at the bottom, hidden by default. */
#debug-log { #debug-log {
position: fixed; position: fixed;
bottom: 0; left: 0; right: 0; bottom: 0;
left: 0;
right: 0;
max-height: 40vh; max-height: 40vh;
overflow-y: auto; overflow-y: auto;
background: color-mix(in srgb, var(--crust) 95%, transparent); background: color-mix(in srgb, var(--crust) 95%, transparent);
@ -828,10 +982,20 @@ body.screen-shell {
z-index: 100; z-index: 100;
display: none; /* toggled by the debug button */ display: none; /* toggled by the debug button */
} }
#debug-log .dbg-line { color: var(--subtext0); margin: 1px 0; white-space: pre; } #debug-log .dbg-line {
#debug-log .dbg-line.err { color: var(--red); } color: var(--subtext0);
#debug-log .dbg-line.ok { color: var(--green); } margin: 1px 0;
#debug-log .dbg-line.send { color: var(--blue); } white-space: pre;
}
#debug-log .dbg-line.err {
color: var(--red);
}
#debug-log .dbg-line.ok {
color: var(--green);
}
#debug-log .dbg-line.send {
color: var(--blue);
}
/* Canvas container — centres the VNC framebuffer. */ /* Canvas container — centres the VNC framebuffer. */
#canvas-wrap { #canvas-wrap {
@ -844,11 +1008,21 @@ body.screen-shell {
background: var(--crust); background: var(--crust);
} }
/* Fit mode: canvas scaled in JS to fill the container. */ /* Fit mode: canvas scaled in JS to fill the container. */
#canvas-wrap.fit { align-items: center; overflow: hidden; } #canvas-wrap.fit {
#canvas-wrap canvas { display: block; cursor: default; } align-items: center;
overflow: hidden;
}
#canvas-wrap canvas {
display: block;
cursor: default;
}
/* Pin the canvas to the JS-calculated size; prevents flex-item /* Pin the canvas to the JS-calculated size; prevents flex-item
min-width:auto from silently expanding beyond the scaled size. */ min-width:auto from silently expanding beyond the scaled size. */
#canvas-wrap.fit canvas { flex: none; min-width: 0; min-height: 0; } #canvas-wrap.fit canvas {
flex: none;
min-width: 0;
min-height: 0;
}
/* Transient flash message (copy-text, disconnect, etc.). */ /* Transient flash message (copy-text, disconnect, etc.). */
#msg { #msg {

View file

@ -8,8 +8,11 @@
// will keep accumulating cruft in the shared component." This file owns // will keep accumulating cruft in the shared component." This file owns
// its own state/storage entirely; `SettingsMenu` just renders it as a // its own state/storage entirely; `SettingsMenu` just renders it as a
// child, no different from any other consumer's markup. // child, no different from any other consumer's markup.
import { useState } from 'preact/hooks'; import { useState } from "preact/hooks";
import { getExpandDetailsPref, setExpandDetailsPref } from '@hive/shared/prefs.js'; import {
getExpandDetailsPref,
setExpandDetailsPref,
} from "@hive/shared/prefs.js";
export function ExpandDetailsSetting() { export function ExpandDetailsSetting() {
// Plain localStorage read, not a hook-managed override like // Plain localStorage read, not a hook-managed override like
@ -17,7 +20,9 @@ export function ExpandDetailsSetting() {
// (`@hive/shared/prefs.js`) are the existing shared-storage-key pair // (`@hive/shared/prefs.js`) are the existing shared-storage-key pair
// the terminal itself already reads live (no round-trip needed here // the terminal itself already reads live (no round-trip needed here
// beyond re-rendering this row's own checkbox state on toggle). // beyond re-rendering this row's own checkbox state on toggle).
const [expandDetails, setExpandDetailsState] = useState(() => getExpandDetailsPref()); const [expandDetails, setExpandDetailsState] = useState(() =>
getExpandDetailsPref(),
);
return ( return (
<label class="settings-menu-row"> <label class="settings-menu-row">
<span>expand tool output</span> <span>expand tool output</span>

View file

@ -22,8 +22,8 @@
// tick, a real ResizeObserver feedback loop (confirmed live on the // tick, a real ResizeObserver feedback loop (confirmed live on the
// first pass of this fix). `--agent-header-h` stays the static floor; // first pass of this fix). `--agent-header-h` stays the static floor;
// `--agent-header-real-h` is output-only. // `--agent-header-real-h` is output-only.
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef } from "preact/hooks";
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from "preact";
// Icon 404s when the agent has no `hyperhive.icon` override (no // Icon 404s when the agent has no `hyperhive.icon` override (no
// bundled server-side default any more — see // bundled server-side default any more — see
@ -34,8 +34,8 @@ import type { ComponentChildren } from 'preact';
function handleIconError(e: Event) { function handleIconError(e: Event) {
const img = e.currentTarget as HTMLImageElement; const img = e.currentTarget as HTMLImageElement;
if (img.dataset.fallback) return; if (img.dataset.fallback) return;
img.dataset.fallback = '1'; img.dataset.fallback = "1";
img.src = '/favicon.svg'; img.src = "/favicon.svg";
} }
export interface HeaderProps { export interface HeaderProps {
@ -62,13 +62,16 @@ export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el || typeof ResizeObserver === 'undefined') return; if (!el || typeof ResizeObserver === "undefined") return;
// getBoundingClientRect() (not ResizeObserver's own contentRect) — // getBoundingClientRect() (not ResizeObserver's own contentRect) —
// we want the full visual box padding+border included, the thing // we want the full visual box padding+border included, the thing
// content below the fixed header actually needs to clear, not the // content below the fixed header actually needs to clear, not the
// content-box-only measurement ResizeObserver's entry defaults to. // content-box-only measurement ResizeObserver's entry defaults to.
const ro = new ResizeObserver(() => { const ro = new ResizeObserver(() => {
document.documentElement.style.setProperty('--agent-header-real-h', `${el.getBoundingClientRect().height}px`); document.documentElement.style.setProperty(
"--agent-header-real-h",
`${el.getBoundingClientRect().height}px`,
);
}); });
ro.observe(el); ro.observe(el);
return () => ro.disconnect(); return () => ro.disconnect();
@ -81,7 +84,9 @@ export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
<div class="agent-header-row agent-header-title-row"> <div class="agent-header-row agent-header-title-row">
<h2 class="agent-header-title"> {label} </h2> <h2 class="agent-header-title"> {label} </h2>
</div> </div>
{hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null} {hiveLabel ? (
<div class="agent-header-row agent-hive-row">{hiveLabel}</div>
) : null}
</div> </div>
{children || pills ? ( {children || pills ? (
<div class="agent-header-pills"> <div class="agent-header-pills">

View file

@ -8,22 +8,28 @@
// count === 0` in the old page) and picking the inbox/todos tone so // count === 0` in the old page) and picking the inbox/todos tone so
// the count reads amber/green like it always has (`Badge`'s `tone` // the count reads amber/green like it always has (`Badge`'s `tone`
// colours the value, which here is the count). // colours the value, which here is the count).
import { Badge, type BadgeTone } from '@hive/shared/badge.js'; import { Badge, type BadgeTone } from "@hive/shared/badge.js";
export interface HeaderPillProps { export interface HeaderPillProps {
kind: 'inbox' | 'todos'; kind: "inbox" | "todos";
icon: string; icon: string;
label: string; label: string;
count: number; count: number;
onClick: () => void; onClick: () => void;
} }
const TONE: Record<HeaderPillProps['kind'], BadgeTone> = { const TONE: Record<HeaderPillProps["kind"], BadgeTone> = {
inbox: 'warning', inbox: "warning",
todos: 'positive', todos: "positive",
}; };
export function HeaderPill({ kind, icon, label, count, onClick }: HeaderPillProps) { export function HeaderPill({
kind,
icon,
label,
count,
onClick,
}: HeaderPillProps) {
if (count === 0) return null; if (count === 0) return null;
return ( return (
<Badge <Badge

View file

@ -7,9 +7,9 @@
// JSON, not a redirect; in the common gateway-fronted deployment // JSON, not a redirect; in the common gateway-fronted deployment
// `dashboardBase` already resolves to `location.origin` anyway (see // `dashboardBase` already resolves to `location.origin` anyway (see
// dashboardBase.ts), so it's same-origin in practice. // dashboardBase.ts), so it's same-origin in practice.
import { useState } from 'preact/hooks'; import { useState } from "preact/hooks";
import { useConfirmClick } from '../lib/useConfirmClick.js'; import { useConfirmClick } from "../lib/useConfirmClick.js";
import type { InboxRow } from '../types.js'; import type { InboxRow } from "../types.js";
export interface InboxPanelProps { export interface InboxPanelProps {
rows: InboxRow[]; rows: InboxRow[];
@ -19,24 +19,35 @@ export interface InboxPanelProps {
} }
function fmtTs(unixSeconds: number): string { function fmtTs(unixSeconds: number): string {
return new Date(unixSeconds * 1000).toISOString().replace('T', ' ').slice(5, 19); return new Date(unixSeconds * 1000)
.toISOString()
.replace("T", " ")
.slice(5, 19);
} }
export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanelProps) { export function InboxPanel({
const [status, setStatus] = useState(''); rows,
label,
dashboardBase,
onCleared,
}: InboxPanelProps) {
const [status, setStatus] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
async function markAllRead() { async function markAllRead() {
if (!dashboardBase || !label) { if (!dashboardBase || !label) {
setStatus('dashboard url / label unknown'); setStatus("dashboard url / label unknown");
return; return;
} }
setBusy(true); setBusy(true);
setStatus('clearing…'); setStatus("clearing…");
try { try {
const resp = await fetch(`${dashboardBase}api/agent/${encodeURIComponent(label)}/mark-all-read`, { const resp = await fetch(
method: 'POST', `${dashboardBase}api/agent/${encodeURIComponent(label)}/mark-all-read`,
}); {
method: "POST",
},
);
if (resp.ok) { if (resp.ok) {
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
const n = Number((data as { marked?: number }).marked) || 0; const n = Number((data as { marked?: number }).marked) || 0;
@ -68,16 +79,23 @@ export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanel
disabled={busy} disabled={busy}
title="mark every queued message for this agent as read — drains the host broker's pending + delivered-unacked rows. history shown here is the most-recent-N regardless of state, so the list itself stays visible." title="mark every queued message for this agent as read — drains the host broker's pending + delivered-unacked rows. history shown here is the most-recent-N regardless of state, so the list itself stays visible."
> >
{armed ? 'sure? click again' : '✓ mark all read'} {armed ? "sure? click again" : "✓ mark all read"}
</button> </button>
<span class="inbox-mark-status">{status}</span> <span class="inbox-mark-status">{status}</span>
</div> </div>
<ul> <ul>
{rows.map((m) => ( {rows.map((m) => (
<li key={m.id} class={m.in_reply_to != null ? 'inbox-reply' : undefined}> <li
{m.in_reply_to != null && <span class="inbox-reply-tag"> reply · </span>} key={m.id}
<span class="inbox-ts">{fmtTs(m.at)}</span> <span class="inbox-from">{m.from}</span>{' '} class={m.in_reply_to != null ? "inbox-reply" : undefined}
<span class="inbox-sep"></span> <span class="inbox-body">{m.body}</span> >
{m.in_reply_to != null && (
<span class="inbox-reply-tag"> reply · </span>
)}
<span class="inbox-ts">{fmtTs(m.at)}</span>{" "}
<span class="inbox-from">{m.from}</span>{" "}
<span class="inbox-sep"></span>{" "}
<span class="inbox-body">{m.body}</span>
</li> </li>
))} ))}
</ul> </ul>

View file

@ -20,10 +20,15 @@
// unseen for an append. The old code inferred this from DOM // unseen for an append. The old code inferred this from DOM
// mutation shape after the fact; here the caller already knows // mutation shape after the fact; here the caller already knows
// which one it did, so there's nothing to infer. // which one it did, so there's nothing to infer.
import { useLayoutEffect, useRef, useState, useImperativeHandle } from 'preact/hooks'; import {
import { forwardRef } from 'preact/compat'; useLayoutEffect,
import { useLiveStream } from '../hooks/useLiveStream.js'; useRef,
import { Row } from './Row.js'; useState,
useImperativeHandle,
} from "preact/hooks";
import { forwardRef } from "preact/compat";
import { useLiveStream } from "../hooks/useLiveStream.js";
import { Row } from "./Row.js";
const NEAR_BOTTOM_PX = 48; const NEAR_BOTTOM_PX = 48;
const LOAD_MORE_SCROLL_PX = 80; const LOAD_MORE_SCROLL_PX = 80;
@ -37,82 +42,99 @@ export interface LiveStreamHandle {
clear: () => void; clear: () => void;
} }
export const LiveStream = forwardRef<LiveStreamHandle, object>(function LiveStream(_props, ref) { export const LiveStream = forwardRef<LiveStreamHandle, object>(
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream(); function LiveStream(_props, ref) {
useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]); const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } =
const logRef = useRef<HTMLDivElement>(null); useLiveStream();
const [stickToBottom, setStickToBottom] = useState(true); useImperativeHandle(
const [unseen, setUnseen] = useState(0); ref,
const prevRowCount = useRef(0); () => ({ pushNote: pushLocalNote, clear: clearLocal }),
const prependingRef = useRef(false); [pushLocalNote, clearLocal],
const preScrollHeightRef = useRef(0); );
const logRef = useRef<HTMLDivElement>(null);
const [stickToBottom, setStickToBottom] = useState(true);
const [unseen, setUnseen] = useState(0);
const prevRowCount = useRef(0);
const prependingRef = useRef(false);
const preScrollHeightRef = useRef(0);
function isNearBottom(el: HTMLDivElement): boolean { function isNearBottom(el: HTMLDivElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX; return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX;
}
function handleScroll() {
const el = logRef.current;
if (!el) return;
const nearBottom = isNearBottom(el);
setStickToBottom(nearBottom);
if (nearBottom) setUnseen(0);
if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore) startLoadMore();
}
function startLoadMore() {
const el = logRef.current;
if (!el || loadingMore || !hasMore) return;
prependingRef.current = true;
preScrollHeightRef.current = el.scrollHeight;
loadMore();
}
function jumpToBottom() {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight - el.clientHeight;
setStickToBottom(true);
setUnseen(0);
}
// Runs after every commit that changed `rows` — i.e. after the browser
// has already laid out the new/coalesced content, so `scrollHeight`
// below is always current.
useLayoutEffect(() => {
const el = logRef.current;
if (!el) return;
const added = rows.length - prevRowCount.current;
prevRowCount.current = rows.length;
if (prependingRef.current) {
prependingRef.current = false;
el.scrollTop += el.scrollHeight - preScrollHeightRef.current;
return;
} }
if (stickToBottom) {
el.scrollTop = el.scrollHeight - el.clientHeight;
} else if (added > 0) {
setUnseen((n) => n + added);
}
}, [rows, stickToBottom]);
return ( function handleScroll() {
<div className="terminal-wrap"> const el = logRef.current;
<div className="live terminal" ref={logRef} onScroll={handleScroll}> if (!el) return;
{hasMore && ( const nearBottom = isNearBottom(el);
<button type="button" className="load-more-pill" disabled={loadingMore} onClick={startLoadMore}> setStickToBottom(nearBottom);
{loadingMore ? '↑ loading…' : '↑ load older'} if (nearBottom) setUnseen(0);
if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore)
startLoadMore();
}
function startLoadMore() {
const el = logRef.current;
if (!el || loadingMore || !hasMore) return;
prependingRef.current = true;
preScrollHeightRef.current = el.scrollHeight;
loadMore();
}
function jumpToBottom() {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight - el.clientHeight;
setStickToBottom(true);
setUnseen(0);
}
// Runs after every commit that changed `rows` — i.e. after the browser
// has already laid out the new/coalesced content, so `scrollHeight`
// below is always current.
useLayoutEffect(() => {
const el = logRef.current;
if (!el) return;
const added = rows.length - prevRowCount.current;
prevRowCount.current = rows.length;
if (prependingRef.current) {
prependingRef.current = false;
el.scrollTop += el.scrollHeight - preScrollHeightRef.current;
return;
}
if (stickToBottom) {
el.scrollTop = el.scrollHeight - el.clientHeight;
} else if (added > 0) {
setUnseen((n) => n + added);
}
}, [rows, stickToBottom]);
return (
<div className="terminal-wrap">
<div className="live terminal" ref={logRef} onScroll={handleScroll}>
{hasMore && (
<button
type="button"
className="load-more-pill"
disabled={loadingMore}
onClick={startLoadMore}
>
{loadingMore ? "↑ loading…" : "↑ load older"}
</button>
)}
{rows.map((row) => (
<Row key={row.key} row={row} />
))}
</div>
{unseen > 0 && (
<button
type="button"
className="tail-pill visible"
onClick={jumpToBottom}
>
{unseen} new
</button> </button>
)} )}
{rows.map((row) => (
<Row key={row.key} row={row} />
))}
</div> </div>
{unseen > 0 && ( );
<button type="button" className="tail-pill visible" onClick={jumpToBottom}> },
{unseen} new );
</button>
)}
</div>
);
});

View file

@ -16,10 +16,14 @@
// dropdown/Dropdown.css`), not `Dropdown` itself (its `options.map` // dropdown/Dropdown.css`), not `Dropdown` itself (its `options.map`
// render is menu-item-shaped, not a generic content container — not a // render is menu-item-shaped, not a generic content container — not a
// fit for a login form + output pane). // fit for a login form + output pane).
import { useState } from 'preact/hooks'; import { useState } from "preact/hooks";
import type { SessionView } from '../types.js'; import type { SessionView } from "../types.js";
import { postLoginStart, postLoginCode, postLoginCancel } from '../lib/loginAction.js'; import {
import './LoginFlow.css'; postLoginStart,
postLoginCode,
postLoginCancel,
} from "../lib/loginAction.js";
import "./LoginFlow.css";
function LoginIdle({ onStarted }: { onStarted: () => void }) { function LoginIdle({ onStarted }: { onStarted: () => void }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -32,7 +36,7 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
postLoginStart().then((r) => { postLoginStart().then((r) => {
setBusy(false); setBusy(false);
if (r.ok) onStarted(); if (r.ok) onStarted();
else setError(r.detail ?? 'failed'); else setError(r.detail ?? "failed");
}); });
} }
@ -40,8 +44,8 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
<div class="login-card"> <div class="login-card">
<p class="status-needs-login"> NEEDS L0G1N</p> <p class="status-needs-login"> NEEDS L0G1N</p>
<p> <p>
No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is No Claude session in <code>~/.claude/</code>. The harness is up but the
paused until you log in. turn loop is paused until you log in.
</p> </p>
<form onSubmit={submit}> <form onSubmit={submit}>
<button type="submit" class="btn btn-login" disabled={busy}> <button type="submit" class="btn btn-login" disabled={busy}>
@ -50,15 +54,22 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
</form> </form>
{error ? <p class="meta">error: {error}</p> : null} {error ? <p class="meta">error: {error}</p> : null}
<p class="meta"> <p class="meta">
Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth
here when claude emits it; paste the resulting code back into the form below. URL will appear here when claude emits it; paste the resulting code back
into the form below.
</p> </p>
</div> </div>
); );
} }
function LoginInProgress({ session, onDone }: { session: SessionView | null; onDone: () => void }) { function LoginInProgress({
const [code, setCode] = useState(''); session,
onDone,
}: {
session: SessionView | null;
onDone: () => void;
}) {
const [code, setCode] = useState("");
const [reveal, setReveal] = useState(false); const [reveal, setReveal] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -75,7 +86,7 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
// nothing local to update here either way. // nothing local to update here either way.
postLoginCode(code).then((r) => { postLoginCode(code).then((r) => {
setBusy(false); setBusy(false);
if (!r.ok) setError(r.detail ?? 'failed'); if (!r.ok) setError(r.detail ?? "failed");
}); });
} }
@ -94,23 +105,26 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
{session?.url ? ( {session?.url ? (
<> <>
<p class="login-url"> <p class="login-url">
{' '} {" "}
<a href={session.url} target="_blank" rel="noreferrer"> <a href={session.url} target="_blank" rel="noreferrer">
{session.url} {session.url}
</a> </a>
</p> </p>
<p class="meta"> <p class="meta">
open this URL in a browser, complete the OAuth flow, paste the resulting code below. open this URL in a browser, complete the OAuth flow, paste the
resulting code below.
</p> </p>
</> </>
) : ( ) : (
<p class="meta">waiting for claude to emit an OAuth URL on stdout (output below)</p> <p class="meta">
waiting for claude to emit an OAuth URL on stdout (output below)
</p>
)} )}
{!finished ? ( {!finished ? (
<form class="loginform" onSubmit={submitCode}> <form class="loginform" onSubmit={submitCode}>
<input <input
name="code" name="code"
type={reveal ? 'text' : 'password'} type={reveal ? "text" : "password"}
placeholder="paste OAuth code here (hidden)" placeholder="paste OAuth code here (hidden)"
required required
autocomplete="one-time-code" autocomplete="one-time-code"
@ -133,19 +147,20 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
</button> </button>
</form> </form>
) : null} ) : null}
<form style={{ marginTop: '0.4em' }} onSubmit={cancel}> <form style={{ marginTop: "0.4em" }} onSubmit={cancel}>
<button type="submit" class="btn btn-cancel" disabled={busy}> <button type="submit" class="btn btn-cancel" disabled={busy}>
cancel + kill cancel + kill
</button> </button>
</form> </form>
{finished ? ( {finished ? (
<p class="status-needs-login"> <p class="status-needs-login">
claude process exited: {session?.exit_note || 'exited'}. Start over if needed. claude process exited: {session?.exit_note || "exited"}. Start over if
needed.
</p> </p>
) : null} ) : null}
{error ? <p class="meta">error: {error}</p> : null} {error ? <p class="meta">error: {error}</p> : null}
<h3>output</h3> <h3>output</h3>
<pre class="diff">{session?.output || ''}</pre> <pre class="diff">{session?.output || ""}</pre>
</div> </div>
); );
} }
@ -155,13 +170,13 @@ export function LoginFlow({
session, session,
onRefresh, onRefresh,
}: { }: {
status: 'needs_login_idle' | 'needs_login_in_progress'; status: "needs_login_idle" | "needs_login_in_progress";
session: SessionView | null; session: SessionView | null;
onRefresh: () => void; onRefresh: () => void;
}) { }) {
return ( return (
<div class="agent-status-overlay"> <div class="agent-status-overlay">
{status === 'needs_login_idle' ? ( {status === "needs_login_idle" ? (
<LoginIdle onStarted={onRefresh} /> <LoginIdle onStarted={onRefresh} />
) : ( ) : (
<LoginInProgress session={session} onDone={onRefresh} /> <LoginInProgress session={session} onDone={onRefresh} />

View file

@ -21,11 +21,11 @@
// matching its `.ui-dropdown` visual values exactly (see MetaNav.css) — // matching its `.ui-dropdown` visual values exactly (see MetaNav.css) —
// the same "reuse the values, not the component" call `LoginFlow`'s // the same "reuse the values, not the component" call `LoginFlow`'s
// `.login-card` makes for the same reason. // `.login-card` makes for the same reason.
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from "preact/hooks";
import { Badge } from '@hive/shared/badge.js'; import { Badge } from "@hive/shared/badge.js";
import { LinkIcon } from '@hive/shared/icons.js'; import { LinkIcon } from "@hive/shared/icons.js";
import type { AgentLink } from '../types.js'; import type { AgentLink } from "../types.js";
import './MetaNav.css'; import "./MetaNav.css";
export interface MetaNavProps { export interface MetaNavProps {
links: AgentLink[]; links: AgentLink[];
@ -35,7 +35,11 @@ export interface MetaNavProps {
dashboardBase: string; dashboardBase: string;
} }
export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps) { export function MetaNav({
links,
forgePublicUrl,
dashboardBase,
}: MetaNavProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
@ -43,16 +47,21 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function onPointerDown(e: PointerEvent) { function onPointerDown(e: PointerEvent) {
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) setOpen(false); if (
rootRef.current &&
e.target instanceof Node &&
!rootRef.current.contains(e.target)
)
setOpen(false);
} }
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false); if (e.key === "Escape") setOpen(false);
} }
document.addEventListener('pointerdown', onPointerDown, true); document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener('keydown', onKeyDown); document.addEventListener("keydown", onKeyDown);
return () => { return () => {
document.removeEventListener('pointerdown', onPointerDown, true); document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener('keydown', onKeyDown); document.removeEventListener("keydown", onKeyDown);
}; };
}, [open]); }, [open]);
@ -60,7 +69,7 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
// loop: `forge` needs `forgePublicUrl` set or the link is dropped // loop: `forge` needs `forgePublicUrl` set or the link is dropped
// entirely (never guessed from `<host>:3000`); `external` is already // entirely (never guessed from `<host>:3000`); `external` is already
// absolute; `container` is a same-origin path. // absolute; `container` is a same-origin path.
const visible = links.filter((lnk) => lnk.kind !== 'forge' || forgePublicUrl); const visible = links.filter((lnk) => lnk.kind !== "forge" || forgePublicUrl);
// No early-return-on-empty: the dashboard link below is always // No early-return-on-empty: the dashboard link below is always
// present, so the trigger always has at least one item. // present, so the trigger always has at least one item.
@ -86,7 +95,8 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
🧭 dashboard 🧭 dashboard
</a> </a>
{visible.map((lnk) => { {visible.map((lnk) => {
const href = lnk.kind === 'forge' ? `${forgePublicUrl}${lnk.url}` : lnk.url; const href =
lnk.kind === "forge" ? `${forgePublicUrl}${lnk.url}` : lnk.url;
return ( return (
<a <a
key={lnk.url} key={lnk.url}
@ -97,9 +107,7 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
role="menuitem" role="menuitem"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
{lnk.icon ? ( {lnk.icon ? <span aria-hidden="true">{lnk.icon}</span> : null}
<span aria-hidden="true">{lnk.icon}</span>
) : null}
{lnk.label} {lnk.label}
</a> </a>
); );

View file

@ -6,11 +6,11 @@
// a body is an expandable details row gated by the operator's // a body is an expandable details row gated by the operator's
// expand-tool-output preference. No separate classification step — // expand-tool-output preference. No separate classification step —
// mara: "StreamRow should now match what the server sends in TermMsg." // mara: "StreamRow should now match what the server sends in TermMsg."
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef } from "preact/hooks";
import type { TermRow } from '../lib/termMsg.js'; import type { TermRow } from "../lib/termMsg.js";
import { linkifyToNodes } from '../lib/linkify.js'; import { linkifyToNodes } from "../lib/linkify.js";
import { renderMarkdown } from '../lib/markdown.js'; import { renderMarkdown } from "../lib/markdown.js";
import { getExpandDetailsPref } from '@hive/shared/prefs.js'; import { getExpandDetailsPref } from "@hive/shared/prefs.js";
function MarkdownBody({ text }: { text: string }) { function MarkdownBody({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -18,25 +18,31 @@ function MarkdownBody({ text }: { text: string }) {
useEffect(() => { useEffect(() => {
// marked autolinks URLs but leaves them same-tab — open externally // marked autolinks URLs but leaves them same-tab — open externally
// so a click never navigates the terminal away. // so a click never navigates the terminal away.
ref.current?.querySelectorAll('a[href]').forEach((a) => { ref.current?.querySelectorAll("a[href]").forEach((a) => {
a.setAttribute('target', '_blank'); a.setAttribute("target", "_blank");
a.setAttribute('rel', 'noopener noreferrer'); a.setAttribute("rel", "noopener noreferrer");
}); });
}, [html]); }, [html]);
// eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown // eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown
return <div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />; return (
<div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />
);
} }
function DiffBody({ text }: { text: string }) { function DiffBody({ text }: { text: string }) {
const lines = String(text).split('\n'); const lines = String(text).split("\n");
return ( return (
<pre className="tool-body diff-body"> <pre className="tool-body diff-body">
{lines.map((line, i) => { {lines.map((line, i) => {
const cls = line.startsWith('+ ') ? 'diff-add' : line.startsWith('- ') ? 'diff-del' : 'diff-ctx'; const cls = line.startsWith("+ ")
? "diff-add"
: line.startsWith("- ")
? "diff-del"
: "diff-ctx";
return ( return (
<span key={i} className={cls}> <span key={i} className={cls}>
{line} {line}
{'\n'} {"\n"}
</span> </span>
); );
})} })}
@ -45,8 +51,10 @@ function DiffBody({ text }: { text: string }) {
} }
export function Row({ row }: { row: TermRow }) { export function Row({ row }: { row: TermRow }) {
const cssClass = 'level-' + row.level; const cssClass = "level-" + row.level;
const icon = row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>; const icon = row.icon != null && row.icon !== "" && (
<span className="row-glyph">{row.icon}</span>
);
if (row.body == null) { if (row.body == null) {
return ( return (
@ -59,7 +67,7 @@ export function Row({ row }: { row: TermRow }) {
// Empty summary + markdown body → the body itself is the whole row // Empty summary + markdown body → the body itself is the whole row
// (assistant text), no summary prefix line, never collapsible. // (assistant text), no summary prefix line, never collapsible.
if (row.body_format === 'markdown' && row.summary === '') { if (row.body_format === "markdown" && row.summary === "") {
return ( return (
<div className={`row ${cssClass}`}> <div className={`row ${cssClass}`}>
{icon} {icon}
@ -69,14 +77,19 @@ export function Row({ row }: { row: TermRow }) {
} }
return ( return (
<details className={`row ${cssClass}`} open={getExpandDetailsPref() || undefined}> <details
className={`row ${cssClass}`}
open={getExpandDetailsPref() || undefined}
>
<summary> <summary>
{icon} {icon}
<span className="summary-text">{row.summary}</span> <span className="summary-text">{row.summary}</span>
</summary> </summary>
{row.body_format === 'diff' && <DiffBody text={row.body} />} {row.body_format === "diff" && <DiffBody text={row.body} />}
{row.body_format === 'markdown' && <MarkdownBody text={row.body} />} {row.body_format === "markdown" && <MarkdownBody text={row.body} />}
{row.body_format == null && <pre className="tool-body">{linkifyToNodes(row.body)}</pre>} {row.body_format == null && (
<pre className="tool-body">{linkifyToNodes(row.body)}</pre>
)}
</details> </details>
); );
} }

View file

@ -11,9 +11,9 @@
// Content styling (`.agent-inbox`, `.inbox-*`, `.side-panel-empty`) // Content styling (`.agent-inbox`, `.inbox-*`, `.side-panel-empty`)
// already ships globally via `agent.css` and is reused verbatim by // already ships globally via `agent.css` and is reused verbatim by
// InboxPanel/TodosPanel — only the drawer chrome itself is new here. // InboxPanel/TodosPanel — only the drawer chrome itself is new here.
import { useEffect } from 'preact/hooks'; import { useEffect } from "preact/hooks";
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from "preact";
import './SidePanel.css'; import "./SidePanel.css";
export interface SidePanelProps { export interface SidePanelProps {
open: boolean; open: boolean;
@ -26,17 +26,21 @@ export function SidePanel({ open, title, onClose, children }: SidePanelProps) {
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose(); if (e.key === "Escape") onClose();
} }
document.addEventListener('keydown', onKey); document.addEventListener("keydown", onKey);
return () => document.removeEventListener('keydown', onKey); return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, onClose]);
return ( return (
<> <>
<div class={`agent-side-panel-backdrop${open ? ' open' : ''}`} onClick={onClose} aria-hidden="true" /> <div
class={`agent-side-panel-backdrop${open ? " open" : ""}`}
onClick={onClose}
aria-hidden="true"
/>
<aside <aside
class={`agent-side-panel-drawer${open ? ' open' : ''}`} class={`agent-side-panel-drawer${open ? " open" : ""}`}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-hidden={!open} aria-hidden={!open}
@ -44,7 +48,12 @@ export function SidePanel({ open, title, onClose, children }: SidePanelProps) {
> >
<div class="agent-side-panel-head"> <div class="agent-side-panel-head">
<span class="agent-side-panel-title">{title}</span> <span class="agent-side-panel-title">{title}</span>
<button type="button" class="agent-side-panel-close" title="close (esc)" onClick={onClose}> <button
type="button"
class="agent-side-panel-close"
title="close (esc)"
onClick={onClose}
>
</button> </button>
</div> </div>

View file

@ -10,10 +10,10 @@
// selection state live in the caller (`Root.tsx`, via the // selection state live in the caller (`Root.tsx`, via the
// `useAgentState` hook), so this component can still be demoed and // `useAgentState` hook), so this component can still be demoed and
// reviewed against plain sample data independent of live `/api/state`. // reviewed against plain sample data independent of live `/api/state`.
import { useRef, useState } from 'preact/hooks'; import { useRef, useState } from "preact/hooks";
import { Badge, type BadgeTone } from '@hive/shared/badge.js'; import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js'; import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import './StatusChips.css'; import "./StatusChips.css";
export interface StatusChipsProps { export interface StatusChipsProps {
statusLabel: string; statusLabel: string;
@ -40,16 +40,16 @@ export interface StatusChipsProps {
} }
const MODEL_DESCRIPTIONS: Record<string, string> = { const MODEL_DESCRIPTIONS: Record<string, string> = {
haiku: 'fast', haiku: "fast",
sonnet: 'balanced', sonnet: "balanced",
opus: 'powerful', opus: "powerful",
}; };
const EFFORT_DESCRIPTIONS: Record<string, string> = { const EFFORT_DESCRIPTIONS: Record<string, string> = {
low: '', low: "",
medium: 'default', medium: "default",
high: '', high: "",
xhigh: '', xhigh: "",
max: '', max: "",
}; };
function Picker({ function Picker({
@ -76,7 +76,13 @@ function Picker({
})); }));
return ( return (
<div class="status-chip-anchor" ref={anchorRef}> <div class="status-chip-anchor" ref={anchorRef}>
<Badge label={label} value={value} onClick={() => setOpen((o) => !o)} expanded={open} title={title} /> <Badge
label={label}
value={value}
onClick={() => setOpen((o) => !o)}
expanded={open}
title={title}
/>
<Dropdown <Dropdown
open={open} open={open}
options={dropdownOptions} options={dropdownOptions}
@ -110,7 +116,13 @@ function StatusMenu({
onCancelTurn, onCancelTurn,
}: Pick< }: Pick<
StatusChipsProps, StatusChipsProps,
'statusLabel' | 'statusTone' | 'statusTooltip' | 'paused' | 'onTogglePause' | 'thinking' | 'onCancelTurn' | "statusLabel"
| "statusTone"
| "statusTooltip"
| "paused"
| "onTogglePause"
| "thinking"
| "onCancelTurn"
>) { >) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false); const [confirmCancel, setConfirmCancel] = useState(false);
@ -121,11 +133,13 @@ function StatusMenu({
setConfirmCancel(false); setConfirmCancel(false);
} }
const options: DropdownOption[] = [{ value: 'toggle-pause', label: paused ? '▶ resume' : '⏸ pause' }]; const options: DropdownOption[] = [
{ value: "toggle-pause", label: paused ? "▶ resume" : "⏸ pause" },
];
if (thinking) { if (thinking) {
options.push({ options.push({
value: 'cancel-turn', value: "cancel-turn",
label: confirmCancel ? '■ click again to confirm' : '■ cancel turn', label: confirmCancel ? "■ click again to confirm" : "■ cancel turn",
danger: true, danger: true,
}); });
} }
@ -144,7 +158,7 @@ function StatusMenu({
options={options} options={options}
label="agent status" label="agent status"
onSelect={(value) => { onSelect={(value) => {
if (value === 'toggle-pause') { if (value === "toggle-pause") {
onTogglePause(); onTogglePause();
close(); close();
return; return;
@ -225,7 +239,9 @@ export function StatusChips({
title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)" title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"
/> />
) : null} ) : null}
{lastTurnLabel ? <span class="status-chips-last-turn">{lastTurnLabel}</span> : null} {lastTurnLabel ? (
<span class="status-chips-last-turn">{lastTurnLabel}</span>
) : null}
</div> </div>
); );
} }

View file

@ -10,9 +10,15 @@
// a text-input flow anyway. Instead: typing the command once arms it // a text-input flow anyway. Instead: typing the command once arms it
// (a local note explains what confirming does), typing it again within // (a local note explains what confirming does), typing it again within
// the same "armed" state fires it — a keyboard-native two-step confirm. // the same "armed" state fires it — a keyboard-native two-step confirm.
import { useRef, useState } from 'preact/hooks'; import { useRef, useState } from "preact/hooks";
import { postModel, postEffort } from '../lib/modelEffort.js'; import { postModel, postEffort } from "../lib/modelEffort.js";
import { postCancelTurn, postCompact, postNewSession, postLogout, postSend } from '../lib/termActions.js'; import {
postCancelTurn,
postCompact,
postNewSession,
postLogout,
postSend,
} from "../lib/termActions.js";
export interface TermInputProps { export interface TermInputProps {
label: string; label: string;
@ -22,14 +28,26 @@ export interface TermInputProps {
} }
const SLASH_COMMANDS: { name: string; desc: string }[] = [ const SLASH_COMMANDS: { name: string; desc: string }[] = [
{ name: '/help', desc: 'list slash commands' }, { name: "/help", desc: "list slash commands" },
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' }, { name: "/clear", desc: "wipe the terminal panel (local-only)" },
{ name: '/cancel', desc: 'SIGINT the in-flight claude turn' }, { name: "/cancel", desc: "SIGINT the in-flight claude turn" },
{ name: '/compact', desc: 'compact the persistent claude session' }, { name: "/compact", desc: "compact the persistent claude session" },
{ name: '/model', desc: '/model <name> — switch claude model for future turns' }, {
{ name: '/effort', desc: '/effort <level> — set claude effort (medium/high/xhigh) for future turns' }, name: "/model",
{ name: '/new-session', desc: 'fresh claude session next turn (type twice to confirm)' }, desc: "/model <name> — switch claude model for future turns",
{ name: '/logout', desc: 'rotate OAuth creds + park in needs-login (type twice to confirm)' }, },
{
name: "/effort",
desc: "/effort <level> — set claude effort (medium/high/xhigh) for future turns",
},
{
name: "/new-session",
desc: "fresh claude session next turn (type twice to confirm)",
},
{
name: "/logout",
desc: "rotate OAuth creds + park in needs-login (type twice to confirm)",
},
]; ];
const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base
@ -41,8 +59,13 @@ function completeSlash(prefix: string): string | null {
return matches[(idx + 1) % matches.length].name; return matches[(idx + 1) % matches.length].name;
} }
export function TermInput({ label, online, onLocalNote, onClear }: TermInputProps) { export function TermInput({
const [value, setValue] = useState(''); label,
online,
onLocalNote,
onClear,
}: TermInputProps) {
const [value, setValue] = useState("");
const taRef = useRef<HTMLTextAreaElement>(null); const taRef = useRef<HTMLTextAreaElement>(null);
// Which destructive command is currently "armed" (typed once, waiting // Which destructive command is currently "armed" (typed once, waiting
// for the confirming repeat) — any other command clears it. // for the confirming repeat) — any other command clears it.
@ -51,59 +74,82 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
function grow() { function grow() {
const ta = taRef.current; const ta = taRef.current;
if (!ta) return; if (!ta) return;
ta.style.height = 'auto'; ta.style.height = "auto";
ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + 'px'; ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + "px";
} }
function reportFailure(label_: string, detail?: string) { function reportFailure(label_: string, detail?: string) {
onLocalNote(`${label_} failed${detail ? ': ' + detail : ''}`); onLocalNote(`${label_} failed${detail ? ": " + detail : ""}`);
} }
function handleSlash(line: string): boolean { function handleSlash(line: string): boolean {
const trimmed = line.trim(); const trimmed = line.trim();
const [cmd, ...rest] = trimmed.split(/\s+/); const [cmd, ...rest] = trimmed.split(/\s+/);
const arg = rest.join(' '); const arg = rest.join(" ");
const wasArmed = armedRef.current === cmd; const wasArmed = armedRef.current === cmd;
armedRef.current = null; armedRef.current = null;
switch (cmd) { switch (cmd) {
case '/help': case "/help":
onLocalNote('/help'); onLocalNote("/help");
for (const c of SLASH_COMMANDS) onLocalNote(` ${c.name.padEnd(13)}${c.desc}`); for (const c of SLASH_COMMANDS)
onLocalNote(` ${c.name.padEnd(13)}${c.desc}`);
return true; return true;
case '/clear': case "/clear":
onClear(); onClear();
onLocalNote('· terminal cleared (local view only — server history kept)'); onLocalNote(
"· terminal cleared (local view only — server history kept)",
);
return true; return true;
case '/cancel': case "/cancel":
postCancelTurn().then((r) => !r.ok && reportFailure('/cancel', r.detail)); postCancelTurn().then(
(r) => !r.ok && reportFailure("/cancel", r.detail),
);
return true; return true;
case '/compact': case "/compact":
postCompact().then((r) => !r.ok && reportFailure('/compact', r.detail)); postCompact().then((r) => !r.ok && reportFailure("/compact", r.detail));
return true; return true;
case '/new-session': case "/new-session":
if (wasArmed) { if (wasArmed) {
postNewSession().then((r) => !r.ok && reportFailure('/new-session', r.detail)); postNewSession().then(
(r) => !r.ok && reportFailure("/new-session", r.detail),
);
} else { } else {
armedRef.current = '/new-session'; armedRef.current = "/new-session";
onLocalNote('⚠ drops all prior --continue context. type /new-session again to confirm.'); onLocalNote(
"⚠ drops all prior --continue context. type /new-session again to confirm.",
);
} }
return true; return true;
case '/logout': case "/logout":
if (wasArmed) { if (wasArmed) {
postLogout().then((r) => !r.ok && reportFailure('/logout', r.detail)); postLogout().then((r) => !r.ok && reportFailure("/logout", r.detail));
} else { } else {
armedRef.current = '/logout'; armedRef.current = "/logout";
onLocalNote('⚠ SIGINTs the current turn + rotates OAuth credentials. type /logout again to confirm.'); onLocalNote(
"⚠ SIGINTs the current turn + rotates OAuth credentials. type /logout again to confirm.",
);
} }
return true; return true;
case '/model': case "/model":
if (!arg) onLocalNote('✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)'); if (!arg)
else postModel(arg).then((r) => !r.ok && reportFailure('/model', r.detail)); onLocalNote(
"✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)",
);
else
postModel(arg).then(
(r) => !r.ok && reportFailure("/model", r.detail),
);
return true; return true;
case '/effort': case "/effort":
if (!arg) onLocalNote('✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)'); if (!arg)
else postEffort(arg).then((r) => !r.ok && reportFailure('/effort', r.detail)); onLocalNote(
"✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)",
);
else
postEffort(arg).then(
(r) => !r.ok && reportFailure("/effort", r.detail),
);
return true; return true;
default: default:
onLocalNote(`✗ unknown slash command: ${cmd} — try /help`); onLocalNote(`✗ unknown slash command: ${cmd} — try /help`);
@ -114,14 +160,14 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
function submit() { function submit() {
const line = value; const line = value;
if (!line.trim()) return; if (!line.trim()) return;
setValue(''); setValue("");
requestAnimationFrame(grow); requestAnimationFrame(grow);
if (line.trim().startsWith('/')) { if (line.trim().startsWith("/")) {
handleSlash(line); handleSlash(line);
return; return;
} }
armedRef.current = null; armedRef.current = null;
postSend(line).then((r) => !r.ok && reportFailure('send', r.detail)); postSend(line).then((r) => !r.ok && reportFailure("send", r.detail));
} }
function onInput(e: Event) { function onInput(e: Event) {
@ -130,7 +176,7 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
} }
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Tab' && value.startsWith('/') && !value.includes(' ')) { if (e.key === "Tab" && value.startsWith("/") && !value.includes(" ")) {
const next = completeSlash(value); const next = completeSlash(value);
if (next) { if (next) {
e.preventDefault(); e.preventDefault();
@ -138,14 +184,14 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
} }
return; return;
} }
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault(); e.preventDefault();
submit(); submit();
} }
} }
return ( return (
<div class={`term-input${online ? '' : ' disabled'}`}> <div class={`term-input${online ? "" : " disabled"}`}>
<form <form
class="sendform-term" class="sendform-term"
onSubmit={(e) => { onSubmit={(e) => {

View file

@ -2,8 +2,8 @@
// flyout. "mark done" POSTs to this agent's own backend // flyout. "mark done" POSTs to this agent's own backend
// (`api/todos/mark-done`, same-origin), unlike InboxPanel's cross-agent // (`api/todos/mark-done`, same-origin), unlike InboxPanel's cross-agent
// mark-all-read. // mark-all-read.
import { useState } from 'preact/hooks'; import { useState } from "preact/hooks";
import type { TodoRow } from '../types.js'; import type { TodoRow } from "../types.js";
export interface TodosPanelProps { export interface TodosPanelProps {
todos: TodoRow[]; todos: TodoRow[];
@ -19,7 +19,7 @@ function fmtAge(seconds: number): string {
export function TodosPanel({ todos, onCleared }: TodosPanelProps) { export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
const [checked, setChecked] = useState<Set<number>>(new Set()); const [checked, setChecked] = useState<Set<number>>(new Set());
const [status, setStatus] = useState(''); const [status, setStatus] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
function toggle(id: number) { function toggle(id: number) {
@ -35,15 +35,15 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
const ids = [...checked]; const ids = [...checked];
if (!ids.length) return; if (!ids.length) return;
setBusy(true); setBusy(true);
setStatus('marking…'); setStatus("marking…");
try { try {
const resp = await fetch('api/todos/mark-done', { const resp = await fetch("api/todos/mark-done", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `ids=${encodeURIComponent(ids.join(','))}`, body: `ids=${encodeURIComponent(ids.join(","))}`,
}); });
if (resp.ok) { if (resp.ok) {
setStatus('✓ marked done'); setStatus("✓ marked done");
setChecked(new Set()); setChecked(new Set());
onCleared(); onCleared();
} else { } else {
@ -57,7 +57,9 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
} }
if (!todos.length) { if (!todos.length) {
return <p class="side-panel-empty">no todos all subsystem queues are clear.</p>; return (
<p class="side-panel-empty">no todos all subsystem queues are clear.</p>
);
} }
const allChecked = todos.length > 0 && todos.every((t) => checked.has(t.id)); const allChecked = todos.length > 0 && todos.every((t) => checked.has(t.id));
@ -68,11 +70,18 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
<button <button
type="button" type="button"
class="inbox-mark-all-btn" class="inbox-mark-all-btn"
onClick={() => setChecked(allChecked ? new Set() : new Set(todos.map((t) => t.id)))} onClick={() =>
setChecked(allChecked ? new Set() : new Set(todos.map((t) => t.id)))
}
> >
{allChecked ? 'select none' : 'select all'} {allChecked ? "select none" : "select all"}
</button> </button>
<button type="button" class="inbox-mark-all-btn" onClick={markDone} disabled={busy || checked.size === 0}> <button
type="button"
class="inbox-mark-all-btn"
onClick={markDone}
disabled={busy || checked.size === 0}
>
mark done mark done
</button> </button>
<span class="inbox-mark-status">{status}</span> <span class="inbox-mark-status">{status}</span>
@ -89,10 +98,10 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
class="todo-cb" class="todo-cb"
checked={checked.has(t.id)} checked={checked.has(t.id)}
onChange={() => toggle(t.id)} onChange={() => toggle(t.id)}
/>{' '} />{" "}
<label for={cbId} class="inbox-from"> <label for={cbId} class="inbox-from">
{label} {label}
</label>{' '} </label>{" "}
<span class="inbox-ts">{fmtAge(t.age_seconds)} ago</span> <span class="inbox-ts">{fmtAge(t.age_seconds)} ago</span>
<div class="inbox-body">{t.summary}</div> <div class="inbox-body">{t.summary}</div>
</li> </li>

View file

@ -1,4 +1,4 @@
// Ambient module for `import './Foo.css'` side-effect imports (esbuild // Ambient module for `import './Foo.css'` side-effect imports (esbuild
// resolves these directly, see build.mjs; tsc otherwise has no idea what // resolves these directly, see build.mjs; tsc otherwise has no idea what
// a `.css` specifier is and refuses the whole side-effect import). // a `.css` specifier is and refuses the whole side-effect import).
declare module '*.css'; declare module "*.css";

View file

@ -5,8 +5,8 @@
// the UI derives from it, like the turn-state badge's elapsed-time text — // the UI derives from it, like the turn-state badge's elapsed-time text —
// goes stale until something proactively refetches; without this the // goes stale until something proactively refetches; without this the
// only way back to a live reading was a full page reload. // only way back to a live reading was a full page reload.
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from "preact/hooks";
import type { AgentState } from '../types.js'; import type { AgentState } from "../types.js";
const POLL_MS = 4000; const POLL_MS = 4000;
const RETRY_MS = 5000; const RETRY_MS = 5000;
@ -38,7 +38,7 @@ export function useAgentState(): UseAgentStateResult {
async function poll() { async function poll() {
try { try {
const resp = await fetch('api/state'); const resp = await fetch("api/state");
if (!resp.ok) throw new Error(`http ${resp.status}`); if (!resp.ok) throw new Error(`http ${resp.status}`);
const s = (await resp.json()) as AgentState; const s = (await resp.json()) as AgentState;
if (stoppedRef.current) return; if (stoppedRef.current) return;
@ -80,10 +80,10 @@ export function useAgentState(): UseAgentStateResult {
// component that calls this hook. // component that calls this hook.
useEffect(() => { useEffect(() => {
function onVisible() { function onVisible() {
if (document.visibilityState === 'visible') refresh(); if (document.visibilityState === "visible") refresh();
} }
document.addEventListener('visibilitychange', onVisible); document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener('visibilitychange', onVisible); return () => document.removeEventListener("visibilitychange", onVisible);
}, []); }, []);
return { state, error, refresh }; return { state, error, refresh };

View file

@ -15,8 +15,8 @@
// initial history page, drop it from the buffer). `seq` alone is the // initial history page, drop it from the buffer). `seq` alone is the
// whole dedup signal, see `TermEnvelope`'s doc in hive-agent's // whole dedup signal, see `TermEnvelope`'s doc in hive-agent's
// `web_ui/stream.rs`. // `web_ui/stream.rs`.
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from "preact/hooks";
import type { TermEnvelope, TermRow } from '../lib/termMsg.js'; import type { TermEnvelope, TermRow } from "../lib/termMsg.js";
export interface UseLiveStreamOptions { export interface UseLiveStreamOptions {
historyUrl?: string; historyUrl?: string;
@ -50,9 +50,11 @@ function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
return [...rows, row]; return [...rows, row];
} }
export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamResult { export function useLiveStream(
const historyUrl = opts.historyUrl ?? 'events/history'; opts: UseLiveStreamOptions = {},
const streamUrl = opts.streamUrl ?? 'events/stream'; ): UseLiveStreamResult {
const historyUrl = opts.historyUrl ?? "events/history";
const streamUrl = opts.streamUrl ?? "events/stream";
const [rows, setRows] = useState<TermRow[]>([]); const [rows, setRows] = useState<TermRow[]>([]);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
@ -61,12 +63,16 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
const keySeqRef = useRef(0); const keySeqRef = useRef(0);
function nextKey(): string { function nextKey(): string {
keySeqRef.current += 1; keySeqRef.current += 1;
return 'r' + keySeqRef.current; return "r" + keySeqRef.current;
} }
function toRows(env: TermEnvelope, fromHistory: boolean): TermRow[] { function toRows(env: TermEnvelope, fromHistory: boolean): TermRow[] {
return env.msgs.map((m) => ({ ...m, key: nextKey(), fromHistory })); return env.msgs.map((m) => ({ ...m, key: nextKey(), fromHistory }));
} }
function appendEnvelope(rows: TermRow[], env: TermEnvelope, fromHistory: boolean): TermRow[] { function appendEnvelope(
rows: TermRow[],
env: TermEnvelope,
fromHistory: boolean,
): TermRow[] {
let next = rows; let next = rows;
for (const row of toRows(env, fromHistory)) next = appendRow(next, row); for (const row of toRows(env, fromHistory)) next = appendRow(next, row);
return next; return next;
@ -89,9 +95,14 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
try { try {
env = JSON.parse(e.data); env = JSON.parse(e.data);
} catch { } catch {
setRows((prev) => appendRow(prev, { setRows((prev) =>
key: 'parse-err-' + Date.now(), level: 'warn', summary: '[parse err] ' + e.data, fromHistory: false, appendRow(prev, {
})); key: "parse-err-" + Date.now(),
level: "warn",
summary: "[parse err] " + e.data,
fromHistory: false,
}),
);
return; return;
} }
if (!liveRef.current) { if (!liveRef.current) {
@ -101,30 +112,54 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
pushLive(env); pushLive(env);
}; };
es.onerror = () => { es.onerror = () => {
setRows((prev) => appendRow(prev, { setRows((prev) =>
key: 'conn-note', level: 'warn', fromHistory: false, coalesce_key: 'conn-status', appendRow(prev, {
summary: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]', key: "conn-note",
})); level: "warn",
fromHistory: false,
coalesce_key: "conn-status",
summary:
es.readyState === 0 /* CONNECTING */
? "[reconnecting…]"
: "[disconnected]",
}),
);
}; };
async function backfill() { async function backfill() {
try { try {
const resp = await fetch(historyUrl); const resp = await fetch(historyUrl);
if (!resp.ok) throw new Error('http ' + resp.status); if (!resp.ok) throw new Error("http " + resp.status);
const body = await resp.json(); const body = await resp.json();
const events: TermEnvelope[] = Array.isArray(body) ? body : body.events || []; const events: TermEnvelope[] = Array.isArray(body)
const boundarySeq: number | null = Array.isArray(body) ? null : (body.seq ?? null); ? body
: body.events || [];
const boundarySeq: number | null = Array.isArray(body)
? null
: (body.seq ?? null);
if (!Array.isArray(body)) { if (!Array.isArray(body)) {
setHasMore(!!body.has_more); setHasMore(!!body.has_more);
if (typeof body.min_id === 'number') minIdRef.current = body.min_id; if (typeof body.min_id === "number") minIdRef.current = body.min_id;
} }
if (cancelled) return; if (cancelled) return;
let initial: TermRow[] = []; let initial: TermRow[] = [];
for (const env of events) initial = appendEnvelope(initial, env, true); for (const env of events) initial = appendEnvelope(initial, env, true);
initial = events.length initial = events.length
? appendRow(initial, { key: 'live-sep', level: 'debug', fromHistory: true, summary: '─── live (older above) ───' }) ? appendRow(initial, {
: [{ key: 'placeholder', level: 'debug', fromHistory: true, summary: '(connected — waiting for events)' }]; key: "live-sep",
level: "debug",
fromHistory: true,
summary: "─── live (older above) ───",
})
: [
{
key: "placeholder",
level: "debug",
fromHistory: true,
summary: "(connected — waiting for events)",
},
];
setRows(initial); setRows(initial);
const drained = bufferedRef.current; const drained = bufferedRef.current;
@ -133,11 +168,16 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
for (const env of drained) { for (const env of drained) {
// Already covered by the initial history page — drop it from // Already covered by the initial history page — drop it from
// the buffer rather than rendering it twice. // the buffer rather than rendering it twice.
if (boundarySeq != null && typeof env.seq === 'number' && env.seq <= boundarySeq) continue; if (
boundarySeq != null &&
typeof env.seq === "number" &&
env.seq <= boundarySeq
)
continue;
pushLive(env); pushLive(env);
} }
} catch (err) { } catch (err) {
console.warn('history backfill failed', err); console.warn("history backfill failed", err);
if (cancelled) return; if (cancelled) return;
const drained = bufferedRef.current; const drained = bufferedRef.current;
bufferedRef.current = []; bufferedRef.current = [];
@ -157,30 +197,42 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
if (!hasMore || loadingMore || minIdRef.current == null) return; if (!hasMore || loadingMore || minIdRef.current == null) return;
setLoadingMore(true); setLoadingMore(true);
try { try {
const sep = historyUrl.includes('?') ? '&' : '?'; const sep = historyUrl.includes("?") ? "&" : "?";
const resp = await fetch(historyUrl + sep + 'before=' + minIdRef.current); const resp = await fetch(historyUrl + sep + "before=" + minIdRef.current);
if (!resp.ok) return; if (!resp.ok) return;
const body = await resp.json(); const body = await resp.json();
const events: TermEnvelope[] = Array.isArray(body) ? body : body.events || []; const events: TermEnvelope[] = Array.isArray(body)
? body
: body.events || [];
setHasMore(!!body.has_more); setHasMore(!!body.has_more);
if (typeof body.min_id === 'number') minIdRef.current = body.min_id; if (typeof body.min_id === "number") minIdRef.current = body.min_id;
if (events.length) { if (events.length) {
let older: TermRow[] = []; let older: TermRow[] = [];
for (const env of events) older = appendEnvelope(older, env, true); for (const env of events) older = appendEnvelope(older, env, true);
older = appendRow(older, { older = appendRow(older, {
key: 'older-sep-' + minIdRef.current, level: 'debug', fromHistory: true, summary: '─── older above ───', key: "older-sep-" + minIdRef.current,
level: "debug",
fromHistory: true,
summary: "─── older above ───",
}); });
setRows((prev) => [...older, ...prev]); setRows((prev) => [...older, ...prev]);
} }
} catch (err) { } catch (err) {
console.warn('loadMore failed', err); console.warn("loadMore failed", err);
} finally { } finally {
setLoadingMore(false); setLoadingMore(false);
} }
} }
function pushLocalNote(text: string) { function pushLocalNote(text: string) {
setRows((prev) => appendRow(prev, { key: 'local-' + nextKey(), level: 'info', fromHistory: false, summary: text })); setRows((prev) =>
appendRow(prev, {
key: "local-" + nextKey(),
level: "info",
fromHistory: false,
summary: text,
}),
);
} }
function clearLocal() { function clearLocal() {
setRows([]); setRows([]);

View file

@ -3,8 +3,8 @@
// useAgentState: todos change asynchronously (matrix syncs, bash task // useAgentState: todos change asynchronously (matrix syncs, bash task
// starts/completions) independent of the state snapshot, and the old // starts/completions) independent of the state snapshot, and the old
// page polled them independently too. // page polled them independently too.
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from "preact/hooks";
import type { TodoRow } from '../types.js'; import type { TodoRow } from "../types.js";
const POLL_MS = 4000; const POLL_MS = 4000;
@ -20,16 +20,16 @@ export function useTodos(): UseTodosResult {
async function poll() { async function poll() {
try { try {
const resp = await fetch('api/todos'); const resp = await fetch("api/todos");
if (!resp.ok) throw new Error(`http ${resp.status}`); if (!resp.ok) throw new Error(`http ${resp.status}`);
const body = (await resp.json()) as { todos?: TodoRow[] }; const body = (await resp.json()) as { todos?: TodoRow[] };
if (stoppedRef.current) return; if (stoppedRef.current) return;
// Defensive filter: the wire type is the general `LooseEnd` tagged // Defensive filter: the wire type is the general `LooseEnd` tagged
// enum even though this endpoint only ever emits the `todo` variant // enum even though this endpoint only ever emits the `todo` variant
// today — see types.ts's TodoRow comment. // today — see types.ts's TodoRow comment.
setTodos((body.todos ?? []).filter((t) => t.kind === 'todo')); setTodos((body.todos ?? []).filter((t) => t.kind === "todo"));
} catch (err) { } catch (err) {
console.warn('todos fetch failed', err); console.warn("todos fetch failed", err);
if (!stoppedRef.current) setTodos([]); if (!stoppedRef.current) setTodos([]);
} }
} }

View file

@ -1,28 +1,27 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive agent</title> <title>hyperhive agent</title>
<link rel="icon" type="image/svg+xml" href="icon"> <link rel="icon" type="image/svg+xml" href="icon" />
<link rel="stylesheet" href="static/colors.css"> <link rel="stylesheet" href="static/colors.css" />
<link rel="stylesheet" href="static/theme.css"> <link rel="stylesheet" href="static/theme.css" />
<link rel="stylesheet" href="static/agent.css"> <link rel="stylesheet" href="static/agent.css" />
<!-- main.css (bundled component CSS: Badge/Dropdown/LoginFlow/MetaNav/ <!-- main.css (bundled component CSS: Badge/Dropdown/LoginFlow/MetaNav/
StatusChips/SidePanel/...) MUST load after agent.css — StatusChips/SidePanel/...) MUST load after agent.css —
`.login-card`/`.meta-nav-popover`/etc. share class-selector `.login-card`/`.meta-nav-popover`/etc. share class-selector
specificity with agent.css's legacy rules, so load order is what specificity with agent.css's legacy rules, so load order is what
decides which one wins the cascade (see LoginFlow.css/MetaNav.css decides which one wins the cascade (see LoginFlow.css/MetaNav.css
file comments). --> file comments). -->
<link rel="stylesheet" href="static/main.css"> <link rel="stylesheet" href="static/main.css" />
</head> </head>
<body class="agent-shell"> <body class="agent-shell">
<!-- The Preact rewrite owns everything below — header, terminal,
<!-- The Preact rewrite owns everything below — header, terminal,
composer, side panel. See main.tsx/Root.tsx for the component composer, side panel. See main.tsx/Root.tsx for the component
tree; docs/web-ui/agent.md for the design. --> tree; docs/web-ui/agent.md for the design. -->
<div id="preact-root"></div> <div id="preact-root"></div>
<script type="module" src="static/main.js" defer></script> <script type="module" src="static/main.js" defer></script>
</body> </body>
</html> </html>

View file

@ -5,7 +5,7 @@
// `/agent/<name>/` on the dashboard's own origin (`location.origin`); // `/agent/<name>/` on the dashboard's own origin (`location.origin`);
// accessed directly, it's the same host on `dashboardPort`. // accessed directly, it's the same host on `dashboardPort`.
export function resolveDashboardBase(dashboardPort: number): string { export function resolveDashboardBase(dashboardPort: number): string {
return location.pathname.startsWith('/agent/') return location.pathname.startsWith("/agent/")
? location.origin + '/' ? location.origin + "/"
: `${location.protocol}//${location.hostname}:${dashboardPort}/`; : `${location.protocol}//${location.hostname}:${dashboardPort}/`;
} }

View file

@ -2,16 +2,16 @@
// output shapes — this page's operators are used to reading them). // output shapes — this page's operators are used to reading them).
export function fmtTokens(n: number): string { export function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return Math.round(n / 1000) + 'k'; if (n >= 1_000) return Math.round(n / 1000) + "k";
return String(n); return String(n);
} }
export function fmtAge(ms: number): string { export function fmtAge(ms: number): string {
const s = Math.floor(ms / 1000); const s = Math.floor(ms / 1000);
if (s < 60) return s + 's'; if (s < 60) return s + "s";
const m = Math.floor(s / 60); const m = Math.floor(s / 60);
if (m < 60) return m + 'm ' + (s % 60) + 's'; if (m < 60) return m + "m " + (s % 60) + "s";
const h = Math.floor(m / 60); const h = Math.floor(m / 60);
return h + 'h ' + (m % 60) + 'm'; return h + "h " + (m % 60) + "m";
} }

View file

@ -3,13 +3,15 @@
// output there, JSX fragment here). Same regex + trailing-punctuation // output there, JSX fragment here). Same regex + trailing-punctuation
// strip; deliberately text-only, never innerHTML, so untrusted row text // strip; deliberately text-only, never innerHTML, so untrusted row text
// (matrix-relayed bodies, tool args) can't inject markup this way. // (matrix-relayed bodies, tool args) can't inject markup this way.
import type { JSX } from 'preact'; import type { JSX } from "preact";
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g; const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
export function linkifyToNodes(text: string | null | undefined): (string | JSX.Element)[] { export function linkifyToNodes(
const str = text == null ? '' : String(text); text: string | null | undefined,
if (str.indexOf('://') === -1) return [str]; ): (string | JSX.Element)[] {
const str = text == null ? "" : String(text);
if (str.indexOf("://") === -1) return [str];
const out: (string | JSX.Element)[] = []; const out: (string | JSX.Element)[] = [];
let last = 0; let last = 0;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
@ -18,10 +20,10 @@ export function linkifyToNodes(text: string | null | undefined): (string | JSX.E
while ((m = LINKIFY_URL_RE.exec(str)) !== null) { while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
let url = m[0]; let url = m[0];
const trail = url.match(/[.,;:!?)\]}'"]+$/); const trail = url.match(/[.,;:!?)\]}'"]+$/);
const tail = trail ? trail[0] : ''; const tail = trail ? trail[0] : "";
if (tail) url = url.slice(0, -tail.length); if (tail) url = url.slice(0, -tail.length);
if (m.index > last) out.push(str.slice(last, m.index)); if (m.index > last) out.push(str.slice(last, m.index));
if (!url.slice(url.indexOf('://') + 3)) { if (!url.slice(url.indexOf("://") + 3)) {
// Nothing past the scheme — not a real URL, emit verbatim. // Nothing past the scheme — not a real URL, emit verbatim.
out.push(m[0]); out.push(m[0]);
} else { } else {

View file

@ -6,23 +6,38 @@
// these three routes actually redirect today, but the same tolerant // these three routes actually redirect today, but the same tolerant
// check costs nothing and keeps every same-origin POST in this package // check costs nothing and keeps every same-origin POST in this package
// behaving identically. // behaving identically.
async function post(path: string, body?: URLSearchParams): Promise<{ ok: boolean; detail?: string }> { async function post(
path: string,
body?: URLSearchParams,
): Promise<{ ok: boolean; detail?: string }> {
try { try {
const resp = await fetch(path, { const resp = await fetch(path, {
method: 'POST', method: "POST",
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined, headers: body
? { "Content-Type": "application/x-www-form-urlencoded" }
: undefined,
body, body,
redirect: 'manual', redirect: "manual",
}); });
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); const ok =
resp.ok ||
resp.type === "opaqueredirect" ||
(resp.status >= 200 && resp.status < 400);
if (ok) return { ok: true }; if (ok) return { ok: true };
const detail = await resp.text().catch(() => ''); const detail = await resp.text().catch(() => "");
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` }; return {
ok: false,
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
};
} catch (err) { } catch (err) {
return { ok: false, detail: err instanceof Error ? err.message : String(err) }; return {
ok: false,
detail: err instanceof Error ? err.message : String(err),
};
} }
} }
export const postLoginStart = () => post('login/start'); export const postLoginStart = () => post("login/start");
export const postLoginCode = (code: string) => post('login/code', new URLSearchParams({ code })); export const postLoginCode = (code: string) =>
export const postLoginCancel = () => post('login/cancel'); post("login/code", new URLSearchParams({ code }));
export const postLoginCancel = () => post("login/cancel");

View file

@ -4,22 +4,27 @@
// agent-authored files) — `marked` itself no longer sanitizes (v5+ // agent-authored files) — `marked` itself no longer sanitizes (v5+
// dropped the built-in sanitizer), so every parse is run through // dropped the built-in sanitizer), so every parse is run through
// DOMPurify before it's ever handed to `dangerouslySetInnerHTML`. // DOMPurify before it's ever handed to `dangerouslySetInnerHTML`.
import { marked } from 'marked'; import { marked } from "marked";
import DOMPurify from 'dompurify'; import DOMPurify from "dompurify";
marked.setOptions({ breaks: true, gfm: true }); marked.setOptions({ breaks: true, gfm: true });
const ESCAPE_RE = /[&<>"]/g; const ESCAPE_RE = /[&<>"]/g;
const ESCAPE_MAP: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }; const ESCAPE_MAP: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
};
/** Render `text` as sanitized markdown HTML. Falls back to escaped plain /** Render `text` as sanitized markdown HTML. Falls back to escaped plain
* text if `marked` throws (mirrors app.js's try/catch fallback). */ * text if `marked` throws (mirrors app.js's try/catch fallback). */
export function renderMarkdown(text: string | null | undefined): string { export function renderMarkdown(text: string | null | undefined): string {
const src = String(text ?? ''); const src = String(text ?? "");
try { try {
return DOMPurify.sanitize(marked.parse(src) as string); return DOMPurify.sanitize(marked.parse(src) as string);
} catch (err) { } catch (err) {
console.warn('marked failed', err); console.warn("marked failed", err);
return src.replace(ESCAPE_RE, (c) => ESCAPE_MAP[c] ?? c); return src.replace(ESCAPE_RE, (c) => ESCAPE_MAP[c] ?? c);
} }
} }

View file

@ -4,22 +4,36 @@
// ported from app.js unchanged — the endpoints don't actually redirect // ported from app.js unchanged — the endpoints don't actually redirect
// today, but treating an opaque redirect as success costs nothing and // today, but treating an opaque redirect as success costs nothing and
// matches the existing contract exactly. // matches the existing contract exactly.
async function post(path: string, field: string, value: string): Promise<{ ok: boolean; detail?: string }> { async function post(
path: string,
field: string,
value: string,
): Promise<{ ok: boolean; detail?: string }> {
try { try {
const resp = await fetch(path, { const resp = await fetch(path, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ [field]: value }), body: new URLSearchParams({ [field]: value }),
redirect: 'manual', redirect: "manual",
}); });
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); const ok =
resp.ok ||
resp.type === "opaqueredirect" ||
(resp.status >= 200 && resp.status < 400);
if (ok) return { ok: true }; if (ok) return { ok: true };
const detail = await resp.text().catch(() => ''); const detail = await resp.text().catch(() => "");
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` }; return {
ok: false,
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
};
} catch (err) { } catch (err) {
return { ok: false, detail: err instanceof Error ? err.message : String(err) }; return {
ok: false,
detail: err instanceof Error ? err.message : String(err),
};
} }
} }
export const postModel = (name: string) => post('api/model', 'model', name); export const postModel = (name: string) => post("api/model", "model", name);
export const postEffort = (level: string) => post('api/effort', 'effort', level); export const postEffort = (level: string) =>
post("api/effort", "effort", level);

View file

@ -14,10 +14,14 @@
// navigates the whole page to whatever hive-c0re's endpoint returns — // navigates the whole page to whatever hive-c0re's endpoint returns —
// here, the literal text "ok". Fire-and-forget `fetch` keeps the click // here, the literal text "ok". Fire-and-forget `fetch` keeps the click
// on the agent page. // on the agent page.
export async function submitPauseResume(dashboardBase: string, label: string, verb: 'pause' | 'resume'): Promise<void> { export async function submitPauseResume(
dashboardBase: string,
label: string,
verb: "pause" | "resume",
): Promise<void> {
await fetch(`${dashboardBase}api/${verb}/${label}`, { await fetch(`${dashboardBase}api/${verb}/${label}`, {
method: 'POST', method: "POST",
mode: 'no-cors', mode: "no-cors",
credentials: 'include', credentials: "include",
}).catch(() => {}); }).catch(() => {});
} }

View file

@ -1,25 +1,40 @@
// Same-origin fetch POST helpers for the term-input slash commands — // Same-origin fetch POST helpers for the term-input slash commands —
// same shape as modelEffort.ts's `post()` ({ ok, detail } rather than // same shape as modelEffort.ts's `post()` ({ ok, detail } rather than
// throwing), reused by TermInput.tsx. // throwing), reused by TermInput.tsx.
async function post(path: string, body?: URLSearchParams): Promise<{ ok: boolean; detail?: string }> { async function post(
path: string,
body?: URLSearchParams,
): Promise<{ ok: boolean; detail?: string }> {
try { try {
const resp = await fetch(path, { const resp = await fetch(path, {
method: 'POST', method: "POST",
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined, headers: body
? { "Content-Type": "application/x-www-form-urlencoded" }
: undefined,
body, body,
redirect: 'manual', redirect: "manual",
}); });
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); const ok =
resp.ok ||
resp.type === "opaqueredirect" ||
(resp.status >= 200 && resp.status < 400);
if (ok) return { ok: true }; if (ok) return { ok: true };
const detail = await resp.text().catch(() => ''); const detail = await resp.text().catch(() => "");
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` }; return {
ok: false,
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
};
} catch (err) { } catch (err) {
return { ok: false, detail: err instanceof Error ? err.message : String(err) }; return {
ok: false,
detail: err instanceof Error ? err.message : String(err),
};
} }
} }
export const postCancelTurn = () => post('api/cancel'); export const postCancelTurn = () => post("api/cancel");
export const postCompact = () => post('api/compact'); export const postCompact = () => post("api/compact");
export const postNewSession = () => post('api/new-session'); export const postNewSession = () => post("api/new-session");
export const postLogout = () => post('api/logout'); export const postLogout = () => post("api/logout");
export const postSend = (body: string) => post('send', new URLSearchParams({ body })); export const postSend = (body: string) =>
post("send", new URLSearchParams({ body }));

View file

@ -3,14 +3,14 @@
// a `TermMsg` close to as-is (see components/Row.tsx); there's no // a `TermMsg` close to as-is (see components/Row.tsx); there's no
// separate client-side row model or classification step any more — // separate client-side row model or classification step any more —
// mara: "StreamRow should now match what the server sends in TermMsg." // mara: "StreamRow should now match what the server sends in TermMsg."
export type Level = 'debug' | 'info' | 'warn' | 'error'; export type Level = "debug" | "info" | "warn" | "error";
export interface TermMsg { export interface TermMsg {
icon?: string; icon?: string;
level: Level; level: Level;
summary: string; summary: string;
body?: string; body?: string;
body_format?: 'markdown' | 'diff'; body_format?: "markdown" | "diff";
coalesce_key?: string; coalesce_key?: string;
} }

View file

@ -7,7 +7,7 @@
// the old widget family. First click arms the button (caller renders a // the old widget family. First click arms the button (caller renders a
// distinct "sure? click again" label off `armed`); a second click within // distinct "sure? click again" label off `armed`); a second click within
// `resetMs` fires `onConfirm`; anything else (timeout, blur) disarms. // `resetMs` fires `onConfirm`; anything else (timeout, blur) disarms.
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from "preact/hooks";
export function useConfirmClick(onConfirm: () => void, resetMs = 2500) { export function useConfirmClick(onConfirm: () => void, resetMs = 2500) {
const [armed, setArmed] = useState(false); const [armed, setArmed] = useState(false);

View file

@ -7,8 +7,8 @@
// Mounts to `#preact-root`, the only content `index.html`'s `<body>` // Mounts to `#preact-root`, the only content `index.html`'s `<body>`
// declares now — no more coexisting-with-legacy-markup sibling div, // declares now — no more coexisting-with-legacy-markup sibling div,
// that was only needed while both pages built in parallel. // that was only needed while both pages built in parallel.
import { render } from 'preact'; import { render } from "preact";
import { Root } from './Root.js'; import { Root } from "./Root.js";
const root = document.getElementById('preact-root'); const root = document.getElementById("preact-root");
if (root) render(<Root />, root); if (root) render(<Root />, root);

File diff suppressed because it is too large Load diff

View file

@ -1,52 +1,84 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive agent — stats</title> <title>hyperhive agent — stats</title>
<link rel="icon" type="image/svg+xml" href="icon"> <link rel="icon" type="image/svg+xml" href="icon" />
<link rel="stylesheet" href="static/colors.css"> <link rel="stylesheet" href="static/colors.css" />
<link rel="stylesheet" href="static/theme.css"> <link rel="stylesheet" href="static/theme.css" />
<link rel="stylesheet" href="static/agent.css"> <link rel="stylesheet" href="static/agent.css" />
</head> </head>
<body> <body>
<header class="page-header"> <header class="page-header">
<a class="page-back" id="back-link" href="./">← live</a> <a class="page-back" id="back-link" href="./">← live</a>
<a class="page-back" id="dashboard-link" href="#">dashboard ↗</a> <a class="page-back" id="dashboard-link" href="#">dashboard ↗</a>
<span class="page-title" id="title">◆ … ◆</span> <span class="page-title" id="title">◆ … ◆</span>
</header> </header>
<div class="window-tabs" id="window-tabs" role="tablist"> <div class="window-tabs" id="window-tabs" role="tablist">
<button type="button" data-tab="1h">last 1h</button> <button type="button" data-tab="1h">last 1h</button>
<button type="button" data-tab="4h">last 4h</button> <button type="button" data-tab="4h">last 4h</button>
<button type="button" data-tab="24h">last 24h</button> <button type="button" data-tab="24h">last 24h</button>
<button type="button" data-tab="3d">last 3d</button> <button type="button" data-tab="3d">last 3d</button>
<button type="button" data-tab="7d">last 7d</button> <button type="button" data-tab="7d">last 7d</button>
<button type="button" data-tab="30d">last 30d</button> <button type="button" data-tab="30d">last 30d</button>
<button type="button" data-tab="all">all</button> <button type="button" data-tab="all">all</button>
</div> </div>
<div class="summary" id="summary"></div> <div class="summary" id="summary"></div>
<div class="stats-grid"> <div class="stats-grid">
<div class="stats-card wide"><h3>turns per bucket</h3><div class="chart-wrap"><canvas id="chart-turns"></canvas></div></div> <div class="stats-card wide">
<div class="stats-card wide"><h3>turn duration (ms) — p50 / p95 / avg</h3><div class="chart-wrap"><canvas id="chart-duration"></canvas></div></div> <h3>turns per bucket</h3>
<div class="stats-card wide"><h3>context tokens (last inference per turn) — avg / max</h3><div class="chart-wrap"><canvas id="chart-ctx"></canvas></div></div> <div class="chart-wrap"><canvas id="chart-turns"></canvas></div>
<div class="stats-card wide"><h3>token cost per bucket (sum across inferences)</h3><div class="chart-wrap"><canvas id="chart-cost"></canvas></div></div> </div>
<div class="stats-card wide"><h3>turns by model per bucket — model drives token cost</h3><div class="chart-wrap"><canvas id="chart-model"></canvas></div></div> <div class="stats-card wide">
<div class="stats-card"><h3>top tools</h3><div class="chart-wrap"><canvas id="chart-tools"></canvas></div></div> <h3>turn duration (ms) — p50 / p95 / avg</h3>
<!-- "favorite tools": most-run shell commands. Hidden until the <div class="chart-wrap"><canvas id="chart-duration"></canvas></div>
</div>
<div class="stats-card wide">
<h3>context tokens (last inference per turn) — avg / max</h3>
<div class="chart-wrap"><canvas id="chart-ctx"></canvas></div>
</div>
<div class="stats-card wide">
<h3>token cost per bucket (sum across inferences)</h3>
<div class="chart-wrap"><canvas id="chart-cost"></canvas></div>
</div>
<div class="stats-card wide">
<h3>turns by model per bucket — model drives token cost</h3>
<div class="chart-wrap"><canvas id="chart-model"></canvas></div>
</div>
<div class="stats-card">
<h3>top tools</h3>
<div class="chart-wrap"><canvas id="chart-tools"></canvas></div>
</div>
<!-- "favorite tools": most-run shell commands. Hidden until the
bash_commands capture (hive-bash-daemon) has recorded data, so the bash_commands capture (hive-bash-daemon) has recorded data, so the
card never shows a permanently-empty doughnut. --> card never shows a permanently-empty doughnut. -->
<div class="stats-card" id="card-bash" hidden><h3>favorite tools (bash)</h3><div class="chart-wrap"><canvas id="chart-bash"></canvas></div></div> <div class="stats-card" id="card-bash" hidden>
<div class="stats-card"><h3>wake source mix</h3><div class="chart-wrap"><canvas id="chart-wake"></canvas></div></div> <h3>favorite tools (bash)</h3>
<div class="stats-card"><h3>result mix</h3><div class="chart-wrap"><canvas id="chart-result"></canvas></div></div> <div class="chart-wrap"><canvas id="chart-bash"></canvas></div>
<div class="stats-card wide"><h3>result trend per bucket — errors / rate-limits / compactions over time</h3><div class="chart-wrap"><canvas id="chart-result-trend"></canvas></div></div> </div>
</div> <div class="stats-card">
<h3>wake source mix</h3>
<div class="chart-wrap"><canvas id="chart-wake"></canvas></div>
</div>
<div class="stats-card">
<h3>result mix</h3>
<div class="chart-wrap"><canvas id="chart-result"></canvas></div>
</div>
<div class="stats-card wide">
<h3>
result trend per bucket — errors / rate-limits / compactions over time
</h3>
<div class="chart-wrap"><canvas id="chart-result-trend"></canvas></div>
</div>
</div>
<!-- Chart.js is now bundled into stats.js by esbuild (npm dep <!-- Chart.js is now bundled into stats.js by esbuild (npm dep
chart.js@4.4.4), so the page works offline / on operator chart.js@4.4.4), so the page works offline / on operator
machines without internet egress. No SRI hash to maintain. --> machines without internet egress. No SRI hash to maintain. -->
<script type="module" src="static/stats.js" defer></script> <script type="module" src="static/stats.js" defer></script>
</body> </body>
</html> </html>

View file

@ -2,8 +2,8 @@
// once on load, then /api/stats?window=... for the chart data — re-fetches // once on load, then /api/stats?window=... for the chart data — re-fetches
// when the operator clicks a window tab. // when the operator clicks a window tab.
import Chart from 'chart.js/auto'; import Chart from "chart.js/auto";
import { createTabStrip } from '@hive/shared/tabs.js'; import { createTabStrip } from "@hive/shared/tabs.js";
// Expose for the IIFE below — pre-split this was a window global from // Expose for the IIFE below — pre-split this was a window global from
// the jsDelivr CDN script tag. esbuild now bundles chart.js into // the jsDelivr CDN script tag. esbuild now bundles chart.js into
@ -12,26 +12,36 @@ import { createTabStrip } from '@hive/shared/tabs.js';
window.Chart = Chart; window.Chart = Chart;
(function () { (function () {
'use strict'; "use strict";
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); const cssVar = (name) =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const palette = { const palette = {
bg: cssVar('--bg'), bg: cssVar("--bg"),
bgElev: cssVar('--bg-elev'), bgElev: cssVar("--bg-elev"),
fg: cssVar('--fg'), fg: cssVar("--fg"),
muted: cssVar('--muted'), muted: cssVar("--muted"),
purple: cssVar('--purple'), purple: cssVar("--purple"),
cyan: cssVar('--cyan'), cyan: cssVar("--cyan"),
pink: cssVar('--pink'), pink: cssVar("--pink"),
amber: cssVar('--amber'), amber: cssVar("--amber"),
green: cssVar('--green'), green: cssVar("--green"),
red: cssVar('--red'), red: cssVar("--red"),
border: cssVar('--border'), border: cssVar("--border"),
}; };
// Distinct hues for categorical charts (top tools / wake mix / result mix). // Distinct hues for categorical charts (top tools / wake mix / result mix).
const wheel = [palette.purple, palette.cyan, palette.pink, palette.amber, const wheel = [
palette.green, palette.red, '#94e2d5', '#f9e2af', palette.purple,
'#74c7ec', '#b4befe']; palette.cyan,
palette.pink,
palette.amber,
palette.green,
palette.red,
"#94e2d5",
"#f9e2af",
"#74c7ec",
"#b4befe",
];
// Apply Catppuccin defaults globally so each Chart inherits without per-call // Apply Catppuccin defaults globally so each Chart inherits without per-call
// overrides. Chart.js v4 reads these on chart construction. // overrides. Chart.js v4 reads these on chart construction.
@ -42,16 +52,16 @@ window.Chart = Chart;
Chart.defaults.plugins.legend.labels.color = palette.fg; Chart.defaults.plugins.legend.labels.color = palette.fg;
const charts = {}; const charts = {};
let currentWindow = '24h'; let currentWindow = "24h";
function fmtMs(ms) { function fmtMs(ms) {
if (!Number.isFinite(ms) || ms <= 0) return '0'; if (!Number.isFinite(ms) || ms <= 0) return "0";
if (ms < 1000) return ms.toFixed(0) + 'ms'; if (ms < 1000) return ms.toFixed(0) + "ms";
return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + 's'; return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + "s";
} }
function fmtInt(n) { function fmtInt(n) {
if (!Number.isFinite(n)) return '0'; if (!Number.isFinite(n)) return "0";
return new Intl.NumberFormat().format(Math.round(n)); return new Intl.NumberFormat().format(Math.round(n));
} }
@ -74,18 +84,21 @@ window.Chart = Chart;
destroy(canvasId); destroy(canvasId);
const cv = document.getElementById(canvasId); const cv = document.getElementById(canvasId);
if (!cv) return; if (!cv) return;
const ctx = cv.getContext('2d'); const ctx = cv.getContext("2d");
ctx.clearRect(0, 0, cv.width, cv.height); ctx.clearRect(0, 0, cv.width, cv.height);
ctx.fillStyle = palette.muted; ctx.fillStyle = palette.muted;
ctx.font = '12px monospace'; ctx.font = "12px monospace";
ctx.textAlign = 'center'; ctx.textAlign = "center";
ctx.textBaseline = 'middle'; ctx.textBaseline = "middle";
ctx.fillText(msg, cv.width / 2, cv.height / 2); ctx.fillText(msg, cv.width / 2, cv.height / 2);
} }
// Sum the four token streams across every bucket in the window. // Sum the four token streams across every bucket in the window.
function tokenTotals(s) { function tokenTotals(s) {
let input = 0, output = 0, cacheRead = 0, cacheCreation = 0; let input = 0,
output = 0,
cacheRead = 0,
cacheCreation = 0;
for (const b of s.buckets || []) { for (const b of s.buckets || []) {
input += b.input_tokens || 0; input += b.input_tokens || 0;
output += b.output_tokens || 0; output += b.output_tokens || 0;
@ -96,54 +109,58 @@ window.Chart = Chart;
} }
function renderSummary(s) { function renderSummary(s) {
const root = document.getElementById('summary'); const root = document.getElementById("summary");
root.replaceChildren(); root.replaceChildren();
const chips = [ const chips = [
['turns', fmtInt(s.turn_count)], ["turns", fmtInt(s.turn_count)],
['avg duration', fmtMs(s.duration_summary.avg_ms)], ["avg duration", fmtMs(s.duration_summary.avg_ms)],
['p50 duration', fmtMs(s.duration_summary.p50_ms)], ["p50 duration", fmtMs(s.duration_summary.p50_ms)],
['p95 duration', fmtMs(s.duration_summary.p95_ms)], ["p95 duration", fmtMs(s.duration_summary.p95_ms)],
['window', s.window], ["window", s.window],
]; ];
// Token-efficiency chips: cache hit-rate (cached input vs all // Token-efficiency chips: cache hit-rate (cached input vs all
// input-side tokens) and average tokens billed per turn. // input-side tokens) and average tokens billed per turn.
const t = tokenTotals(s); const t = tokenTotals(s);
const inputSide = t.input + t.cacheRead + t.cacheCreation; const inputSide = t.input + t.cacheRead + t.cacheCreation;
if (inputSide > 0) { if (inputSide > 0) {
chips.push(['cache hit-rate', (100 * t.cacheRead / inputSide).toFixed(1) + '%']); chips.push([
"cache hit-rate",
((100 * t.cacheRead) / inputSide).toFixed(1) + "%",
]);
} }
if (s.turn_count > 0) { if (s.turn_count > 0) {
const perTurn = (t.input + t.output + t.cacheRead + t.cacheCreation) / s.turn_count; const perTurn =
chips.push(['tokens/turn', fmtInt(perTurn)]); (t.input + t.output + t.cacheRead + t.cacheCreation) / s.turn_count;
chips.push(["tokens/turn", fmtInt(perTurn)]);
} }
if (s.reminder_stats) { if (s.reminder_stats) {
chips.push( chips.push(
['reminders scheduled', fmtInt(s.reminder_stats.scheduled)], ["reminders scheduled", fmtInt(s.reminder_stats.scheduled)],
['reminders delivered', fmtInt(s.reminder_stats.delivered)], ["reminders delivered", fmtInt(s.reminder_stats.delivered)],
['reminders pending', fmtInt(s.reminder_stats.pending)], ["reminders pending", fmtInt(s.reminder_stats.pending)],
); );
} }
// Session count: fresh claude sessions started in the window (each // Session count: fresh claude sessions started in the window (each
// new-session or auto-compaction-fallback mints one). Omitted until // new-session or auto-compaction-fallback mints one). Omitted until
// the sessions table exists (older db). // the sessions table exists (older db).
if (typeof s.session_count === 'number') { if (typeof s.session_count === "number") {
chips.push(['sessions', fmtInt(s.session_count)]); chips.push(["sessions", fmtInt(s.session_count)]);
} }
// First-turn ctx: input tokens of the most recent fresh session's // First-turn ctx: input tokens of the most recent fresh session's
// first turn — the cold system-prompt + CLAUDE.md cost, a sprawl // first turn — the cold system-prompt + CLAUDE.md cost, a sprawl
// proxy. Omitted from the JSON (and so absent here) until the // proxy. Omitted from the JSON (and so absent here) until the
// per-session capture has data. // per-session capture has data.
if (typeof s.first_turn_ctx === 'number') { if (typeof s.first_turn_ctx === "number") {
chips.push(['first-turn ctx', fmtInt(s.first_turn_ctx)]); chips.push(["first-turn ctx", fmtInt(s.first_turn_ctx)]);
} }
for (const [label, value] of chips) { for (const [label, value] of chips) {
const chip = document.createElement('span'); const chip = document.createElement("span");
chip.className = 'chip'; chip.className = "chip";
const l = document.createElement('span'); const l = document.createElement("span");
l.className = 'label'; l.className = "label";
l.textContent = label; l.textContent = label;
const v = document.createElement('span'); const v = document.createElement("span");
v.className = 'value'; v.className = "value";
v.textContent = value; v.textContent = value;
chip.append(l, v); chip.append(l, v);
root.append(chip); root.append(chip);
@ -151,54 +168,67 @@ window.Chart = Chart;
} }
function renderTurnsChart(s) { function renderTurnsChart(s) {
const id = 'chart-turns'; const id = "chart-turns";
destroy(id); destroy(id);
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
const data = s.buckets.map((b) => b.turn_count); const data = s.buckets.map((b) => b.turn_count);
charts[id] = new Chart(document.getElementById(id), { charts[id] = new Chart(document.getElementById(id), {
type: 'bar', type: "bar",
data: { data: {
labels, labels,
datasets: [{ datasets: [
label: 'turns', {
data, label: "turns",
backgroundColor: palette.purple, data,
borderColor: palette.purple, backgroundColor: palette.purple,
borderWidth: 1, borderColor: palette.purple,
}], borderWidth: 1,
},
],
}, },
options: { options: {
responsive: true, maintainAspectRatio: false, responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } }, plugins: { legend: { display: false } },
scales: { scales: {
x: { grid: { color: palette.border } }, x: { grid: { color: palette.border } },
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } }, y: {
beginAtZero: true,
grid: { color: palette.border },
ticks: { precision: 0 },
},
}, },
}, },
}); });
} }
function renderDurationChart(s) { function renderDurationChart(s) {
const id = 'chart-duration'; const id = "chart-duration";
destroy(id); destroy(id);
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
const ds = (label, color, key) => ({ const ds = (label, color, key) => ({
label, data: s.buckets.map((b) => b[key]), label,
borderColor: color, backgroundColor: color + '33', data: s.buckets.map((b) => b[key]),
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, borderColor: color,
backgroundColor: color + "33",
tension: 0.25,
pointRadius: 0,
borderWidth: 2,
spanGaps: true,
}); });
charts[id] = new Chart(document.getElementById(id), { charts[id] = new Chart(document.getElementById(id), {
type: 'line', type: "line",
data: { data: {
labels, labels,
datasets: [ datasets: [
ds('p50', palette.cyan, 'p50_duration_ms'), ds("p50", palette.cyan, "p50_duration_ms"),
ds('p95', palette.pink, 'p95_duration_ms'), ds("p95", palette.pink, "p95_duration_ms"),
ds('avg', palette.amber, 'avg_duration_ms'), ds("avg", palette.amber, "avg_duration_ms"),
], ],
}, },
options: { options: {
responsive: true, maintainAspectRatio: false, responsive: true,
maintainAspectRatio: false,
scales: { scales: {
x: { grid: { color: palette.border } }, x: { grid: { color: palette.border } },
y: { y: {
@ -212,77 +242,108 @@ window.Chart = Chart;
} }
function renderCtxChart(s) { function renderCtxChart(s) {
const id = 'chart-ctx'; const id = "chart-ctx";
destroy(id); destroy(id);
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
charts[id] = new Chart(document.getElementById(id), { charts[id] = new Chart(document.getElementById(id), {
type: 'line', type: "line",
data: { data: {
labels, labels,
datasets: [ datasets: [
{ {
label: 'avg ctx', label: "avg ctx",
data: s.buckets.map((b) => b.avg_ctx_tokens), data: s.buckets.map((b) => b.avg_ctx_tokens),
borderColor: palette.cyan, borderColor: palette.cyan,
backgroundColor: palette.cyan + '33', backgroundColor: palette.cyan + "33",
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, tension: 0.25,
pointRadius: 0,
borderWidth: 2,
spanGaps: true,
}, },
{ {
label: 'max ctx', label: "max ctx",
data: s.buckets.map((b) => b.max_ctx_tokens), data: s.buckets.map((b) => b.max_ctx_tokens),
borderColor: palette.amber, borderColor: palette.amber,
backgroundColor: palette.amber + '33', backgroundColor: palette.amber + "33",
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, tension: 0.25,
pointRadius: 0,
borderWidth: 2,
spanGaps: true,
}, },
], ],
}, },
options: { options: {
responsive: true, maintainAspectRatio: false, responsive: true,
maintainAspectRatio: false,
scales: { scales: {
x: { grid: { color: palette.border } }, x: { grid: { color: palette.border } },
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } }, y: {
beginAtZero: true,
grid: { color: palette.border },
ticks: { callback: (v) => fmtInt(v) },
},
}, },
}, },
}); });
} }
function renderCostChart(s) { function renderCostChart(s) {
const id = 'chart-cost'; const id = "chart-cost";
destroy(id); destroy(id);
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
// Stacked bars: cache_read (cheap) / cache_creation / input / output. // Stacked bars: cache_read (cheap) / cache_creation / input / output.
// Highlights "what's actually getting billed at full rate" vs cache hits. // Highlights "what's actually getting billed at full rate" vs cache hits.
charts[id] = new Chart(document.getElementById(id), { charts[id] = new Chart(document.getElementById(id), {
type: 'bar', type: "bar",
data: { data: {
labels, labels,
datasets: [ datasets: [
{ label: 'cache_read', data: s.buckets.map((b) => b.cache_read_input_tokens), {
backgroundColor: palette.muted }, label: "cache_read",
{ label: 'cache_creation', data: s.buckets.map((b) => b.cache_creation_input_tokens), data: s.buckets.map((b) => b.cache_read_input_tokens),
backgroundColor: palette.cyan }, backgroundColor: palette.muted,
{ label: 'input', data: s.buckets.map((b) => b.input_tokens), },
backgroundColor: palette.amber }, {
{ label: 'output', data: s.buckets.map((b) => b.output_tokens), label: "cache_creation",
backgroundColor: palette.pink }, data: s.buckets.map((b) => b.cache_creation_input_tokens),
backgroundColor: palette.cyan,
},
{
label: "input",
data: s.buckets.map((b) => b.input_tokens),
backgroundColor: palette.amber,
},
{
label: "output",
data: s.buckets.map((b) => b.output_tokens),
backgroundColor: palette.pink,
},
], ],
}, },
options: { options: {
responsive: true, maintainAspectRatio: false, responsive: true,
maintainAspectRatio: false,
scales: { scales: {
x: { stacked: true, grid: { color: palette.border } }, x: { stacked: true, grid: { color: palette.border } },
y: { stacked: true, beginAtZero: true, y: {
grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } }, stacked: true,
beginAtZero: true,
grid: { color: palette.border },
ticks: { callback: (v) => fmtInt(v) },
},
}, },
}, },
}); });
} }
function renderModelChart(s) { function renderModelChart(s) {
const id = 'chart-model'; const id = "chart-model";
destroy(id); destroy(id);
const models = s.models || []; const models = s.models || [];
if (!models.length) { paintEmpty(id, 'no turns in window'); return; } if (!models.length) {
paintEmpty(id, "no turns in window");
return;
}
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
// One stacked series per model. Model choice drives token cost, // One stacked series per model. Model choice drives token cost,
// so this lines up against the cost chart above it. // so this lines up against the cost chart above it.
@ -292,28 +353,36 @@ window.Chart = Chart;
backgroundColor: wheel[i % wheel.length], backgroundColor: wheel[i % wheel.length],
})); }));
charts[id] = new Chart(document.getElementById(id), { charts[id] = new Chart(document.getElementById(id), {
type: 'bar', type: "bar",
data: { labels, datasets }, data: { labels, datasets },
options: { options: {
responsive: true, maintainAspectRatio: false, responsive: true,
plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } }, maintainAspectRatio: false,
plugins: { legend: { position: "top", labels: { boxWidth: 12 } } },
scales: { scales: {
x: { stacked: true, grid: { color: palette.border } }, x: { stacked: true, grid: { color: palette.border } },
y: { stacked: true, beginAtZero: true, y: {
grid: { color: palette.border }, ticks: { precision: 0 } }, stacked: true,
beginAtZero: true,
grid: { color: palette.border },
ticks: { precision: 0 },
},
}, },
}, },
}); });
} }
function renderResultTrendChart(s) { function renderResultTrendChart(s) {
const id = 'chart-result-trend'; const id = "chart-result-trend";
destroy(id); destroy(id);
// Series = the result kinds seen in the window (same order + // Series = the result kinds seen in the window (same order +
// colours as the result-mix doughnut). One stacked bar series per // colours as the result-mix doughnut). One stacked bar series per
// kind, so error / rate-limit / compaction spikes line up in time. // kind, so error / rate-limit / compaction spikes line up in time.
const kinds = (s.result_mix || []).map((kc) => kc.key); const kinds = (s.result_mix || []).map((kc) => kc.key);
if (!kinds.length) { paintEmpty(id, 'no results'); return; } if (!kinds.length) {
paintEmpty(id, "no results");
return;
}
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
const datasets = kinds.map((k, i) => ({ const datasets = kinds.map((k, i) => ({
label: k, label: k,
@ -321,15 +390,20 @@ window.Chart = Chart;
backgroundColor: wheel[i % wheel.length], backgroundColor: wheel[i % wheel.length],
})); }));
charts[id] = new Chart(document.getElementById(id), { charts[id] = new Chart(document.getElementById(id), {
type: 'bar', type: "bar",
data: { labels, datasets }, data: { labels, datasets },
options: { options: {
responsive: true, maintainAspectRatio: false, responsive: true,
plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } }, maintainAspectRatio: false,
plugins: { legend: { position: "top", labels: { boxWidth: 12 } } },
scales: { scales: {
x: { stacked: true, grid: { color: palette.border } }, x: { stacked: true, grid: { color: palette.border } },
y: { stacked: true, beginAtZero: true, y: {
grid: { color: palette.border }, ticks: { precision: 0 } }, stacked: true,
beginAtZero: true,
grid: { color: palette.border },
ticks: { precision: 0 },
},
}, },
}, },
}); });
@ -345,11 +419,22 @@ window.Chart = Chart;
const data = items.map((kc) => kc.count); const data = items.map((kc) => kc.count);
const colors = items.map((_, i) => wheel[i % wheel.length]); const colors = items.map((_, i) => wheel[i % wheel.length]);
charts[canvasId] = new Chart(document.getElementById(canvasId), { charts[canvasId] = new Chart(document.getElementById(canvasId), {
type: 'doughnut', type: "doughnut",
data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: palette.bg, borderWidth: 2 }] }, data: {
labels,
datasets: [
{
data,
backgroundColor: colors,
borderColor: palette.bg,
borderWidth: 2,
},
],
},
options: { options: {
responsive: true, maintainAspectRatio: false, responsive: true,
plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } }, maintainAspectRatio: false,
plugins: { legend: { position: "right", labels: { boxWidth: 12 } } },
}, },
}); });
} }
@ -360,30 +445,30 @@ window.Chart = Chart;
// doughnut. Runs independently of turn_count (a bash task is tied to // doughnut. Runs independently of turn_count (a bash task is tied to
// a turn, but we don't want to couple the two reads). // a turn, but we don't want to couple the two reads).
function renderBashCard(s) { function renderBashCard(s) {
const card = document.getElementById('card-bash'); const card = document.getElementById("card-bash");
const items = s.bash_breakdown || []; const items = s.bash_breakdown || [];
if (!items.length) { if (!items.length) {
if (card) card.hidden = true; if (card) card.hidden = true;
destroy('chart-bash'); destroy("chart-bash");
return; return;
} }
if (card) card.hidden = false; if (card) card.hidden = false;
renderKeyCount('chart-bash', items, 'no bash commands'); renderKeyCount("chart-bash", items, "no bash commands");
} }
function render(s) { function render(s) {
renderSummary(s); renderSummary(s);
renderBashCard(s); renderBashCard(s);
if (s.turn_count === 0) { if (s.turn_count === 0) {
paintEmpty('chart-turns', 'no turns in window'); paintEmpty("chart-turns", "no turns in window");
paintEmpty('chart-duration', 'no turns in window'); paintEmpty("chart-duration", "no turns in window");
paintEmpty('chart-ctx', 'no turns in window'); paintEmpty("chart-ctx", "no turns in window");
paintEmpty('chart-cost', 'no turns in window'); paintEmpty("chart-cost", "no turns in window");
paintEmpty('chart-model', 'no turns in window'); paintEmpty("chart-model", "no turns in window");
paintEmpty('chart-tools', 'no tool calls'); paintEmpty("chart-tools", "no tool calls");
paintEmpty('chart-wake', 'no wakes'); paintEmpty("chart-wake", "no wakes");
paintEmpty('chart-result', 'no results'); paintEmpty("chart-result", "no results");
paintEmpty('chart-result-trend', 'no results'); paintEmpty("chart-result-trend", "no results");
return; return;
} }
renderTurnsChart(s); renderTurnsChart(s);
@ -391,51 +476,63 @@ window.Chart = Chart;
renderCtxChart(s); renderCtxChart(s);
renderCostChart(s); renderCostChart(s);
renderModelChart(s); renderModelChart(s);
renderKeyCount('chart-tools', s.tool_breakdown, 'no tool calls'); renderKeyCount("chart-tools", s.tool_breakdown, "no tool calls");
renderKeyCount('chart-wake', s.wake_mix, 'no wakes'); renderKeyCount("chart-wake", s.wake_mix, "no wakes");
renderKeyCount('chart-result', s.result_mix, 'no results'); renderKeyCount("chart-result", s.result_mix, "no results");
renderResultTrendChart(s); renderResultTrendChart(s);
} }
async function loadStats() { async function loadStats() {
try { try {
const resp = await fetch('api/stats?window=' + encodeURIComponent(currentWindow)); const resp = await fetch(
if (!resp.ok) throw new Error('http ' + resp.status); "api/stats?window=" + encodeURIComponent(currentWindow),
);
if (!resp.ok) throw new Error("http " + resp.status);
const snap = await resp.json(); const snap = await resp.json();
render(snap); render(snap);
} catch (e) { } catch (e) {
document.getElementById('summary').textContent = 'stats fetch failed: ' + e; document.getElementById("summary").textContent =
"stats fetch failed: " + e;
} }
} }
async function loadIdentity() { async function loadIdentity() {
try { try {
const resp = await fetch('api/state'); const resp = await fetch("api/state");
if (!resp.ok) return; if (!resp.ok) return;
const s = await resp.json(); const s = await resp.json();
document.title = 'stats · ' + s.label; document.title = "stats · " + s.label;
document.getElementById('title').textContent = '◆ ' + s.label + ' ◆'; document.getElementById("title").textContent = "◆ " + s.label + " ◆";
const dl = document.getElementById('dashboard-link'); const dl = document.getElementById("dashboard-link");
// When accessed via hive-gateway the page lives at `/agent/<name>/` // When accessed via hive-gateway the page lives at `/agent/<name>/`
// on the same origin as the dashboard. Detect via path prefix // on the same origin as the dashboard. Detect via path prefix
// rather than the direct port (which is unreachable or wrong scheme // rather than the direct port (which is unreachable or wrong scheme
// behind HTTPS TLS termination). The dashboard SPA lives at // behind HTTPS TLS termination). The dashboard SPA lives at
// `dashboard.html` — the `/` root now serves the H0M3 menu hub. // `dashboard.html` — the `/` root now serves the H0M3 menu hub.
dl.href = window.location.pathname.startsWith('/agent/') dl.href = window.location.pathname.startsWith("/agent/")
? window.location.origin + '/dashboard.html' ? window.location.origin + "/dashboard.html"
: 'http://' + window.location.hostname + ':' + s.dashboard_port + '/dashboard.html'; : "http://" +
} catch (_) { /* non-fatal */ } window.location.hostname +
":" +
s.dashboard_port +
"/dashboard.html";
} catch (_) {
/* non-fatal */
}
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener("DOMContentLoaded", () => {
loadIdentity(); loadIdentity();
// The shared hash-routed tab strip drives the stat-window selector, // The shared hash-routed tab strip drives the stat-window selector,
// making it deep-linkable (#1h / #24h / …). onShow updates the // making it deep-linkable (#1h / #24h / …). onShow updates the
// window + reloads; the strip's initial show fires onShow once, so // window + reloads; the strip's initial show fires onShow once, so
// there's no separate loadStats() call here (avoids a double fetch). // there's no separate loadStats() call here (avoids a double fetch).
createTabStrip(document.getElementById('window-tabs'), { createTabStrip(document.getElementById("window-tabs"), {
defaultId: currentWindow, defaultId: currentWindow,
onShow: (w) => { currentWindow = w; loadStats(); }, onShow: (w) => {
currentWindow = w;
loadStats();
},
}); });
}); });
})(); })();

View file

@ -9,7 +9,11 @@ export interface AgentState {
label: string; label: string;
qualified_label: string; qualified_label: string;
dashboard_port: number; dashboard_port: number;
status: 'online' | 'rate_limited' | 'needs_login_idle' | 'needs_login_in_progress'; status:
| "online"
| "rate_limited"
| "needs_login_idle"
| "needs_login_in_progress";
session: SessionView | null; session: SessionView | null;
turn_state: string; turn_state: string;
turn_state_since: number; turn_state_since: number;
@ -38,7 +42,7 @@ export interface AgentLink {
url: string; url: string;
icon: string; icon: string;
label: string; label: string;
kind: 'container' | 'forge' | 'external'; kind: "container" | "forge" | "external";
} }
// Mirrors `hive_agent::web_ui::state::SessionView` — populated only // Mirrors `hive_agent::web_ui::state::SessionView` — populated only
@ -72,7 +76,7 @@ export interface InboxRow {
// the only variant `GET /api/todos` actually returns (a dynamic, // the only variant `GET /api/todos` actually returns (a dynamic,
// subsystem-pushed todo: matrix/bash/forge are the built-in producers). // subsystem-pushed todo: matrix/bash/forge are the built-in producers).
export interface TodoRow { export interface TodoRow {
kind: 'todo'; kind: "todo";
id: number; id: number;
subsystem: string; subsystem: string;
subsystem_key?: string | null; subsystem_key?: string | null;

View file

@ -48,32 +48,41 @@
// prefix) so it never shadows the exact-match /dashboard/stream + // prefix) so it never shadows the exact-match /dashboard/stream +
// /dashboard/history SSE routes registered before the ServeDir fallback. // /dashboard/history SSE routes registered before the ServeDir fallback.
import { build } from 'esbuild'; import { build } from "esbuild";
import { mkdirSync, copyFileSync, rmSync } from 'node:fs'; import { mkdirSync, copyFileSync, rmSync } from "node:fs";
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from "node:path";
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const src = (p) => resolve(here, 'src', p); const src = (p) => resolve(here, "src", p);
const dist = (p) => resolve(here, 'dist', p); const dist = (p) => resolve(here, "dist", p);
const staticDir = (p) => resolve(here, 'dist', 'static', p); const staticDir = (p) => resolve(here, "dist", "static", p);
rmSync(dist(''), { recursive: true, force: true }); rmSync(dist(""), { recursive: true, force: true });
mkdirSync(staticDir(''), { recursive: true }); mkdirSync(staticDir(""), { recursive: true });
// Bundle the JS entries. ES-module output, browser target, no minify // Bundle the JS entries. ES-module output, browser target, no minify
// (line-aligned source aids debugging; minification belongs in a later // (line-aligned source aids debugging; minification belongs in a later
// follow-up once asset sizes warrant it). esbuild writes each entry // follow-up once asset sizes warrant it). esbuild writes each entry
// to `static/<name>.js` based on the entryPoint basename. // to `static/<name>.js` based on the entryPoint basename.
await build({ await build({
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('stats.js'), src('core.js'), src('builds.js'), src('credentials.js')], entryPoints: [
outdir: staticDir(''), src("tabs.js"),
src("flow.js"),
src("logs.js"),
src("home.js"),
src("stats.js"),
src("core.js"),
src("builds.js"),
src("credentials.js"),
],
outdir: staticDir(""),
bundle: true, bundle: true,
format: 'esm', format: "esm",
platform: 'browser', platform: "browser",
target: ['es2022'], target: ["es2022"],
sourcemap: true, sourcemap: true,
logLevel: 'info', logLevel: "info",
// `@hive/shared/modal.js` and `hive-btn.js` import their shadow-DOM // `@hive/shared/modal.js` and `hive-btn.js` import their shadow-DOM
// component CSS (hive-dialog.css, hive-toast.css, hive-btn.css) as raw // component CSS (hive-dialog.css, hive-toast.css, hive-btn.css) as raw
// text via a plain `import css from './foo.css'` — the `text` loader turns // text via a plain `import css from './foo.css'` — the `text` loader turns
@ -82,15 +91,15 @@ await build({
// file any other way, so this doesn't collide with the separate // file any other way, so this doesn't collide with the separate
// page-stylesheet bundling below (`loader: { '.css': 'css' }`), which // page-stylesheet bundling below (`loader: { '.css': 'css' }`), which
// runs as its own esbuild invocation over different entry points. // runs as its own esbuild invocation over different entry points.
loader: { '.css': 'text' }, loader: { ".css": "text" },
// `@hive/shared/jobq-graph.js` resolves to a real `.jsx` file // `@hive/shared/jobq-graph.js` resolves to a real `.jsx` file
// (`JobqGraph.jsx`), pulled in transitively by `builds.js` — esbuild // (`JobqGraph.jsx`), pulled in transitively by `builds.js` — esbuild
// already picks the `jsx` loader for `.jsx` by extension, this just // already picks the `jsx` loader for `.jsx` by extension, this just
// sets the transform mode to match swarm-ui's (which also authors // sets the transform mode to match swarm-ui's (which also authors
// this file). No other entry here uses JSX today; this doesn't turn // this file). No other entry here uses JSX today; this doesn't turn
// any plain `.js` file into one, `.js` still parses as plain JS. // any plain `.js` file into one, `.js` still parses as plain JS.
jsx: 'automatic', jsx: "automatic",
jsxImportSource: 'preact', jsxImportSource: "preact",
}); });
// Stream-worker entry (#448). Lives in a separate bundle: SharedWorker // Stream-worker entry (#448). Lives in a separate bundle: SharedWorker
@ -106,14 +115,14 @@ await build({
// the IIFE format will surface it as a build error rather than // the IIFE format will surface it as a build error rather than
// silently shipping broken code. // silently shipping broken code.
await build({ await build({
entryPoints: [src('stream-worker.js')], entryPoints: [src("stream-worker.js")],
outdir: staticDir(''), outdir: staticDir(""),
bundle: true, bundle: true,
format: 'iife', format: "iife",
platform: 'browser', platform: "browser",
target: ['es2022'], target: ["es2022"],
sourcemap: true, sourcemap: true,
logLevel: 'info', logLevel: "info",
}); });
// Bundle CSS — one entry per page. esbuild resolves @import including // Bundle CSS — one entry per page. esbuild resolves @import including
@ -122,18 +131,39 @@ await build({
// so a swap replaces only it) + theme.css (the semantic derivation // so a swap replaces only it) + theme.css (the semantic derivation
// layer) + common.css (shared typography, badges, buttons, inbox, side // layer) + common.css (shared typography, badges, buttons, inbox, side
// panel) plus its own page-specific bundle. // panel) plus its own page-specific bundle.
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'stats.css', 'core.css', 'builds.css', 'credentials.css']) { for (const entry of [
"colors.css",
"theme.css",
"common.css",
"dashboard.css",
"flow.css",
"logs.css",
"home.css",
"stats.css",
"core.css",
"builds.css",
"credentials.css",
]) {
await build({ await build({
entryPoints: [src(entry)], entryPoints: [src(entry)],
outfile: staticDir(entry), outfile: staticDir(entry),
bundle: true, bundle: true,
loader: { '.css': 'css' }, loader: { ".css": "css" },
logLevel: 'info', logLevel: "info",
}); });
} }
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'stats.html', 'core.html', 'builds.html', 'credentials.html']) { for (const html of [
"index.html",
"dashboard.html",
"flow.html",
"logs.html",
"stats.html",
"core.html",
"builds.html",
"credentials.html",
]) {
copyFileSync(src(html), dist(html)); copyFileSync(src(html), dist(html));
} }
console.log('dashboard build ok →', dist('')); console.log("dashboard build ok →", dist(""));

View file

@ -22,9 +22,9 @@
// pure positioning rule which is now generic and lives in `<hive-menu>`'s // pure positioning rule which is now generic and lives in `<hive-menu>`'s
// own shadow-scoped `.menu-dropdown`). // own shadow-scoped `.menu-dropdown`).
import { el } from '@hive/shared/dom.js'; import { el } from "@hive/shared/dom.js";
import { themedConfirm, themedToast } from '@hive/shared/modal.js'; import { themedConfirm, themedToast } from "@hive/shared/modal.js";
import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect import import "@hive/shared/hive-menu.js"; // registers <hive-menu> — side-effect import
// Single-agent POST helper shared by all menu items. `flags` is an object // Single-agent POST helper shared by all menu items. `flags` is an object
// of boolean query params to set truthy (e.g. `{ graceful: true }` or // of boolean query params to set truthy (e.g. `{ graceful: true }` or
@ -34,25 +34,32 @@ import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect imp
async function agentMenuPost(actionPath, name, body, flags) { async function agentMenuPost(actionPath, name, body, flags) {
const params = new URLSearchParams(); const params = new URLSearchParams();
for (const [k, v] of Object.entries(flags || {})) { for (const [k, v] of Object.entries(flags || {})) {
if (v) params.set(k, 'true'); if (v) params.set(k, "true");
} }
const qs = params.toString(); const qs = params.toString();
const url = actionPath + encodeURIComponent(name) + (qs ? '?' + qs : ''); const url = actionPath + encodeURIComponent(name) + (qs ? "?" + qs : "");
try { try {
const resp = await fetch(url, { const resp = await fetch(url, {
method: 'POST', method: "POST",
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}, headers: body
? { "Content-Type": "application/x-www-form-urlencoded" }
: {},
body: body ? new URLSearchParams(body) : undefined, body: body ? new URLSearchParams(body) : undefined,
redirect: 'manual', redirect: "manual",
}); });
const ok = resp.ok || resp.type === 'opaqueredirect' const ok =
|| (resp.status >= 200 && resp.status < 400); resp.ok ||
resp.type === "opaqueredirect" ||
(resp.status >= 200 && resp.status < 400);
if (!ok) { if (!ok) {
const text = await resp.text().catch(() => ''); const text = await resp.text().catch(() => "");
themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' }); themedToast(
"action failed: " + resp.status + (text ? "\n\n" + text : ""),
{ type: "error" },
);
} }
} catch (err) { } catch (err) {
themedToast('action failed: ' + err, { type: 'error' }); themedToast("action failed: " + err, { type: "error" });
} }
} }
@ -67,40 +74,58 @@ class HiveAgentMenu extends HTMLElement {
const { c, forgeBase } = this._opts || {}; const { c, forgeBase } = this._opts || {};
const btn = el('button', { const btn = el(
type: 'button', "button",
class: 'agent-menu-btn', {
title: `actions for ${c.name}`, type: "button",
'aria-label': `actions for ${c.name}`, class: "agent-menu-btn",
'aria-haspopup': 'menu', title: `actions for ${c.name}`,
'aria-expanded': 'false', "aria-label": `actions for ${c.name}`,
}, '⋮'); "aria-haspopup": "menu",
const dropdown = el('ul', { class: 'agent-menu-dropdown', role: 'menu' }); "aria-expanded": "false",
},
"⋮",
);
const dropdown = el("ul", { class: "agent-menu-dropdown", role: "menu" });
const close = () => this._menu.close(); const close = () => this._menu.close();
const menuItem = (label, opts) => { const menuItem = (label, opts) => {
const li = el('li', { role: 'presentation' }); const li = el("li", { role: "presentation" });
const item = el('button', { const item = el(
type: 'button', "button",
class: 'agent-menu-item', {
role: 'menuitem', type: "button",
}, label); class: "agent-menu-item",
item.addEventListener('click', async () => { role: "menuitem",
},
label,
);
item.addEventListener("click", async () => {
close(); close();
let flags = {}; let flags = {};
if (opts.confirm) { if (opts.confirm) {
const checkboxes = []; const checkboxes = [];
if (opts.graceful) { if (opts.graceful) {
checkboxes.push({ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' }); checkboxes.push({
name: "graceful",
label:
opts.gracefulLabel ||
"stop gracefully — let the agent finish its turn and flush state before the container stops",
});
} }
if (opts.paused) { if (opts.paused) {
checkboxes.push({ name: 'paused', label: opts.pausedLabel || 'start paused — come up without driving turns until resumed' }); checkboxes.push({
name: "paused",
label:
opts.pausedLabel ||
"start paused — come up without driving turns until resumed",
});
} }
const r = await themedConfirm({ const r = await themedConfirm({
message: opts.confirm, message: opts.confirm,
danger: true, danger: true,
confirmLabel: opts.confirmLabel || 'confirm', confirmLabel: opts.confirmLabel || "confirm",
checkboxes, checkboxes,
}); });
if (!r) return; if (!r) return;
@ -112,18 +137,23 @@ class HiveAgentMenu extends HTMLElement {
return li; return li;
}; };
const menuSep = () => el('li', { class: 'agent-menu-sep', role: 'separator' }); const menuSep = () =>
el("li", { class: "agent-menu-sep", role: "separator" });
// Navigation link item (opens in same tab by default). // Navigation link item (opens in same tab by default).
const menuLink = (label, href, title) => { const menuLink = (label, href, title) => {
const li = el('li', { role: 'presentation' }); const li = el("li", { role: "presentation" });
const a = el('a', { const a = el(
class: 'agent-menu-item', "a",
href, {
role: 'menuitem', class: "agent-menu-item",
title: title || '', href,
}, label); role: "menuitem",
a.addEventListener('click', close); title: title || "",
},
label,
);
a.addEventListener("click", close);
li.append(a); li.append(a);
return li; return li;
}; };
@ -131,21 +161,28 @@ class HiveAgentMenu extends HTMLElement {
// Show only actions that are applicable in the current state. // Show only actions that are applicable in the current state.
if (c.running) { if (c.running) {
dropdown.append( dropdown.append(
menuItem('↺ R3ST4RT', { menuItem("↺ R3ST4RT", {
action: '/api/restart/', action: "/api/restart/",
confirm: `restart ${c.name}?`, confirm: `restart ${c.name}?`,
graceful: true, graceful: true,
gracefulLabel: 'restart gracefully — let the agent finish its turn and flush state before the container restarts', gracefulLabel:
"restart gracefully — let the agent finish its turn and flush state before the container restarts",
}),
menuItem("■ ST0P", {
action: "/api/kill/",
confirm: `stop ${c.name}?`,
confirmLabel: "■ stop",
graceful: true,
}), }),
menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }),
); );
} else { } else {
dropdown.append( dropdown.append(
menuItem('▶ ST4RT', { menuItem("▶ ST4RT", {
action: '/api/start/', action: "/api/start/",
confirm: `start ${c.name}?`, confirm: `start ${c.name}?`,
paused: true, paused: true,
pausedLabel: 'start paused — come up without driving turns until resumed', pausedLabel:
"start paused — come up without driving turns until resumed",
}), }),
); );
} }
@ -153,55 +190,70 @@ class HiveAgentMenu extends HTMLElement {
// paused; a paused running agent keeps its container but drives no turns. // paused; a paused running agent keeps its container but drives no turns.
if (c.paused) { if (c.paused) {
dropdown.append( dropdown.append(
menuItem('▶ R3SUM3', { action: '/api/resume/', confirm: `resume ${c.name}? the turn loop restarts and drains queued messages.` }), menuItem("▶ R3SUM3", {
action: "/api/resume/",
confirm: `resume ${c.name}? the turn loop restarts and drains queued messages.`,
}),
); );
} else { } else {
dropdown.append( dropdown.append(
menuItem('⏸ P4US3', { action: '/api/pause/', confirm: `pause ${c.name}? parks the turn loop — inbox messages queue unacked.` }), menuItem("⏸ P4US3", {
action: "/api/pause/",
confirm: `pause ${c.name}? parks the turn loop — inbox messages queue unacked.`,
}),
); );
} }
dropdown.append( dropdown.append(
menuSep(), menuSep(),
menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }), menuItem("↻ R3BU1LD", {
action: "/api/rebuild/",
confirm: `rebuild ${c.name}? hot-reloads the container.`,
}),
menuSep(), menuSep(),
// Deep-link to the AGENT log tab pre-filtered to this container. // Deep-link to the AGENT log tab pre-filtered to this container.
// The ?agent= param is read by logs.js on load and pre-selects this // The ?agent= param is read by logs.js on load and pre-selects this
// agent's journal without extra clicks. // agent's journal without extra clicks.
menuLink('journal logs →', menuLink(
"journal logs →",
`/logs.html?agent=${encodeURIComponent(c.name)}#agent`, `/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
`view ${c.name} journal logs`), `view ${c.name} journal logs`,
),
); );
dropdown.append( dropdown.append(
menuSep(), menuSep(),
menuItem('DESTR0Y', { menuItem("DESTR0Y", {
action: '/api/destroy/', action: "/api/destroy/",
confirm: `destroy ${c.name}? container removed; state + creds kept.`, confirm: `destroy ${c.name}? container removed; state + creds kept.`,
}), }),
menuItem('PURG3', { menuItem("PURG3", {
action: '/api/destroy/', action: "/api/destroy/",
body: { purge: 'on' }, body: { purge: "on" },
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`, confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
}), }),
); );
if (c.deployed_sha && forgeBase) { if (c.deployed_sha && forgeBase) {
const li = el('li', { role: 'presentation' }); const li = el("li", { role: "presentation" });
const a = el('a', { const a = el(
class: 'agent-menu-item', "a",
href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`, {
target: '_blank', class: "agent-menu-item",
rel: 'noopener', href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
role: 'menuitem', target: "_blank",
title: 'deployed config commit on forge', rel: "noopener",
}, `deployed:${c.deployed_sha}`); role: "menuitem",
title: "deployed config commit on forge",
},
`deployed:${c.deployed_sha}`,
);
li.append(a); li.append(a);
dropdown.append(menuSep(), li); dropdown.append(menuSep(), li);
} }
this._menu = document.createElement('hive-menu'); this._menu = document.createElement("hive-menu");
this._menu._opts = { trigger: btn, content: dropdown }; this._menu._opts = { trigger: btn, content: dropdown };
this.append(this._menu); this.append(this._menu);
} }
} }
customElements.define('hive-agent-menu', HiveAgentMenu); customElements.define("hive-agent-menu", HiveAgentMenu);

View file

@ -24,4 +24,6 @@ body.builds-shell {
padding: 1.2em 1.5em 2em; padding: 1.2em 1.5em 2em;
} }
.builds-pane[hidden] { display: none; } .builds-pane[hidden] {
display: none;
}

View file

@ -1,73 +1,103 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // BU1LDS</title> <title>hyperhive // BU1LDS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css"> <link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css"> <link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/builds.css"> <link rel="stylesheet" href="/static/builds.css" />
</head> </head>
<body class="builds-shell"> <body class="builds-shell">
<!-- BU1LDS: the build lifecycle hub — rebuild queue, live build log,
<!-- BU1LDS: the build lifecycle hub — rebuild queue, live build log,
meta inputs, and build log history on one page. Carved out of the meta inputs, and build log history on one page. Carved out of the
old /core.html (rebuild queue + meta inputs) and /logs.html (BUILD old /core.html (rebuild queue + meta inputs) and /logs.html (BUILD
tab) so the full build lifecycle is in one place. Same minimal tab) so the full build lifecycle is in one place. Same minimal
chrome as /core.html — a `← home` back-link + a <hive-tab-strip> chrome as /core.html — a `← home` back-link + a <hive-tab-strip>
sub-tab nav. Default sub-tab is the rebuild queue. --> sub-tab nav. Default sub-tab is the rebuild queue. -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← home</a>
<hive-tab-strip class="hive-tabbar builds-tabbar" id="builds-tabbar" prefix="builds" <hive-tab-strip
role="tablist"></hive-tab-strip> class="hive-tabbar builds-tabbar"
</header> id="builds-tabbar"
prefix="builds"
role="tablist"
></hive-tab-strip>
</header>
<main class="builds-main"> <main class="builds-main">
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
first-spawns. Rendered from GET /api/jobq/graph by the first-spawns. Rendered from GET /api/jobq/graph by the
`JobqGraph` Preact component (@hive/shared/jobq-graph.js); `JobqGraph` Preact component (@hive/shared/jobq-graph.js);
`rebuild_queue_changed` over /api/dashboard/stream is the `rebuild_queue_changed` over /api/dashboard/stream is the
refresh trigger and carries no payload of its own. Default tab. --> refresh trigger and carries no payload of its own. Default tab. -->
<section class="builds-pane" id="builds-pane-rebuild" data-tab-pane="rebuild" <section
role="tabpanel" aria-labelledby="builds-tab-rebuild"> class="builds-pane"
<p class="meta">pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.</p> id="builds-pane-rebuild"
<div id="rebuild-queue-section"> data-tab-pane="rebuild"
<p class="meta">loading…</p> role="tabpanel"
</div> aria-labelledby="builds-tab-rebuild"
<!-- Live build log of the currently-running rebuild (one runs at a >
<p class="meta">
pending + running rebuilds, meta-updates, and first-spawns. one runs
at a time; meta-update cascades nest under their parent. dedup:
re-enqueueing a still-queued op collapses into the existing entry.
</p>
<div id="rebuild-queue-section">
<p class="meta">loading…</p>
</div>
<!-- Live build log of the currently-running rebuild (one runs at a
time). Managed by renderRebuildLiveLog in builds.js, separate from time). Managed by renderRebuildLiveLog in builds.js, separate from
rebuild-queue-section so the queue's row re-render never disturbs rebuild-queue-section so the queue's row re-render never disturbs
the open SSE stream. --> the open SSE stream. -->
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div> <div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
</section> </section>
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. --> <!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
<section class="builds-pane" id="builds-pane-meta" data-tab-pane="meta" <section
role="tabpanel" aria-labelledby="builds-tab-meta"> class="builds-pane"
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; the submitting agent learns each outcome via the usual <code>rebuilt</code> system event.</p> id="builds-pane-meta"
<div id="meta-inputs-section"> data-tab-pane="meta"
<p class="meta">loading…</p> role="tabpanel"
</div> aria-labelledby="builds-tab-meta"
</section> >
<p class="meta">
select inputs to <code>nix flake update</code> in <code>/meta/</code>.
selected agents rebuild in sequence after the lock bump; the
submitting agent learns each outcome via the usual
<code>rebuilt</code> system event.
</p>
<div id="meta-inputs-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- BUILD L0GS: all-agents build log history (moved from /logs.html). <!-- BUILD L0GS: all-agents build log history (moved from /logs.html).
Click a row to expand stdout + stderr. Live builds stream in real Click a row to expand stdout + stderr. Live builds stream in real
time. Lazy-loaded on first tab activation; auto-refreshes when time. Lazy-loaded on first tab activation; auto-refreshes when
rebuild_queue_changed fires. Deep-link: ?id=N#buildlogs. --> rebuild_queue_changed fires. Deep-link: ?id=N#buildlogs. -->
<section class="builds-pane" id="builds-pane-buildlogs" data-tab-pane="buildlogs" <section
role="tabpanel" aria-labelledby="builds-tab-buildlogs"> class="builds-pane"
<p class="meta">all-agents build log history. click a row to expand stdout + stderr. live builds stream in real time.</p> id="builds-pane-buildlogs"
<div class="logs-toolbar"> data-tab-pane="buildlogs"
<button type="button" class="btn btn-restart" id="build-refresh">↻ refresh</button> role="tabpanel"
</div> aria-labelledby="builds-tab-buildlogs"
<div id="build-list"><p class="meta">loading…</p></div> >
</section> <p class="meta">
all-agents build log history. click a row to expand stdout + stderr.
live builds stream in real time.
</p>
<div class="logs-toolbar">
<button type="button" class="btn btn-restart" id="build-refresh">
↻ refresh
</button>
</div>
<div id="build-list"><p class="meta">loading…</p></div>
</section>
</main>
</main> <script type="module" src="/static/builds.js" defer></script>
</body>
<script type="module" src="/static/builds.js" defer></script>
</body>
</html> </html>

View file

@ -10,14 +10,20 @@
// renderers here are direct copies from core.js / logs.js with only the // renderers here are direct copies from core.js / logs.js with only the
// deep-link URL and count-pill id adjusted. // deep-link URL and count-pill id adjusted.
import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } from './common.js'; import {
import { el } from '@hive/shared/dom.js'; $,
import { bindAsyncForms } from '@hive/shared/forms.js'; fmtAgeSecs,
import { themedConfirm } from '@hive/shared/modal.js'; openStream,
import { h, render } from 'preact'; openBuildLogStream,
import { fmtAgo, fmtDuration, truncate } from './util.js'; initServerWarnings,
import '@hive/shared/hive-tab-strip.js'; } from "./common.js";
import { JobqGraph } from '@hive/shared/jobq-graph.js'; import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
import { themedConfirm } from "@hive/shared/modal.js";
import { h, render } from "preact";
import { fmtAgo, fmtDuration, truncate } from "./util.js";
import "@hive/shared/hive-tab-strip.js";
import { JobqGraph } from "@hive/shared/jobq-graph.js";
// ─── derived state ─────────────────────────────────────────────────────────── // ─── derived state ───────────────────────────────────────────────────────────
let metaInputsState = []; let metaInputsState = [];
@ -36,93 +42,122 @@ function syncFromSnapshot(s) {
// ─── meta inputs ───────────────────────────────────────────────────────────── // ─── meta inputs ─────────────────────────────────────────────────────────────
function renderMetaInputs(s) { function renderMetaInputs(s) {
const root = $('meta-inputs-section'); const root = $("meta-inputs-section");
if (!root) return; if (!root) return;
// Snapshot ticked checkboxes before wiping so a concurrent // Snapshot ticked checkboxes before wiping so a concurrent
// MetaInputsChanged doesn't silently clear a pending selection. // MetaInputsChanged doesn't silently clear a pending selection.
const checkedInputs = new Set( const checkedInputs = new Set(
Array.from(root.querySelectorAll('input[type="checkbox"][data-meta-input]:checked')) Array.from(
.map((cb) => cb.dataset.metaInput), root.querySelectorAll('input[type="checkbox"][data-meta-input]:checked'),
).map((cb) => cb.dataset.metaInput),
); );
root.replaceChildren(); root.replaceChildren();
const inputs = s.meta_inputs || []; const inputs = s.meta_inputs || [];
if (!inputs.length) { if (!inputs.length) {
root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet')); root.append(el("p", { class: "empty" }, "meta repo not seeded yet"));
return; return;
} }
if (metaUpdateRunning) { if (metaUpdateRunning) {
root.append(el('p', { class: 'meta-update-running' }, root.append(
'⏳ meta-update running — flake lock bump + affected agents rebuilding. ' el(
+ 'watch the agent cards for per-rebuild progress.')); "p",
{ class: "meta-update-running" },
"⏳ meta-update running — flake lock bump + affected agents rebuilding. " +
"watch the agent cards for per-rebuild progress.",
),
);
} }
const f = el('form', { const f = el("form", {
method: 'POST', method: "POST",
action: '/api/meta-update', action: "/api/meta-update",
class: 'meta-inputs-form', class: "meta-inputs-form",
'data-async': '', "data-async": "",
'data-no-refresh': '', "data-no-refresh": "",
'data-confirm': 'update selected meta flake inputs + rebuild affected agents?', "data-confirm":
"update selected meta flake inputs + rebuild affected agents?",
}); });
const bulk = el('div', { class: 'meta-inputs-bulk' }); const bulk = el("div", { class: "meta-inputs-bulk" });
const selAll = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select all'); const selAll = el(
const selNone = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select none'); "button",
bulk.append('bulk: ', selAll, ' ', selNone); { type: "button", class: "meta-bulk-btn" },
"select all",
);
const selNone = el(
"button",
{ type: "button", class: "meta-bulk-btn" },
"select none",
);
bulk.append("bulk: ", selAll, " ", selNone);
f.append(bulk); f.append(bulk);
const ul = el('ul', { class: 'meta-inputs' }); const ul = el("ul", { class: "meta-inputs" });
for (const inp of inputs) { for (const inp of inputs) {
const depth = (inp.name.match(/\//g) || []).length; const depth = (inp.name.match(/\//g) || []).length;
const leaf = inp.name.slice(inp.name.lastIndexOf('/') + 1); const leaf = inp.name.slice(inp.name.lastIndexOf("/") + 1);
const li = el('li'); const li = el("li");
if (depth > 0) li.style.marginLeft = (depth * 1.3) + 'em'; if (depth > 0) li.style.marginLeft = depth * 1.3 + "em";
const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_'); const id = "meta-input-" + inp.name.replace(/[^a-z0-9-]/gi, "_");
const cb = el('input', { const cb = el("input", {
type: 'checkbox', type: "checkbox",
name: 'meta_input_' + inp.name, name: "meta_input_" + inp.name,
id, id,
value: inp.name, value: inp.name,
'data-meta-input': inp.name, "data-meta-input": inp.name,
}); });
if (checkedInputs.has(inp.name)) cb.checked = true; if (checkedInputs.has(inp.name)) cb.checked = true;
const label = el('label', { for: id, title: inp.name }); const label = el("label", { for: id, title: inp.name });
label.append(cb); label.append(cb);
if (depth > 0) label.append(el('span', { class: 'meta-input-twig' }, '└ ')); if (depth > 0) label.append(el("span", { class: "meta-input-twig" }, "└ "));
label.append( label.append(
el('span', { class: 'meta-input-name' }, leaf), ' ', el("span", { class: "meta-input-name" }, leaf),
el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ', " ",
el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)), el("code", { class: "meta-input-rev" }, inp.rev.slice(0, 12)),
" ",
el("span", { class: "meta-input-ts" }, fmtAgo(inp.last_modified)),
); );
if (inp.url) { if (inp.url) {
label.append(' ', el('span', { class: 'meta-input-url', title: inp.url }, label.append(
'· ' + truncate(inp.url, 48))); " ",
el(
"span",
{ class: "meta-input-url", title: inp.url },
"· " + truncate(inp.url, 48),
),
);
} }
li.append(label); li.append(label);
ul.append(li); ul.append(li);
} }
f.append(ul); f.append(ul);
const hidden = el('input', { type: 'hidden', name: 'inputs', value: '' }); const hidden = el("input", { type: "hidden", name: "inputs", value: "" });
f.append(hidden); f.append(hidden);
const btn = el('button', { const btn = el(
type: 'submit', "button",
class: 'btn btn-meta-update', {
disabled: '', type: "submit",
}, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD'); class: "btn btn-meta-update",
disabled: "",
},
metaUpdateRunning ? "⏳ UPD4T1NG…" : "◆ UPD4TE & R3BU1LD",
);
f.append(btn); f.append(btn);
function refreshDisabled() { function refreshDisabled() {
const any = f.querySelectorAll('input[data-meta-input]:checked').length > 0; const any = f.querySelectorAll("input[data-meta-input]:checked").length > 0;
if (any && !metaUpdateRunning) btn.removeAttribute('disabled'); if (any && !metaUpdateRunning) btn.removeAttribute("disabled");
else btn.setAttribute('disabled', ''); else btn.setAttribute("disabled", "");
} }
f.addEventListener('change', refreshDisabled); f.addEventListener("change", refreshDisabled);
function setAllChecked(val) { function setAllChecked(val) {
for (const b of f.querySelectorAll('input[data-meta-input]')) b.checked = val; for (const b of f.querySelectorAll("input[data-meta-input]"))
b.checked = val;
refreshDisabled(); refreshDisabled();
} }
selAll.addEventListener('click', () => setAllChecked(true)); selAll.addEventListener("click", () => setAllChecked(true));
selNone.addEventListener('click', () => setAllChecked(false)); selNone.addEventListener("click", () => setAllChecked(false));
f.addEventListener('submit', () => { f.addEventListener("submit", () => {
const selected = Array.from(f.querySelectorAll('input[data-meta-input]:checked')) const selected = Array.from(
.map((b) => b.dataset.metaInput); f.querySelectorAll("input[data-meta-input]:checked"),
hidden.value = selected.join(','); ).map((b) => b.dataset.metaInput);
hidden.value = selected.join(",");
}); });
root.append(f); root.append(f);
} }
@ -150,41 +185,50 @@ function renderMetaInputs(s) {
// tracked children out from under its diffing rather than let it // tracked children out from under its diffing rather than let it
// update them minimally). // update them minimally).
function mountRebuildQueue() { function mountRebuildQueue() {
const root = $('rebuild-queue-section'); const root = $("rebuild-queue-section");
if (!root) return; if (!root) return;
root.replaceChildren(); root.replaceChildren();
renderRebuildQueue(); renderRebuildQueue();
} }
function renderRebuildQueue() { function renderRebuildQueue() {
const root = $('rebuild-queue-section'); const root = $("rebuild-queue-section");
if (!root) return; if (!root) return;
render(h(JobqGraph, { render(
endpoint: '/api/jobq/graph', h(JobqGraph, {
cancellable: true, endpoint: "/api/jobq/graph",
refreshToken: jobqGraphToken, cancellable: true,
onUpdate: (nodes) => { refreshToken: jobqGraphToken,
jobqNodes = nodes || []; onUpdate: (nodes) => {
renderRebuildLiveLog(); jobqNodes = nodes || [];
updateRebuildCount(); renderRebuildLiveLog();
}, updateRebuildCount();
onCancel: async (id) => { },
const node = jobqNodes.find((n) => n.id === id); onCancel: async (id) => {
const label = node ? node.payload.label : 'node ' + id; const node = jobqNodes.find((n) => n.id === id);
if (!(await themedConfirm({ const label = node ? node.payload.label : "node " + id;
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`, if (
danger: true, confirmLabel: '✕ cancel', !(await themedConfirm({
}))) return; message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
try { danger: true,
const r = await fetch('/api/rebuild-queue/' + id + '/cancel', { method: 'POST' }); confirmLabel: "✕ cancel",
if (!r.ok) throw new Error('http ' + r.status); }))
// No manual refresh: cancel flips node state, which fires )
// rebuild_queue_changed over SSE — the existing handler below return;
// already bumps jobqGraphToken and re-renders on that tick. try {
} catch (err) { const r = await fetch("/api/rebuild-queue/" + id + "/cancel", {
console.error('cancel failed', err); method: "POST",
} });
}, if (!r.ok) throw new Error("http " + r.status);
}), root); // No manual refresh: cancel flips node state, which fires
// rebuild_queue_changed over SSE — the existing handler below
// already bumps jobqGraphToken and re-renders on that tick.
} catch (err) {
console.error("cancel failed", err);
}
},
}),
root,
);
} }
// ─── running-rebuild live log ───────────────────────────────────────────────── // ─── running-rebuild live log ─────────────────────────────────────────────────
@ -196,13 +240,16 @@ function renderRebuildQueue() {
// {stdout, stderr}) rather than SSE — a running node's log re-fetches on the // {stdout, stderr}) rather than SSE — a running node's log re-fetches on the
// rebuild_queue_changed tick; a terminal node's log is static (one last fetch // rebuild_queue_changed tick; a terminal node's log is static (one last fetch
// on transition, then done). // on transition, then done).
let liveLogId = null; // current node id being shown let liveLogId = null; // current node id being shown
let liveLogDone = false; // true once the node left 'Running' let liveLogDone = false; // true once the node left 'Running'
let liveLogCollapsed = false; let liveLogCollapsed = false;
let liveLogPollTimer = null; let liveLogPollTimer = null;
function clearLiveLogPoll() { function clearLiveLogPoll() {
if (liveLogPollTimer) { clearInterval(liveLogPollTimer); liveLogPollTimer = null; } if (liveLogPollTimer) {
clearInterval(liveLogPollTimer);
liveLogPollTimer = null;
}
} }
// First running node with a log, in wire order (root-then-subtree per // First running node with a log, in wire order (root-then-subtree per
@ -210,26 +257,39 @@ function clearLiveLogPoll() {
// Gate on build_log_id so lock/noop/store-only nodes don't open a blank // Gate on build_log_id so lock/noop/store-only nodes don't open a blank
// panel. // panel.
function findLiveBuild() { function findLiveBuild() {
return jobqNodes.find((n) => n.state === 'Running' && n.payload.data && n.payload.data.build_log_id != null) return (
|| null; jobqNodes.find(
(n) =>
n.state === "Running" &&
n.payload.data &&
n.payload.data.build_log_id != null,
) || null
);
} }
async function fetchAndRenderLiveLog(nodeId, pre) { async function fetchAndRenderLiveLog(nodeId, pre) {
try { try {
const r = await fetch('/api/build-log/' + nodeId); const r = await fetch("/api/build-log/" + nodeId);
if (!r.ok) return; if (!r.ok) return;
const data = await r.json(); const data = await r.json();
const text = [data.stdout, data.stderr ? '--- stderr ---\n' + data.stderr : ''].filter(Boolean).join('\n'); const text = [
data.stdout,
data.stderr ? "--- stderr ---\n" + data.stderr : "",
]
.filter(Boolean)
.join("\n");
if (pre.textContent !== text) { if (pre.textContent !== text) {
const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40; const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
pre.textContent = text; pre.textContent = text;
if (atBottom) pre.scrollTop = pre.scrollHeight; if (atBottom) pre.scrollTop = pre.scrollHeight;
} }
} catch { /* network blip — ignore, next poll will retry */ } } catch {
/* network blip — ignore, next poll will retry */
}
} }
function renderRebuildLiveLog() { function renderRebuildLiveLog() {
const root = $('rebuild-live-log'); const root = $("rebuild-live-log");
if (!root) return; if (!root) return;
const liveNode = findLiveBuild(); const liveNode = findLiveBuild();
@ -237,22 +297,26 @@ function renderRebuildLiveLog() {
clearLiveLogPoll(); clearLiveLogPoll();
liveLogId = null; liveLogId = null;
liveLogDone = false; liveLogDone = false;
if (!root.hidden) { root.hidden = true; root.replaceChildren(); } if (!root.hidden) {
root.hidden = true;
root.replaceChildren();
}
return; return;
} }
// Same node, already polling — just let the timer tick (or do a final // Same node, already polling — just let the timer tick (or do a final
// fetch if the node just went non-running and we haven't marked done yet). // fetch if the node just went non-running and we haven't marked done yet).
if (liveNode.id === liveLogId) { if (liveNode.id === liveLogId) {
if (!liveLogDone && liveNode.state !== 'Running') { if (!liveLogDone && liveNode.state !== "Running") {
clearLiveLogPoll(); clearLiveLogPoll();
liveLogDone = true; liveLogDone = true;
const pre = root.querySelector('.rebuild-live-log-output'); const pre = root.querySelector(".rebuild-live-log-output");
const badge = root.querySelector('.rebuild-live-log-badge'); const badge = root.querySelector(".rebuild-live-log-badge");
if (pre) fetchAndRenderLiveLog(liveNode.id, pre); if (pre) fetchAndRenderLiveLog(liveNode.id, pre);
if (badge) { if (badge) {
const ok = liveNode.state !== 'Failed'; const ok = liveNode.state !== "Failed";
badge.className = 'rebuild-live-log-badge ' + (ok ? 'rll-ok' : 'rll-fail'); badge.className =
"rebuild-live-log-badge " + (ok ? "rll-ok" : "rll-fail");
badge.textContent = liveNode.state; badge.textContent = liveNode.state;
} }
} }
@ -266,54 +330,84 @@ function renderRebuildLiveLog() {
root.hidden = false; root.hidden = false;
root.replaceChildren(); root.replaceChildren();
const pre = el('pre', { class: 'rebuild-live-log-output' }, ''); const pre = el("pre", { class: "rebuild-live-log-output" }, "");
pre.hidden = liveLogCollapsed; pre.hidden = liveLogCollapsed;
const badge = el('span', { class: 'rebuild-live-log-badge rll-running' }, 'live'); const badge = el(
const toggle = el('button', { "span",
type: 'button', { class: "rebuild-live-log-badge rll-running" },
class: 'rebuild-live-log-toggle', "live",
'aria-expanded': String(!liveLogCollapsed), );
title: liveLogCollapsed ? 'expand live log' : 'collapse live log', const toggle = el(
}, liveLogCollapsed ? '▸' : '▾'); "button",
toggle.addEventListener('click', () => { {
type: "button",
class: "rebuild-live-log-toggle",
"aria-expanded": String(!liveLogCollapsed),
title: liveLogCollapsed ? "expand live log" : "collapse live log",
},
liveLogCollapsed ? "▸" : "▾",
);
toggle.addEventListener("click", () => {
liveLogCollapsed = !liveLogCollapsed; liveLogCollapsed = !liveLogCollapsed;
pre.hidden = liveLogCollapsed; pre.hidden = liveLogCollapsed;
toggle.textContent = liveLogCollapsed ? '▸' : '▾'; toggle.textContent = liveLogCollapsed ? "▸" : "▾";
toggle.setAttribute('aria-expanded', String(!liveLogCollapsed)); toggle.setAttribute("aria-expanded", String(!liveLogCollapsed));
toggle.title = liveLogCollapsed ? 'expand live log' : 'collapse live log'; toggle.title = liveLogCollapsed ? "expand live log" : "collapse live log";
}); });
const liveAgent = liveNode.payload.data && liveNode.payload.data.agent; const liveAgent = liveNode.payload.data && liveNode.payload.data.agent;
const header = el('div', { class: 'rebuild-live-log-header' }, const header = el(
toggle, ' ', "div",
el('span', { class: 'rebuild-live-log-title' }, 'live build log — '), { class: "rebuild-live-log-header" },
toggle,
" ",
el("span", { class: "rebuild-live-log-title" }, "live build log — "),
// Label the specific node's agent, not the whole group's agent set. // Label the specific node's agent, not the whole group's agent set.
el('code', { class: 'rqe-agent' }, liveAgent || ''), el("code", { class: "rqe-agent" }, liveAgent || ""),
' ', el('span', { class: 'rqe-kind' }, liveNode.payload.label), " ",
' ', badge, ' ', el("span", { class: "rqe-kind" }, liveNode.payload.label),
el('a', { " ",
class: 'rebuild-live-log-raw', badge,
href: '/api/build-log/' + liveNode.id + '/raw', " ",
download: 'build-log-' + liveNode.id + '.txt', el(
}, '↓ raw'), "a",
{
class: "rebuild-live-log-raw",
href: "/api/build-log/" + liveNode.id + "/raw",
download: "build-log-" + liveNode.id + ".txt",
},
"↓ raw",
),
); );
root.append(header, pre); root.append(header, pre);
// Start polling. // Start polling.
fetchAndRenderLiveLog(liveNode.id, pre); fetchAndRenderLiveLog(liveNode.id, pre);
liveLogPollTimer = setInterval(() => fetchAndRenderLiveLog(liveNode.id, pre), 2000); liveLogPollTimer = setInterval(
() => fetchAndRenderLiveLog(liveNode.id, pre),
2000,
);
} }
// ─── rebuild-queue count pill ───────────────────────────────────────────────── // ─── rebuild-queue count pill ─────────────────────────────────────────────────
function updateRebuildCount() { function updateRebuildCount() {
const pill = $('builds-tab-count-rebuild'); const pill = $("builds-tab-count-rebuild");
if (!pill) return; if (!pill) return;
// Pending/Running/Finishing = in flight (Finishing = own work done, a // Pending/Running/Finishing = in flight (Finishing = own work done, a
// sub-node still running — still counts). Root nodes only: each is one // sub-node still running — still counts). Root nodes only: each is one
// queue entry. // queue entry.
const n = jobqNodes.filter((n) => n.parent == null const n = jobqNodes.filter(
&& (n.state === 'Pending' || n.state === 'Running' || n.state === 'Finishing')).length; (n) =>
if (n > 0) { pill.textContent = String(n); pill.hidden = false; } n.parent == null &&
else { pill.hidden = true; } (n.state === "Pending" ||
n.state === "Running" ||
n.state === "Finishing"),
).length;
if (n > 0) {
pill.textContent = String(n);
pill.hidden = false;
} else {
pill.hidden = true;
}
} }
// ─── render-all (cold load + any full re-render) ────────────────────────────── // ─── render-all (cold load + any full re-render) ──────────────────────────────
@ -330,43 +424,63 @@ function renderAll() {
// 2s) when rebuild_queue_changed fires while the BUILD L0GS tab is active. // 2s) when rebuild_queue_changed fires while the BUILD L0GS tab is active.
// Deep-link: ?id=N#buildlogs auto-expands the target row. // Deep-link: ?id=N#buildlogs auto-expands the target row.
const buildList = $('build-list'); const buildList = $("build-list");
const buildRefresh = $('build-refresh'); const buildRefresh = $("build-refresh");
let buildLogsLoaded = false; let buildLogsLoaded = false;
let buildTabs; // set in init let buildTabs; // set in init
function fmtTs(unixSecs) { function fmtTs(unixSecs) {
if (!unixSecs) return ''; if (!unixSecs) return "";
const age = Math.floor(Date.now() / 1000) - unixSecs; const age = Math.floor(Date.now() / 1000) - unixSecs;
return fmtAgeSecs(Math.max(0, age)) + ' ago'; return fmtAgeSecs(Math.max(0, age)) + " ago";
} }
async function fetchBuild() { async function fetchBuild() {
if (!buildList) return; if (!buildList) return;
buildList.replaceChildren(); buildList.replaceChildren();
buildList.append(el('p', { class: 'meta' }, 'loading…')); buildList.append(el("p", { class: "meta" }, "loading…"));
try { try {
const resp = await fetch('/api/build-logs?limit=30'); const resp = await fetch("/api/build-logs?limit=30");
if (!resp.ok) throw new Error('http ' + resp.status); if (!resp.ok) throw new Error("http " + resp.status);
const rows = await resp.json(); const rows = await resp.json();
buildList.replaceChildren(); buildList.replaceChildren();
if (!rows || rows.length === 0) { if (!rows || rows.length === 0) {
buildList.append(el('p', { class: 'meta' }, '(no build logs yet)')); buildList.append(el("p", { class: "meta" }, "(no build logs yet)"));
return; return;
} }
const ul = el('ul', { class: 'build-logs-list' }); const ul = el("ul", { class: "build-logs-list" });
for (const h of rows) { for (const h of rows) {
const li = el('li', { class: 'build-logs-item', 'data-log-id': String(h.id) }); const li = el("li", {
class: "build-logs-item",
"data-log-id": String(h.id),
});
const live = !h.status; const live = !h.status;
const ok = h.status === 'ok'; const ok = h.status === "ok";
const statusClass = live ? 'hive-pill-sm badge-running' : ok ? 'hive-pill-sm badge-ok' : 'hive-pill-sm badge-fail'; const statusClass = live
const statusLabel = live ? 'live' : ok ? 'ok' : 'fail'; ? "hive-pill-sm badge-running"
const age = h.finished_at ? fmtTs(h.finished_at) : (live ? '' : fmtTs(h.started_at)); : ok
const runtime = h.runtime_secs != null ? "hive-pill-sm badge-ok"
? el('span', { class: 'build-logs-runtime meta' }, fmtDuration(Math.max(0, h.runtime_secs))) : "hive-pill-sm badge-fail";
const statusLabel = live ? "live" : ok ? "ok" : "fail";
const age = h.finished_at
? fmtTs(h.finished_at)
: live : live
? el('span', { class: 'build-logs-runtime meta build-logs-live-dur' }, '…') ? ""
: el('span', { class: 'build-logs-runtime meta' }, ''); : fmtTs(h.started_at);
const runtime =
h.runtime_secs != null
? el(
"span",
{ class: "build-logs-runtime meta" },
fmtDuration(Math.max(0, h.runtime_secs)),
)
: live
? el(
"span",
{ class: "build-logs-runtime meta build-logs-live-dur" },
"…",
)
: el("span", { class: "build-logs-runtime meta" }, "");
let durTimer = null; let durTimer = null;
if (live && h.started_at) { if (live && h.started_at) {
@ -378,78 +492,106 @@ async function fetchBuild() {
durTimer = setInterval(updateDur, 1000); durTimer = setInterval(updateDur, 1000);
} }
const rowBtn = el('button', { const rowBtn = el(
type: 'button', "button",
class: 'build-logs-row-btn', {
'aria-expanded': 'false', type: "button",
}, class: "build-logs-row-btn",
el('span', { class: statusClass }, statusLabel), "aria-expanded": "false",
el('span', { class: 'build-logs-agent' }, h.agent), },
el("span", { class: statusClass }, statusLabel),
el("span", { class: "build-logs-agent" }, h.agent),
runtime, runtime,
el('span', { class: 'build-logs-kind' }, h.kind), el("span", { class: "build-logs-kind" }, h.kind),
el('span', { class: 'build-logs-age meta' }, age), el("span", { class: "build-logs-age meta" }, age),
el('span', { class: 'build-logs-cmdline meta' }, h.cmdline), el("span", { class: "build-logs-cmdline meta" }, h.cmdline),
); );
const detail = el('div', { class: 'build-logs-detail' }); const detail = el("div", { class: "build-logs-detail" });
detail.hidden = true; detail.hidden = true;
let streamEs = null; let streamEs = null;
rowBtn.addEventListener('click', async () => { rowBtn.addEventListener("click", async () => {
const expanded = rowBtn.getAttribute('aria-expanded') === 'true'; const expanded = rowBtn.getAttribute("aria-expanded") === "true";
rowBtn.setAttribute('aria-expanded', String(!expanded)); rowBtn.setAttribute("aria-expanded", String(!expanded));
detail.hidden = expanded; detail.hidden = expanded;
if (expanded) { if (expanded) {
if (streamEs) { streamEs.close(); streamEs = null; } if (streamEs) {
streamEs.close();
streamEs = null;
}
return; return;
} }
if (detail.dataset.loaded) return; if (detail.dataset.loaded) return;
detail.replaceChildren(); detail.replaceChildren();
detail.append( detail.append(
el('a', { el(
href: '/api/build-logs/id/' + h.id + '/raw', "a",
download: 'build-log-' + h.id + '.txt', {
class: 'build-logs-dl', href: "/api/build-logs/id/" + h.id + "/raw",
}, '↓ download raw'), download: "build-log-" + h.id + ".txt",
class: "build-logs-dl",
},
"↓ download raw",
),
); );
if (live) { if (live) {
const pre = el('pre', { class: 'build-logs-output build-logs-live' }, ''); const pre = el(
const badge = el('span', { class: 'build-logs-live-badge hive-pill-sm badge-running' }, 'live'); "pre",
{ class: "build-logs-output build-logs-live" },
"",
);
const badge = el(
"span",
{ class: "build-logs-live-badge hive-pill-sm badge-running" },
"live",
);
detail.append(badge, pre); detail.append(badge, pre);
streamEs = openBuildLogStream(h.id, pre, { streamEs = openBuildLogStream(h.id, pre, {
onDone: (status) => { onDone: (status) => {
if (durTimer) { clearInterval(durTimer); durTimer = null; } if (durTimer) {
clearInterval(durTimer);
durTimer = null;
}
if (h.started_at) { if (h.started_at) {
const elapsed = Math.floor(Date.now() / 1000) - h.started_at; const elapsed = Math.floor(Date.now() / 1000) - h.started_at;
runtime.textContent = fmtDuration(Math.max(0, elapsed)); runtime.textContent = fmtDuration(Math.max(0, elapsed));
} }
badge.className = status === 'ok' ? 'hive-pill-sm badge-ok' : 'hive-pill-sm badge-fail'; badge.className =
status === "ok"
? "hive-pill-sm badge-ok"
: "hive-pill-sm badge-fail";
badge.textContent = status; badge.textContent = status;
streamEs = null; streamEs = null;
detail.dataset.loaded = '1'; detail.dataset.loaded = "1";
}, },
onError: () => { onError: () => {
if (durTimer) { clearInterval(durTimer); durTimer = null; } if (durTimer) {
badge.textContent = 'stream error'; clearInterval(durTimer);
badge.className = 'hive-pill-sm badge-fail'; durTimer = null;
}
badge.textContent = "stream error";
badge.className = "hive-pill-sm badge-fail";
streamEs = null; streamEs = null;
}, },
}); });
} else { } else {
const pre = el('pre', { class: 'build-logs-output' }, 'fetching…'); const pre = el("pre", { class: "build-logs-output" }, "fetching…");
detail.append(pre); detail.append(pre);
try { try {
const r2 = await fetch('/api/build-logs/id/' + h.id); const r2 = await fetch("/api/build-logs/id/" + h.id);
if (!r2.ok) { if (!r2.ok) {
pre.textContent = 'error ' + r2.status; pre.textContent = "error " + r2.status;
} else { } else {
const full = await r2.json(); const full = await r2.json();
const out = [full.stdout, full.stderr].filter(Boolean).join('\n--- stderr ---\n'); const out = [full.stdout, full.stderr]
pre.textContent = out || '(empty)'; .filter(Boolean)
.join("\n--- stderr ---\n");
pre.textContent = out || "(empty)";
} }
detail.dataset.loaded = '1'; detail.dataset.loaded = "1";
} catch (err) { } catch (err) {
pre.textContent = 'fetch failed: ' + err; pre.textContent = "fetch failed: " + err;
} }
} }
}); });
@ -458,24 +600,24 @@ async function fetchBuild() {
} }
buildList.append(ul); buildList.append(ul);
// Deep-link: ?id=N auto-expands the target row. // Deep-link: ?id=N auto-expands the target row.
const deepId = new URLSearchParams(location.search).get('id'); const deepId = new URLSearchParams(location.search).get("id");
if (deepId) { if (deepId) {
const target = ul.querySelector('[data-log-id="' + deepId + '"]'); const target = ul.querySelector('[data-log-id="' + deepId + '"]');
if (target) { if (target) {
const btn = target.querySelector('.build-logs-row-btn'); const btn = target.querySelector(".build-logs-row-btn");
if (btn) { if (btn) {
btn.click(); btn.click();
target.scrollIntoView({ behavior: 'smooth', block: 'start' }); target.scrollIntoView({ behavior: "smooth", block: "start" });
} }
} }
} }
} catch (err) { } catch (err) {
buildList.replaceChildren(); buildList.replaceChildren();
buildList.append(el('p', { class: 'meta' }, 'fetch failed: ' + err)); buildList.append(el("p", { class: "meta" }, "fetch failed: " + err));
} }
} }
if (buildRefresh) buildRefresh.addEventListener('click', fetchBuild); if (buildRefresh) buildRefresh.addEventListener("click", fetchBuild);
// ─── SSE handlers ───────────────────────────────────────────────────────────── // ─── SSE handlers ─────────────────────────────────────────────────────────────
let buildRefreshTimer = null; let buildRefreshTimer = null;
@ -488,7 +630,7 @@ const SSE_HANDLERS = {
jobqGraphToken += 1; jobqGraphToken += 1;
renderRebuildQueue(); renderRebuildQueue();
// Auto-refresh build log list when the queue changes and BUILD L0GS is active. // Auto-refresh build log list when the queue changes and BUILD L0GS is active.
if (buildTabs && buildTabs.active() === 'buildlogs') { if (buildTabs && buildTabs.active() === "buildlogs") {
if (buildRefreshTimer) clearTimeout(buildRefreshTimer); if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
buildRefreshTimer = setTimeout(fetchBuild, 2000); buildRefreshTimer = setTimeout(fetchBuild, 2000);
} }
@ -506,7 +648,7 @@ const SSE_HANDLERS = {
// ─── boot ───────────────────────────────────────────────────────────────────── // ─── boot ─────────────────────────────────────────────────────────────────────
async function refreshState() { async function refreshState() {
try { try {
const resp = await fetch('/api/state'); const resp = await fetch("/api/state");
if (resp.ok) syncFromSnapshot(await resp.json()); if (resp.ok) syncFromSnapshot(await resp.json());
} catch { } catch {
// best-effort // best-effort
@ -521,15 +663,19 @@ async function init() {
// fetch needed here, unlike meta inputs below. // fetch needed here, unlike meta inputs below.
mountRebuildQueue(); mountRebuildQueue();
buildTabs = document.getElementById('builds-tabbar').configure({ buildTabs = document.getElementById("builds-tabbar").configure({
tabs: [ tabs: [
{ id: 'rebuild', label: 'R3BU1LD QU3U3', badgeId: 'builds-tab-count-rebuild' }, {
{ id: 'meta', label: 'M3T4 1NPUTS' }, id: "rebuild",
{ id: 'buildlogs', label: 'BUILD L0GS' }, label: "R3BU1LD QU3U3",
badgeId: "builds-tab-count-rebuild",
},
{ id: "meta", label: "M3T4 1NPUTS" },
{ id: "buildlogs", label: "BUILD L0GS" },
], ],
defaultId: 'rebuild', defaultId: "rebuild",
onShow: (id) => { onShow: (id) => {
if (id === 'buildlogs' && !buildLogsLoaded) { if (id === "buildlogs" && !buildLogsLoaded) {
buildLogsLoaded = true; buildLogsLoaded = true;
fetchBuild(); fetchBuild();
} }
@ -544,12 +690,16 @@ async function init() {
// (subscription discipline, part 1 of the dashboard-event-stream- // (subscription discipline, part 1 of the dashboard-event-stream-
// split issue). // split issue).
const es = openStream( const es = openStream(
'/api/dashboard/stream?kinds=rebuild_queue_changed,meta_inputs_changed,meta_update_running', "/api/dashboard/stream?kinds=rebuild_queue_changed,meta_inputs_changed,meta_update_running",
); );
if (es) { if (es) {
es.onmessage = (e) => { es.onmessage = (e) => {
let ev; let ev;
try { ev = JSON.parse(e.data); } catch { return; } try {
ev = JSON.parse(e.data);
} catch {
return;
}
const h = SSE_HANDLERS[ev.kind]; const h = SSE_HANDLERS[ev.kind];
if (h) h(ev); if (h) h(ev);
}; };

View file

@ -10,16 +10,17 @@
// live-mutation paths call an injected `onCountsChanged` callback the entry // live-mutation paths call an injected `onCountsChanged` callback the entry
// registers once via `initCall`. // registers once via `initCall`.
import { $, form, appendLinkified } from './common.js'; import { $, form, appendLinkified } from "./common.js";
import { el } from '@hive/shared/dom.js'; import { el } from "@hive/shared/dom.js";
import { epochSec, fmtAgo, fmtDuration } from './util.js'; import { epochSec, fmtAgo, fmtDuration } from "./util.js";
// Registered by the dashboard entry at boot; defaults to a no-op so the // Registered by the dashboard entry at boot; defaults to a no-op so the
// module is safe to call before wiring. // module is safe to call before wiring.
let onCountsChanged = () => {}; let onCountsChanged = () => {};
export function initCall(opts = {}) { export function initCall(opts = {}) {
if (typeof opts.onCountsChanged === 'function') onCountsChanged = opts.onCountsChanged; if (typeof opts.onCountsChanged === "function")
onCountsChanged = opts.onCountsChanged;
} }
// ─── operator inbox — unread agent→operator messages ──────────── // ─── operator inbox — unread agent→operator messages ────────────
@ -31,50 +32,66 @@ export function initCall(opts = {}) {
// Count folds into the Y3R C4LL pill + browser-title prefix. // Count folds into the Y3R C4LL pill + browser-title prefix.
let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first
export function operatorInboxCount() { return operatorInbox.length; } export function operatorInboxCount() {
return operatorInbox.length;
}
export async function refreshOperatorInbox() { export async function refreshOperatorInbox() {
try { try {
const r = await fetch('/api/operator-inbox'); const r = await fetch("/api/operator-inbox");
if (r.ok) { if (r.ok) {
const data = await r.json(); const data = await r.json();
operatorInbox = Array.isArray(data.messages) ? data.messages : []; operatorInbox = Array.isArray(data.messages) ? data.messages : [];
} }
} catch { /* keep prior list on transient failure */ } } catch {
/* keep prior list on transient failure */
}
renderOperatorInbox(); renderOperatorInbox();
onCountsChanged(); onCountsChanged();
} }
function renderOperatorInbox() { function renderOperatorInbox() {
const root = $('operator-inbox-section'); const root = $("operator-inbox-section");
if (!root) return; if (!root) return;
root.replaceChildren(); root.replaceChildren();
if (!operatorInbox.length) { if (!operatorInbox.length) {
root.append(el('p', { class: 'meta' }, 'no unread messages')); root.append(el("p", { class: "meta" }, "no unread messages"));
return; return;
} }
const mark = el('button', { type: 'button', class: 'btn', id: 'op-inbox-mark-read' }, const mark = el(
`✓ mark all read (${operatorInbox.length})`); "button",
mark.addEventListener('click', markOperatorInboxRead); { type: "button", class: "btn", id: "op-inbox-mark-read" },
root.append(el('div', { class: 'inbox-toolbar' }, mark)); `✓ mark all read (${operatorInbox.length})`,
const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19); );
const ul = el('ul', { class: 'inbox' }); mark.addEventListener("click", markOperatorInboxRead);
root.append(el("div", { class: "inbox-toolbar" }, mark));
const fmt = (ts) => new Date(ts).toISOString().replace("T", " ").slice(0, 19);
const ul = el("ul", { class: "inbox" });
for (const m of operatorInbox) { for (const m of operatorInbox) {
const body = el('span', { class: 'msg-body' }); const body = el("span", { class: "msg-body" });
appendLinkified(body, m.body, m.file_refs); appendLinkified(body, m.body, m.file_refs);
ul.append(el('li', {}, ul.append(
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ', el(
el('span', { class: 'msg-from' }, m.from), ' ', "li",
el('span', { class: 'msg-sep' }, '→ '), {},
body, el("span", { class: "msg-ts" }, fmt(m.at)),
)); " ",
el("span", { class: "msg-from" }, m.from),
" ",
el("span", { class: "msg-sep" }, "→ "),
body,
),
);
} }
root.append(ul); root.append(ul);
} }
async function markOperatorInboxRead() { async function markOperatorInboxRead() {
try { await fetch('/api/agent/operator/mark-all-read', { method: 'POST' }); } try {
catch { /* best-effort; the next refresh reconciles */ } await fetch("/api/agent/operator/mark-all-read", { method: "POST" });
} catch {
/* best-effort; the next refresh reconciles */
}
operatorInbox = []; operatorInbox = [];
renderOperatorInbox(); renderOperatorInbox();
onCountsChanged(); onCountsChanged();
@ -86,7 +103,10 @@ async function markOperatorInboxRead() {
export function operatorInboxAppendFromEvent(ev) { export function operatorInboxAppendFromEvent(ev) {
if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return; if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return;
operatorInbox.unshift({ operatorInbox.unshift({
id: ev.id, from: ev.from, body: ev.body, at: ev.at, id: ev.id,
from: ev.from,
body: ev.body,
at: ev.at,
file_refs: ev.file_refs || [], file_refs: ev.file_refs || [],
}); });
if (operatorInbox.length > 100) operatorInbox.length = 100; if (operatorInbox.length > 100) operatorInbox.length = 100;
@ -95,14 +115,16 @@ export function operatorInboxAppendFromEvent(ev) {
} }
// ─── approvals — the operator config-change / spawn approval queue ──────── // ─── approvals — the operator config-change / spawn approval queue ────────
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab'; const APPROVAL_TAB_KEY = "hyperhive:approvals:tab";
// Derived approval state — cold-loaded from /api/state, then mutated // Derived approval state — cold-loaded from /api/state, then mutated
// live by `approval_added` / `approval_resolved` dashboard events. // live by `approval_added` / `approval_resolved` dashboard events.
// `pending` is the open queue (newest-first); `history` is the last // `pending` is the open queue (newest-first); `history` is the last
// 30 resolved rows. // 30 resolved rows.
const APPROVAL_HISTORY_LIMIT = 30; const APPROVAL_HISTORY_LIMIT = 30;
const approvalsState = { pending: [], history: [] }; const approvalsState = { pending: [], history: [] };
export function activeApprovalCount() { return approvalsState.pending.length; } export function activeApprovalCount() {
return approvalsState.pending.length;
}
export function syncApprovalsFromSnapshot(s) { export function syncApprovalsFromSnapshot(s) {
approvalsState.pending = (s.approvals || []).slice(); approvalsState.pending = (s.approvals || []).slice();
approvalsState.history = (s.approval_history || []).slice(); approvalsState.history = (s.approval_history || []).slice();
@ -122,8 +144,8 @@ export function applyApprovalAdded(ev) {
// approval was queued just now, so client-now is accurate — and // approval was queued just now, so client-now is accurate — and
// consistent with how fmtAgo compares everything to client-now. // consistent with how fmtAgo compares everything to client-now.
// A later /api/state cold-load swaps in the server value. // A later /api/state cold-load swaps in the server value.
requested_at: ev.requested_at != null requested_at:
? ev.requested_at : Math.floor(Date.now() / 1000), ev.requested_at != null ? ev.requested_at : Math.floor(Date.now() / 1000),
}; };
if (existing >= 0) approvalsState.pending[existing] = row; if (existing >= 0) approvalsState.pending[existing] = row;
else approvalsState.pending.push(row); else approvalsState.pending.push(row);
@ -154,7 +176,7 @@ export function applyApprovalResolved(ev) {
renderApprovals(); renderApprovals();
} }
export function renderApprovals() { export function renderApprovals() {
const root = $('approvals-section'); const root = $("approvals-section");
// #approvals-section only lives on /dashboard.html (Y3R C4LL tab); // #approvals-section only lives on /dashboard.html (Y3R C4LL tab);
// no-op elsewhere — `approval_added` / `approval_resolved` SSE // no-op elsewhere — `approval_added` / `approval_resolved` SSE
// events route through here on every page that loads the bundle. // events route through here on every page that loads the bundle.
@ -163,42 +185,42 @@ export function renderApprovals() {
const pending = approvalsState.pending; const pending = approvalsState.pending;
const history = approvalsState.history; const history = approvalsState.history;
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending'; const active = localStorage.getItem(APPROVAL_TAB_KEY) || "pending";
const tabs = el('div', { class: 'approval-tabs' }); const tabs = el("div", { class: "approval-tabs" });
const pendingTab = el( const pendingTab = el(
'button', "button",
{ {
type: 'button', type: "button",
class: 'approval-tab' + (active === 'pending' ? ' active' : ''), class: "approval-tab" + (active === "pending" ? " active" : ""),
}, },
`pending · ${pending.length}`, `pending · ${pending.length}`,
); );
const historyTab = el( const historyTab = el(
'button', "button",
{ {
type: 'button', type: "button",
class: 'approval-tab' + (active === 'history' ? ' active' : ''), class: "approval-tab" + (active === "history" ? " active" : ""),
}, },
`history · ${history.length}`, `history · ${history.length}`,
); );
pendingTab.addEventListener('click', () => { pendingTab.addEventListener("click", () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'pending'); localStorage.setItem(APPROVAL_TAB_KEY, "pending");
renderApprovals(); renderApprovals();
}); });
historyTab.addEventListener('click', () => { historyTab.addEventListener("click", () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'history'); localStorage.setItem(APPROVAL_TAB_KEY, "history");
renderApprovals(); renderApprovals();
}); });
tabs.append(pendingTab, historyTab); tabs.append(pendingTab, historyTab);
root.append(tabs); root.append(tabs);
if (active === 'history') { if (active === "history") {
renderApprovalHistory(root, history); renderApprovalHistory(root, history);
return; return;
} }
if (!pending.length) { if (!pending.length) {
root.append(el('p', { class: 'empty' }, 'queue empty')); root.append(el("p", { class: "empty" }, "queue empty"));
return; return;
} }
// forge link base — only when the hive-forge container is up. // forge link base — only when the hive-forge container is up.
@ -208,81 +230,143 @@ export function renderApprovals() {
// below already gates on forgeBase being truthy. // below already gates on forgeBase being truthy.
const forgeBase = (fs && fs.forge_present && fs.forge_public_url) || null; const forgeBase = (fs && fs.forge_present && fs.forge_public_url) || null;
const ul = el('ul', { class: 'approvals' }); const ul = el("ul", { class: "approvals" });
for (const a of pending) { for (const a of pending) {
const isInit = a.kind === 'init_config'; const isInit = a.kind === "init_config";
const isMergePr = a.kind === 'merge_config_pr'; const isMergePr = a.kind === "merge_config_pr";
const isUpdateMeta = a.kind === 'update_meta_inputs'; const isUpdateMeta = a.kind === "update_meta_inputs";
const isSchedule = a.kind === 'schedule_prompt'; const isSchedule = a.kind === "schedule_prompt";
const li = el('li', { class: 'approval-card' }); const li = el("li", { class: "approval-card" });
// ── identity header ────────────────────────────────────────── // ── identity header ──────────────────────────────────────────
const head = el('div', { class: 'approval-head' }, const head = el(
el('span', { class: 'glyph' }, isMergePr ? '⇒' : isUpdateMeta ? '↻' : isSchedule ? '⏱' : '⊕'), "div",
el('span', { class: 'id' }, '#' + a.id), { class: "approval-head" },
el('span', { class: 'agent' }, a.agent), el(
el('span', { class: 'kind' + ((isMergePr || isUpdateMeta || isSchedule) ? '' : ' kind-spawn') }, "span",
isMergePr ? 'merge-pr' : isUpdateMeta ? 'meta-update' : isSchedule ? 'schedule' : isInit ? 'init' : 'spawn'), { class: "glyph" },
isMergePr ? "⇒" : isUpdateMeta ? "↻" : isSchedule ? "⏱" : "⊕",
),
el("span", { class: "id" }, "#" + a.id),
el("span", { class: "agent" }, a.agent),
el(
"span",
{
class:
"kind" +
(isMergePr || isUpdateMeta || isSchedule ? "" : " kind-spawn"),
},
isMergePr
? "merge-pr"
: isUpdateMeta
? "meta-update"
: isSchedule
? "schedule"
: isInit
? "init"
: "spawn",
),
); );
if (isMergePr && a.sha_short) head.append(el('code', {}, a.sha_short)); if (isMergePr && a.sha_short) head.append(el("code", {}, a.sha_short));
// When the approval was requested — relative time, right-aligned. // When the approval was requested — relative time, right-aligned.
// Goes amber once it's been pending an hour so a stale request is // Goes amber once it's been pending an hour so a stale request is
// obvious at a glance (see docs/web-ui.md::Approval card). // obvious at a glance (see docs/web-ui.md::Approval card).
if (a.requested_at != null) { if (a.requested_at != null) {
const requestedSec = epochSec(a.requested_at); const requestedSec = epochSec(a.requested_at);
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - requestedSec)); const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - requestedSec));
head.append(el('span', { head.append(
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''), el(
title: 'requested ' + new Date(a.requested_at).toLocaleString(), "span",
'data-requested-at': String(requestedSec), {
}, 'requested ' + fmtAgo(a.requested_at))); class: "approval-ts" + (ageSec >= 3600 ? " stale" : ""),
title: "requested " + new Date(a.requested_at).toLocaleString(),
"data-requested-at": String(requestedSec),
},
"requested " + fmtAgo(a.requested_at),
),
);
} }
li.append(head); li.append(head);
// ── what-changed body ──────────────────────────────────────── // ── what-changed body ────────────────────────────────────────
const body = el('div', { class: 'approval-body' }); const body = el("div", { class: "approval-body" });
if (a.description) { if (a.description) {
body.append(el('div', { class: 'approval-description' }, a.description)); body.append(el("div", { class: "approval-description" }, a.description));
} }
if (isMergePr) { if (isMergePr) {
// PR-based config deploy: link to the reviewed PR on the forge. // PR-based config deploy: link to the reviewed PR on the forge.
// The config diff lives on the forge PR itself. // The config diff lives on the forge PR itself.
const drill = el('div', { class: 'drill-ins' }); const drill = el("div", { class: "drill-ins" });
if (forgeBase && a.pr_number != null) { if (forgeBase && a.pr_number != null) {
drill.append(el('a', { drill.append(
class: 'panel-trigger', target: '_blank', rel: 'noopener', el(
href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`, "a",
title: 'review this config PR on the hive forge', {
}, '↳ review PR on forge ↗')); class: "panel-trigger",
target: "_blank",
rel: "noopener",
href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`,
title: "review this config PR on the hive forge",
},
"↳ review PR on forge ↗",
),
);
} }
body.append(drill); body.append(drill);
} else if (isUpdateMeta) { } else if (isUpdateMeta) {
let inputs; let inputs;
try { inputs = JSON.parse(a.commit_ref || '[]'); } catch (_) { inputs = []; } try {
body.append(el('span', { class: 'meta' }, inputs = JSON.parse(a.commit_ref || "[]");
inputs.length } catch (_) {
? 'bump flake inputs: ' + inputs.join(', ') inputs = [];
: 'bump all flake inputs')); }
body.append(
el(
"span",
{ class: "meta" },
inputs.length
? "bump flake inputs: " + inputs.join(", ")
: "bump all flake inputs",
),
);
} else if (isSchedule) { } else if (isSchedule) {
let payload; let payload;
try { payload = JSON.parse(a.commit_ref || '{}'); } catch (_) { payload = {}; } try {
const targets = (payload.targets || []).join(', '); payload = JSON.parse(a.commit_ref || "{}");
} catch (_) {
payload = {};
}
const targets = (payload.targets || []).join(", ");
const firstFire = payload.first_fire_at_unix const firstFire = payload.first_fire_at_unix
? new Date(payload.first_fire_at_unix * 1000).toLocaleString() ? new Date(payload.first_fire_at_unix * 1000).toLocaleString()
: '?'; : "?";
const cadence = payload.interval_seconds const cadence = payload.interval_seconds
? ' · ↻ every ' + fmtDuration(payload.interval_seconds) ? " · ↻ every " + fmtDuration(payload.interval_seconds)
: ' · one-shot'; : " · one-shot";
body.append(el('div', { class: 'meta' }, '→ ' + targets + ' · first: ' + firstFire + cadence)); body.append(
el(
"div",
{ class: "meta" },
"→ " + targets + " · first: " + firstFire + cadence,
),
);
if (payload.body) { if (payload.body) {
const excerpt = payload.body.length > 80 ? payload.body.slice(0, 80) + '…' : payload.body; const excerpt =
body.append(el('div', { class: 'approval-description' }, excerpt)); payload.body.length > 80
? payload.body.slice(0, 80) + "…"
: payload.body;
body.append(el("div", { class: "approval-description" }, excerpt));
} }
} else { } else {
body.append(el('span', { class: 'meta' }, body.append(
isInit el(
? 'scaffold proposed config repo — submitting agent customises agent.nix before spawn' "span",
: 'new sub-agent — container will be created on approve')); { class: "meta" },
isInit
? "scaffold proposed config repo — submitting agent customises agent.nix before spawn"
: "new sub-agent — container will be created on approve",
),
);
} }
li.append(body); li.append(body);
@ -291,16 +375,32 @@ export function renderApprovals() {
// handler stashes it into a hidden `note` input that rides along // handler stashes it into a hidden `note` input that rides along
// on the POST and is surfaced to the submitting agent via // on the POST and is surfaced to the submitting agent via
// HelperEvent::ApprovalResolved { note }. // HelperEvent::ApprovalResolved { note }.
const denyForm = el('form', { const denyForm = el("form", {
method: 'POST', action: '/api/deny/' + a.id, method: "POST",
class: 'inline', 'data-async': '', 'data-no-refresh': '', action: "/api/deny/" + a.id,
'data-prompt': 'reason for denying (optional, sent to submitter):', class: "inline",
"data-async": "",
"data-no-refresh": "",
"data-prompt": "reason for denying (optional, sent to submitter):",
}); });
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY')); denyForm.append(
li.append(el('div', { class: 'approval-actions' }, el("button", { type: "submit", class: "btn btn-deny" }, "DENY"),
form('/api/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }), );
denyForm, li.append(
)); el(
"div",
{ class: "approval-actions" },
form(
"/api/approve/" + a.id,
"btn-approve",
"◆ APPR0VE",
null,
{},
{ noRefresh: true },
),
denyForm,
),
);
ul.append(li); ul.append(li);
} }
@ -309,31 +409,52 @@ export function renderApprovals() {
function renderApprovalHistory(root, history) { function renderApprovalHistory(root, history) {
if (!history.length) { if (!history.length) {
root.append(el('p', { class: 'empty' }, 'no resolved approvals yet')); root.append(el("p", { class: "empty" }, "no resolved approvals yet"));
return; return;
} }
const ul = el('ul', { class: 'approvals approvals-history' }); const ul = el("ul", { class: "approvals approvals-history" });
for (const a of history) { for (const a of history) {
const li = el('li'); const li = el("li");
const row = el('div', { class: 'row' }); const row = el("div", { class: "row" });
const glyph = a.status === 'approved' ? '✓' const glyph =
: a.status === 'denied' ? '✗' a.status === "approved"
: a.status === 'cancelled' ? '⊘' ? "✓"
: '⚠'; : a.status === "denied"
? "✗"
: a.status === "cancelled"
? "⊘"
: "⚠";
row.append( row.append(
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ', el("span", { class: "glyph glyph-" + a.status }, glyph),
el('span', { class: 'id' }, '#' + a.id), ' ', " ",
el('span', { class: 'agent' }, a.agent), ' ', el("span", { class: "id" }, "#" + a.id),
el('span', { class: 'kind' }, a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'update_meta_inputs' ? 'meta-update' : a.kind === 'schedule_prompt' ? 'schedule' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ', " ",
el("span", { class: "agent" }, a.agent),
" ",
el(
"span",
{ class: "kind" },
a.kind === "merge_config_pr"
? "merge-pr"
: a.kind === "update_meta_inputs"
? "meta-update"
: a.kind === "schedule_prompt"
? "schedule"
: a.kind === "init_config"
? "init"
: "spawn",
),
" ",
); );
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' '); if (a.sha_short) row.append(el("code", {}, a.sha_short), " ");
row.append( row.append(
el('span', { class: 'status status-' + a.status }, a.status), ' ', el("span", { class: "status status-" + a.status }, a.status),
el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)), " ",
el("span", { class: "msg-ts" }, fmtAgo(a.resolved_at)),
); );
li.append(row); li.append(row);
if (a.note) { if (a.note) {
li.append(el('div', { class: 'history-note' }, a.note)); li.append(el("div", { class: "history-note" }, a.note));
} }
ul.append(li); ul.append(li);
} }

View file

@ -13,7 +13,8 @@
Element-level rules shared across all three pages (index, flow, Element-level rules shared across all three pages (index, flow,
logs). These must not reference page-specific chrome classes. */ logs). These must not reference page-specific chrome classes. */
h1, h2 { h1,
h2 {
color: var(--purple); color: var(--purple);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.15em; letter-spacing: 0.15em;
@ -26,9 +27,17 @@ h1, h2 {
white-space: nowrap; white-space: nowrap;
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
ul { list-style: none; padding-left: 0; } ul {
li { padding: 0.5em 0; } list-style: none;
.glyph { color: var(--purple); margin-right: 0.5em; } padding-left: 0;
}
li {
padding: 0.5em 0;
}
.glyph {
color: var(--purple);
margin-right: 0.5em;
}
a { a {
color: var(--cyan); color: var(--cyan);
text-decoration: none; text-decoration: none;
@ -51,10 +60,25 @@ code {
/* shared inline labels /* shared inline labels
.meta / .id / .agent / .empty appear in message rows, build-log .meta / .id / .agent / .empty appear in message rows, build-log
toolbars, and side-panel content rendered by common.js. */ toolbars, and side-panel content rendered by common.js. */
.meta { color: var(--muted); font-size: 0.85em; margin-left: 0.4em; } .meta {
.id { color: var(--pink); font-weight: bold; margin-right: 0.4em; } color: var(--muted);
.agent { color: var(--amber); font-weight: bold; margin-right: 0.6em; } font-size: 0.85em;
.empty { color: var(--muted); font-style: italic; } margin-left: 0.4em;
}
.id {
color: var(--pink);
font-weight: bold;
margin-right: 0.4em;
}
.agent {
color: var(--amber);
font-weight: bold;
margin-right: 0.6em;
}
.empty {
color: var(--muted);
font-style: italic;
}
/* status badges /* status badges
Semantic colour variants only shape comes from `hive-pill-sm` Semantic colour variants only shape comes from `hive-pill-sm`
@ -63,48 +87,69 @@ code {
(swarm.js/core.js/builds.js). Used on container rows and build-log (swarm.js/core.js/builds.js). Used on container rows and build-log
rows. */ rows. */
.badge-warn { .badge-warn {
color: var(--amber); border-color: var(--amber); color: var(--amber);
border-color: var(--amber);
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 50%, transparent); text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 50%, transparent);
} }
.badge-rate-limited { .badge-rate-limited {
color: var(--red); border-color: var(--red); color: var(--red);
border-color: var(--red);
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 50%, transparent); text-shadow: 0 0 6px color-mix(in srgb, var(--red) 50%, transparent);
} }
.badge-muted { .badge-muted {
color: var(--muted); border-color: var(--purple-dim); color: var(--muted);
border-color: var(--purple-dim);
background: color-mix(in srgb, var(--muted) 8%, transparent); background: color-mix(in srgb, var(--muted) 8%, transparent);
} }
.badge-loose-ends { .badge-loose-ends {
color: var(--purple); border-color: var(--purple); color: var(--purple);
border-color: var(--purple);
text-shadow: 0 0 6px color-mix(in srgb, var(--purple) 40%, transparent); text-shadow: 0 0 6px color-mix(in srgb, var(--purple) 40%, transparent);
} }
/* Paused agent: turn loop parked, container still up. Clickable (btn-inline): /* Paused agent: turn loop parked, container still up. Clickable (btn-inline):
clicking sends POST /api/resume/{name} so the badge doubles as a resume button. */ clicking sends POST /api/resume/{name} so the badge doubles as a resume button. */
.badge-paused { .badge-paused {
color: var(--yellow); border-color: var(--yellow); color: var(--yellow);
border-color: var(--yellow);
background: color-mix(in srgb, var(--yellow) 10%, transparent); background: color-mix(in srgb, var(--yellow) 10%, transparent);
} }
/* Active Claude model badge on dashboard container rows. */ /* Active Claude model badge on dashboard container rows. */
.badge-model { .badge-model {
color: var(--blue); border-color: var(--blue); color: var(--blue);
border-color: var(--blue);
opacity: 0.8; opacity: 0.8;
} }
/* Context-window usage badges on dashboard container rows. */ /* Context-window usage badges on dashboard container rows. */
.badge-ctx-ok { .badge-ctx-ok {
color: var(--green); border-color: var(--green); color: var(--green);
border-color: var(--green);
opacity: 0.85; opacity: 0.85;
} }
.badge-ctx-caution { .badge-ctx-caution {
color: var(--amber); border-color: var(--amber); color: var(--amber);
border-color: var(--amber);
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 50%, transparent); text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 50%, transparent);
} }
.badge-ctx-warn { .badge-ctx-warn {
color: var(--red); border-color: var(--red); color: var(--red);
border-color: var(--red);
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 50%, transparent); text-shadow: 0 0 6px color-mix(in srgb, var(--red) 50%, transparent);
} }
.badge-ok { background: color-mix(in srgb, var(--green) 12%, transparent); color: var(--green); border-color: var(--green); } .badge-ok {
.badge-fail { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); border-color: var(--red); } background: color-mix(in srgb, var(--green) 12%, transparent);
.badge-running { background: color-mix(in srgb, var(--amber) 12%, transparent); color: var(--amber); border-color: var(--amber); } color: var(--green);
border-color: var(--green);
}
.badge-fail {
background: color-mix(in srgb, var(--red) 12%, transparent);
color: var(--red);
border-color: var(--red);
}
.badge-running {
background: color-mix(in srgb, var(--amber) 12%, transparent);
color: var(--amber);
border-color: var(--amber);
}
/* buttons /* buttons
.btn base + semantic colour/size modifiers. Logs page uses .btn base + semantic colour/size modifiers. Logs page uses
@ -145,26 +190,89 @@ code {
text-shadow: none; text-shadow: none;
box-shadow: none; box-shadow: none;
} }
.btn-approve { color: var(--green); border-color: var(--green); } .btn-approve {
.btn-deny { color: var(--red); border-color: var(--red); } color: var(--green);
.btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } border-color: var(--green);
.btn-rebuild { color: var(--amber); border-color: var(--amber); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } }
.btn-restart { color: var(--cyan); border-color: var(--cyan); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } .btn-deny {
.btn-stop { color: var(--pink); border-color: var(--pink); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } color: var(--red);
.btn-start { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } border-color: var(--red);
}
.btn-destroy {
color: var(--red);
border-color: var(--red);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-rebuild {
color: var(--amber);
border-color: var(--amber);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-restart {
color: var(--cyan);
border-color: var(--cyan);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-stop {
color: var(--pink);
border-color: var(--pink);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-start {
color: var(--green);
border-color: var(--green);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
/* Same yellow as .badge-paused (the paused-state pill) pause enters /* Same yellow as .badge-paused (the paused-state pill) pause enters
that state, so the trigger and the resulting badge read as one that state, so the trigger and the resulting badge read as one
colour, not two unrelated ones. Resume reads as green like colour, not two unrelated ones. Resume reads as green like
.btn-start: both are "go" actions, back to the turn loop running. */ .btn-start: both are "go" actions, back to the turn loop running. */
.btn-pause { color: var(--yellow); border-color: var(--yellow); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } .btn-pause {
.btn-resume { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } color: var(--yellow);
border-color: var(--yellow);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-resume {
color: var(--green);
border-color: var(--green);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
/* M0V3 affordance (selection bar) mauve reads as "structural /* M0V3 affordance (selection bar) mauve reads as "structural
change" rather than the destructive red / amber chrome of change" rather than the destructive red / amber chrome of
destroy / rebuild. See docs/web-ui.md::Selection bar. */ destroy / rebuild. See docs/web-ui.md::Selection bar. */
.btn-move { color: var(--purple); border-color: var(--purple); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } .btn-move {
.btn-talk { color: var(--cyan); border-color: var(--cyan); } color: var(--purple);
.btn-spawn { color: var(--amber); border-color: var(--amber); } border-color: var(--purple);
.btn-fire-now { color: var(--purple); border-color: var(--purple); } font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-talk {
color: var(--cyan);
border-color: var(--cyan);
}
.btn-spawn {
color: var(--amber);
border-color: var(--amber);
}
.btn-fire-now {
color: var(--purple);
border-color: var(--purple);
}
/* Post-fire flash: report renders directly on the button for ~1.5s /* Post-fire flash: report renders directly on the button for ~1.5s
so the operator sees ok/failed/missing/consumed counts inline so the operator sees ok/failed/missing/consumed counts inline
without a modal. Green when at least one ok; muted otherwise. */ without a modal. Green when at least one ok; muted otherwise. */
@ -177,13 +285,24 @@ code {
/* Inline edit button on each schedule row. Yellow reads as a /* Inline edit button on each schedule row. Yellow reads as a
parallel destructive-adjacent action (edit changes state, but parallel destructive-adjacent action (edit changes state, but
isn't deletion). */ isn't deletion). */
.btn-edit-schedule { color: var(--yellow); border-color: var(--yellow); } .btn-edit-schedule {
color: var(--yellow);
border-color: var(--yellow);
}
/* file-preview / diff panel (common.js Panel) /* file-preview / diff panel (common.js Panel)
Side-panel content rendered by openFilePanel / buildTabbedPreview Side-panel content rendered by openFilePanel / buildTabbedPreview
in common.js; all three pages load the Panel singleton. */ in common.js; all three pages load the Panel singleton. */
.diff-panel { display: flex; flex-direction: column; gap: 0.6em; } .diff-panel {
.diff-base-tabs { display: flex; flex-wrap: wrap; gap: 0.4em; } display: flex;
flex-direction: column;
gap: 0.6em;
}
.diff-base-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.diff-base-tab { .diff-base-tab {
background: transparent; background: transparent;
border: 1px solid var(--border); border: 1px solid var(--border);
@ -193,14 +312,18 @@ code {
padding: 0.2em 0.7em; padding: 0.2em 0.7em;
cursor: pointer; cursor: pointer;
} }
.diff-base-tab:hover { color: var(--fg); } .diff-base-tab:hover {
color: var(--fg);
}
.diff-base-tab.active { .diff-base-tab.active {
color: var(--purple); color: var(--purple);
border-color: var(--purple); border-color: var(--purple);
background: color-mix(in srgb, var(--purple) 8%, transparent); background: color-mix(in srgb, var(--purple) 8%, transparent);
} }
/* Image / tabbed file preview */ /* Image / tabbed file preview */
.preview-host { margin-top: 0.5em; } .preview-host {
margin-top: 0.5em;
}
.img-preview { .img-preview {
display: block; display: block;
max-width: 100%; max-width: 100%;
@ -209,7 +332,8 @@ code {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
/* checkerboard so transparent regions of the image read clearly */ /* checkerboard so transparent regions of the image read clearly */
background: repeating-conic-gradient(var(--border) 0% 25%, var(--bg) 0% 50%) 50% / 18px 18px; background: repeating-conic-gradient(var(--border) 0% 25%, var(--bg) 0% 50%)
50% / 18px 18px;
} }
/* Path linkification agents drop pointer strings into messages; /* Path linkification agents drop pointer strings into messages;
@ -219,7 +343,9 @@ code {
text-decoration: underline dotted; text-decoration: underline dotted;
cursor: pointer; cursor: pointer;
} }
.path-link:hover { color: var(--amber); } .path-link:hover {
color: var(--amber);
}
/* File-preview body — rendered inside the side panel. */ /* File-preview body — rendered inside the side panel. */
.path-preview-body { .path-preview-body {
background: var(--bg); background: var(--bg);
@ -255,11 +381,24 @@ code {
gap: 0.5em; gap: 0.5em;
align-items: baseline; align-items: baseline;
} }
.inbox li:last-child { border-bottom: 0; } .inbox li:last-child {
.inbox .msg-ts { color: var(--muted); font-size: 0.85em; } border-bottom: 0;
.inbox .msg-from { color: var(--amber); } }
.inbox .msg-sep { color: var(--muted); } .inbox .msg-ts {
.inbox .msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; } color: var(--muted);
font-size: 0.85em;
}
.inbox .msg-from {
color: var(--amber);
}
.inbox .msg-sep {
color: var(--muted);
}
.inbox .msg-body {
color: var(--fg);
white-space: pre-wrap;
word-break: break-word;
}
/* `#msgflow` is a shared `.live` pane inside `.terminal-wrap`. The /* `#msgflow` is a shared `.live` pane inside `.terminal-wrap`. The
msgrow / msg-* rules below power both the dashboard CALL tab and msgrow / msg-* rules below power both the dashboard CALL tab and
the /flow.html full-page terminal. */ the /flow.html full-page terminal. */
@ -275,8 +414,12 @@ code {
flex: 1 1 100%; flex: 1 1 100%;
min-width: 0; min-width: 0;
} }
.live .msgrow.sent .msg-arrow { color: var(--cyan); } .live .msgrow.sent .msg-arrow {
.live .msgrow.delivered .msg-arrow { color: var(--green); } color: var(--cyan);
}
.live .msgrow.delivered .msg-arrow {
color: var(--green);
}
/* Reply-thread rendering: indented border-left + muted reply tag. */ /* Reply-thread rendering: indented border-left + muted reply tag. */
.live .msgrow.msg-reply { .live .msgrow.msg-reply {
padding-left: 1.2em; padding-left: 1.2em;
@ -294,18 +437,41 @@ code {
text-shadow: none; text-shadow: none;
font-weight: normal; font-weight: normal;
} }
.msg-reply-tag a:hover { color: var(--fg); } .msg-reply-tag a:hover {
color: var(--fg);
}
@keyframes msg-highlight-fade { @keyframes msg-highlight-fade {
from { background: color-mix(in srgb, var(--purple) 18%, transparent); } from {
to { background: transparent; } background: color-mix(in srgb, var(--purple) 18%, transparent);
}
to {
background: transparent;
}
}
.msg-highlight {
animation: msg-highlight-fade 1.5s ease-out forwards;
}
.msg-ts {
color: var(--muted);
font-size: 0.85em;
}
.msg-arrow {
font-weight: bold;
}
.msg-from {
color: var(--amber);
}
.msg-sep {
color: var(--muted);
}
.msg-to {
color: var(--pink);
}
.msg-body {
color: var(--fg);
white-space: pre-wrap;
word-break: break-word;
} }
.msg-highlight { animation: msg-highlight-fade 1.5s ease-out forwards; }
.msg-ts { color: var(--muted); font-size: 0.85em; }
.msg-arrow { font-weight: bold; }
.msg-from { color: var(--amber); }
.msg-sep { color: var(--muted); }
.msg-to { color: var(--pink); }
.msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
/* operator compose box /* operator compose box
Sits inside `.terminal-wrap` on both /flow.html and the dashboard Sits inside `.terminal-wrap` on both /flow.html and the dashboard
@ -339,8 +505,12 @@ code {
max-height: 8em; max-height: 8em;
padding: 0; padding: 0;
} }
.op-compose-input:focus { outline: none; } .op-compose-input:focus {
.op-compose-input::placeholder { color: var(--muted); } outline: none;
}
.op-compose-input::placeholder {
color: var(--muted);
}
.op-compose-suggest { .op-compose-suggest {
position: absolute; position: absolute;
left: 3em; left: 3em;
@ -387,15 +557,29 @@ code {
body.side-panel-resizing { body.side-panel-resizing {
user-select: none; user-select: none;
} }
body.side-panel-resizing * { cursor: ew-resize !important; } body.side-panel-resizing * {
hive-side-panel .md { color: var(--fg); line-height: 1.5; } cursor: ew-resize !important;
hive-side-panel .md > :first-child { margin-top: 0; } }
hive-side-panel .md > :last-child { margin-bottom: 0; } hive-side-panel .md {
hive-side-panel .md p { margin: 0.5em 0; } color: var(--fg);
line-height: 1.5;
}
hive-side-panel .md > :first-child {
margin-top: 0;
}
hive-side-panel .md > :last-child {
margin-bottom: 0;
}
hive-side-panel .md p {
margin: 0.5em 0;
}
hive-side-panel .md h1, hive-side-panel .md h1,
hive-side-panel .md h2, hive-side-panel .md h2,
hive-side-panel .md h3, hive-side-panel .md h3,
hive-side-panel .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; } hive-side-panel .md h4 {
color: var(--purple);
margin: 0.9em 0 0.4em;
}
hive-side-panel .md code { hive-side-panel .md code {
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
@ -410,17 +594,29 @@ hive-side-panel .md pre {
overflow-x: auto; overflow-x: auto;
margin: 0.5em 0; margin: 0.5em 0;
} }
hive-side-panel .md pre code { background: none; border: none; padding: 0; } hive-side-panel .md pre code {
hive-side-panel .md a { color: var(--cyan); } background: none;
border: none;
padding: 0;
}
hive-side-panel .md a {
color: var(--cyan);
}
hive-side-panel .md ul, hive-side-panel .md ul,
hive-side-panel .md ol { margin: 0.4em 0; padding-left: 1.5em; } hive-side-panel .md ol {
margin: 0.4em 0;
padding-left: 1.5em;
}
hive-side-panel .md blockquote { hive-side-panel .md blockquote {
border-left: 3px solid var(--purple-dim); border-left: 3px solid var(--purple-dim);
padding-left: 0.8em; padding-left: 0.8em;
margin: 0.4em 0; margin: 0.4em 0;
color: var(--muted); color: var(--muted);
} }
hive-side-panel .md table { border-collapse: collapse; margin: 0.5em 0; } hive-side-panel .md table {
border-collapse: collapse;
margin: 0.5em 0;
}
hive-side-panel .md th, hive-side-panel .md th,
hive-side-panel .md td { hive-side-panel .md td {
border: 1px solid var(--border); border: 1px solid var(--border);
@ -435,7 +631,9 @@ hive-side-panel .md td {
with no negative-margin breakout hacks. Only matches on pages that opt with no negative-margin breakout hacks. Only matches on pages that opt
in by adding the wrapper (dashboard.html, index.html); FL0W / L0GS / in by adding the wrapper (dashboard.html, index.html); FL0W / L0GS /
ST4TS are full-bleed with their own `.<page>-main` padding. */ ST4TS are full-bleed with their own `.<page>-main` padding. */
.page-content { padding: 0 1.5em; } .page-content {
padding: 0 1.5em;
}
/* sticky top region /* sticky top region
One sticky container per page holding the warning banner above the One sticky container per page holding the warning banner above the
@ -458,7 +656,9 @@ hive-side-panel .md td {
the full width edge-to-edge; the dashboard + H0M3 are full-bleed at the the full width edge-to-edge; the dashboard + H0M3 are full-bleed at the
body level (their 1.5em gutter lives on an inner `.page-content` body level (their 1.5em gutter lives on an inner `.page-content`
wrapper), so the banner is full-width for free. */ wrapper), so the banner is full-width for free. */
.server-warnings[hidden] { display: none; } .server-warnings[hidden] {
display: none;
}
.server-warn { .server-warn {
text-align: center; text-align: center;
padding: 0.4em 1em; padding: 0.4em 1em;

View file

@ -3,35 +3,55 @@
// the OS-notification module, and the path-link / file-preview // the OS-notification module, and the path-link / file-preview
// infrastructure for the side panel. // infrastructure for the side panel.
import { linkify as termLinkify } from '@hive/shared/terminal.js'; import { linkify as termLinkify } from "@hive/shared/terminal.js";
import { el } from '@hive/shared/dom.js'; import { el } from "@hive/shared/dom.js";
import '@hive/shared/side-panel.js'; // registers <hive-side-panel> — side-effect import import "@hive/shared/side-panel.js"; // registers <hive-side-panel> — side-effect import
import '@hive/shared/hive-warn.js'; // registers <hive-warn> — side-effect import import "@hive/shared/hive-warn.js"; // registers <hive-warn> — side-effect import
import DOMPurify from 'dompurify'; import DOMPurify from "dompurify";
// ─── helpers ──────────────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────────────
export const $ = (id) => document.getElementById(id); export const $ = (id) => document.getElementById(id);
export const fmtAgeSecs = (s) => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m` export const fmtAgeSecs = (s) =>
: s < 86400 ? `${Math.floor(s/3600)}h` : `${Math.floor(s/86400)}d`; s < 60
? `${s}s`
: s < 3600
? `${Math.floor(s / 60)}m`
: s < 86400
? `${Math.floor(s / 3600)}h`
: `${Math.floor(s / 86400)}d`;
export const esc = (s) => String(s).replace(/[&<>"]/g, (c) => export const esc = (s) =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[c]) String(s).replace(
); /[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => { export const form = (
const f = el('form', { action,
method: 'POST', action, class: 'inline', 'data-async': '', btnClass,
...(confirmMsg ? { 'data-confirm': confirmMsg } : {}), btnLabel,
confirmMsg,
extra = {},
opts = {},
) => {
const f = el("form", {
method: "POST",
action,
class: "inline",
"data-async": "",
...(confirmMsg ? { "data-confirm": confirmMsg } : {}),
// Endpoints whose mutation fires a DashboardEvent (and whose // Endpoints whose mutation fires a DashboardEvent (and whose
// derived store applies it live) opt out of the post-submit // derived store applies it live) opt out of the post-submit
// /api/state refetch. See the async-form handler. // /api/state refetch. See the async-form handler.
...(opts.noRefresh ? { 'data-no-refresh': '' } : {}), ...(opts.noRefresh ? { "data-no-refresh": "" } : {}),
}); });
for (const [name, value] of Object.entries(extra)) { for (const [name, value] of Object.entries(extra)) {
f.append(el('input', { type: 'hidden', name, value })); f.append(el("input", { type: "hidden", name, value }));
} }
f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel)); f.append(
el("button", { type: "submit", class: "btn " + btnClass }, btnLabel),
);
return f; return f;
}; };
@ -55,8 +75,8 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
// Consumer API: assign `onmessage` / `onopen` / `onerror`; `.close()` // Consumer API: assign `onmessage` / `onopen` / `onerror`; `.close()`
// drops the subscription (the worker closes the upstream when the last // drops the subscription (the worker closes the upstream when the last
// subscriber leaves). // subscriber leaves).
const SHARED_WORKER_PATH = '/static/stream-worker.js'; const SHARED_WORKER_PATH = "/static/stream-worker.js";
const SHARED_WORKER_NAME = 'hyperhive-stream'; const SHARED_WORKER_NAME = "hyperhive-stream";
// One SharedWorker port per page, reused by all openStream calls on // One SharedWorker port per page, reused by all openStream calls on
// that page. Invalidated on `pagehide` so a bfcache restore picks up // that page. Invalidated on `pagehide` so a bfcache restore picks up
@ -64,13 +84,16 @@ const SHARED_WORKER_NAME = 'hyperhive-stream';
// closed while this page was frozen. // closed while this page was frozen.
let _sharedPort = null; let _sharedPort = null;
function makeSharedPort() { function makeSharedPort() {
if (typeof SharedWorker === 'undefined') return null; if (typeof SharedWorker === "undefined") return null;
try { try {
const sw = new SharedWorker(SHARED_WORKER_PATH, SHARED_WORKER_NAME); const sw = new SharedWorker(SHARED_WORKER_PATH, SHARED_WORKER_NAME);
sw.port.start(); sw.port.start();
return sw.port; return sw.port;
} catch (err) { } catch (err) {
console.warn('SharedWorker unavailable, falling back to direct EventSource:', err); console.warn(
"SharedWorker unavailable, falling back to direct EventSource:",
err,
);
return null; return null;
} }
} }
@ -87,19 +110,27 @@ function getSharedPort() {
const WORKER_DEAD_THRESHOLD_MS = 90_000; const WORKER_DEAD_THRESHOLD_MS = 90_000;
const WORKER_WATCHDOG_INTERVAL_MS = 15_000; const WORKER_WATCHDOG_INTERVAL_MS = 15_000;
let _lastWorkerActivityAt = 0; let _lastWorkerActivityAt = 0;
function noteWorkerActivity() { _lastWorkerActivityAt = Date.now(); } function noteWorkerActivity() {
_lastWorkerActivityAt = Date.now();
}
let _watchdogTimer = null; let _watchdogTimer = null;
function startWorkerWatchdog() { function startWorkerWatchdog() {
if (_watchdogTimer != null) return; if (_watchdogTimer != null) return;
_watchdogTimer = setInterval(() => { _watchdogTimer = setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return; if (
typeof document !== "undefined" &&
document.visibilityState !== "visible"
)
return;
if (!_activeSubs.size) return; if (!_activeSubs.size) return;
if (!_sharedPort) return; if (!_sharedPort) return;
const sinceLast = Date.now() - _lastWorkerActivityAt; const sinceLast = Date.now() - _lastWorkerActivityAt;
if (sinceLast < WORKER_DEAD_THRESHOLD_MS) return; if (sinceLast < WORKER_DEAD_THRESHOLD_MS) return;
console.warn( console.warn(
'hyperhive-stream worker silent for ' + Math.round(sinceLast / 1000) "hyperhive-stream worker silent for " +
+ 's, presumed dead — re-subscribing on a fresh port'); Math.round(sinceLast / 1000) +
"s, presumed dead — re-subscribing on a fresh port",
);
rebindOnFreshPort(); rebindOnFreshPort();
}, WORKER_WATCHDOG_INTERVAL_MS); }, WORKER_WATCHDOG_INTERVAL_MS);
} }
@ -109,7 +140,9 @@ function rebindOnFreshPort() {
// is the source of truth for what we need to re-attach. // is the source of truth for what we need to re-attach.
if (_sharedPort) { if (_sharedPort) {
for (const sub of _activeSubs.values()) { for (const sub of _activeSubs.values()) {
try { _sharedPort.removeEventListener('message', sub.route); } catch {} try {
_sharedPort.removeEventListener("message", sub.route);
} catch {}
} }
} }
_sharedPort = null; _sharedPort = null;
@ -117,8 +150,10 @@ function rebindOnFreshPort() {
if (!port) return; // SharedWorker unsupported / unavailable — nothing to do if (!port) return; // SharedWorker unsupported / unavailable — nothing to do
for (const [url, sub] of _activeSubs) { for (const [url, sub] of _activeSubs) {
sub.target.readyState = 0; // CONNECTING — worker will (re-)fire 'open' sub.target.readyState = 0; // CONNECTING — worker will (re-)fire 'open'
port.addEventListener('message', sub.route); port.addEventListener("message", sub.route);
try { port.postMessage({ kind: 'subscribe', url }); } catch {} try {
port.postMessage({ kind: "subscribe", url });
} catch {}
} }
// Reset the activity clock so the watchdog gives the fresh worker // Reset the activity clock so the watchdog gives the fresh worker
// a full window to settle before re-triggering. // a full window to settle before re-triggering.
@ -143,32 +178,41 @@ function bindLifecycleOnce() {
if (_lifecycleBound) return; if (_lifecycleBound) return;
_lifecycleBound = true; _lifecycleBound = true;
startWorkerWatchdog(); startWorkerWatchdog();
window.addEventListener('pagehide', () => { window.addEventListener("pagehide", () => {
if (!_sharedPort) return; if (!_sharedPort) return;
for (const url of _activeSubs.keys()) { for (const url of _activeSubs.keys()) {
try { _sharedPort.postMessage({ kind: 'unsubscribe', url }); } try {
catch { /* port dead — worker side already cleaned up */ } _sharedPort.postMessage({ kind: "unsubscribe", url });
} catch {
/* port dead — worker side already cleaned up */
}
} }
// Drop port routes too; the bfcache-restore path will re-add // Drop port routes too; the bfcache-restore path will re-add
// them on a fresh port. Leaving stale routes on a dead port // them on a fresh port. Leaving stale routes on a dead port
// would just keep a closure alive without cost, but cleaning // would just keep a closure alive without cost, but cleaning
// up keeps the registry shape honest. // up keeps the registry shape honest.
for (const sub of _activeSubs.values()) { for (const sub of _activeSubs.values()) {
try { _sharedPort.removeEventListener('message', sub.route); } try {
catch { /* same */ } _sharedPort.removeEventListener("message", sub.route);
} catch {
/* same */
}
} }
_sharedPort = null; _sharedPort = null;
}); });
window.addEventListener('pageshow', (ev) => { window.addEventListener("pageshow", (ev) => {
if (!ev.persisted) return; // cold load — openStream just bound listeners if (!ev.persisted) return; // cold load — openStream just bound listeners
if (!_activeSubs.size) return; if (!_activeSubs.size) return;
const port = getSharedPort(); const port = getSharedPort();
if (!port) return; // SharedWorker really gone; fallback already in place if (!port) return; // SharedWorker really gone; fallback already in place
for (const [url, sub] of _activeSubs) { for (const [url, sub] of _activeSubs) {
sub.target.readyState = 0; // CONNECTING — the worker will fire 'open' sub.target.readyState = 0; // CONNECTING — the worker will fire 'open'
port.addEventListener('message', sub.route); port.addEventListener("message", sub.route);
try { port.postMessage({ kind: 'subscribe', url }); } try {
catch { /* port dead immediately — skip */ } port.postMessage({ kind: "subscribe", url });
} catch {
/* port dead immediately — skip */
}
} }
}); });
} }
@ -191,10 +235,16 @@ export function openStream(url) {
close() { close() {
const p = _sharedPort; const p = _sharedPort;
if (p) { if (p) {
try { p.postMessage({ kind: 'unsubscribe', url }); } try {
catch { /* port dead */ } p.postMessage({ kind: "unsubscribe", url });
try { p.removeEventListener('message', route); } } catch {
catch { /* same */ } /* port dead */
}
try {
p.removeEventListener("message", route);
} catch {
/* same */
}
} }
_activeSubs.delete(url); _activeSubs.delete(url);
}, },
@ -204,29 +254,38 @@ export function openStream(url) {
// before the URL filter, since heartbeat pings carry no URL. // before the URL filter, since heartbeat pings carry no URL.
noteWorkerActivity(); noteWorkerActivity();
const m = e.data; const m = e.data;
if (!m || m.kind === 'ping') return; if (!m || m.kind === "ping") return;
if (m.url !== url) return; if (m.url !== url) return;
if (m.kind === 'open') { if (m.kind === "open") {
target.readyState = 1; // OPEN target.readyState = 1; // OPEN
if (target.onopen) { if (target.onopen) {
try { target.onopen({ target }); } try {
catch (err) { console.error('openStream onopen threw', err); } target.onopen({ target });
} catch (err) {
console.error("openStream onopen threw", err);
}
} }
} else if (m.kind === 'message') { } else if (m.kind === "message") {
if (target.onmessage) { if (target.onmessage) {
try { target.onmessage({ data: m.data, target }); } try {
catch (err) { console.error('openStream onmessage threw', err); } target.onmessage({ data: m.data, target });
} catch (err) {
console.error("openStream onmessage threw", err);
}
} }
} else if (m.kind === 'error') { } else if (m.kind === "error") {
if (target.onerror) { if (target.onerror) {
try { target.onerror({ target }); } try {
catch (err) { console.error('openStream onerror threw', err); } target.onerror({ target });
} catch (err) {
console.error("openStream onerror threw", err);
}
} }
} }
}; };
_activeSubs.set(url, { target, route }); _activeSubs.set(url, { target, route });
port.addEventListener('message', route); port.addEventListener("message", route);
port.postMessage({ kind: 'subscribe', url }); port.postMessage({ kind: "subscribe", url });
// Seed the activity clock so the watchdog has a baseline (would // Seed the activity clock so the watchdog has a baseline (would
// otherwise compare against 0 and trigger immediately). // otherwise compare against 0 and trigger immediately).
noteWorkerActivity(); noteWorkerActivity();
@ -244,33 +303,41 @@ export function openStream(url) {
// (re)connect, so a CONNECTING reconnect resets the <pre> to avoid doubling. // (re)connect, so a CONNECTING reconnect resets the <pre> to avoid doubling.
export function openBuildLogStream(id, pre, { onDone, onError } = {}) { export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
let atBottom = true; let atBottom = true;
pre.addEventListener('scroll', () => { pre.addEventListener("scroll", () => {
atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40; atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
}); });
let stderrSeen = false; let stderrSeen = false;
const es = new EventSource('/api/build-logs/id/' + id + '/stream'); const es = new EventSource("/api/build-logs/id/" + id + "/stream");
es.onmessage = (e) => { es.onmessage = (e) => {
let frame; let frame;
try { frame = JSON.parse(e.data); } catch { return; } try {
frame = JSON.parse(e.data);
} catch {
return;
}
if (frame.stdout_append) { if (frame.stdout_append) {
pre.textContent += frame.stdout_append; pre.textContent += frame.stdout_append;
if (atBottom) pre.scrollTop = pre.scrollHeight; if (atBottom) pre.scrollTop = pre.scrollHeight;
} }
if (frame.stderr_append) { if (frame.stderr_append) {
if (!stderrSeen) { pre.textContent += '\n--- stderr ---\n'; stderrSeen = true; } if (!stderrSeen) {
pre.textContent += "\n--- stderr ---\n";
stderrSeen = true;
}
pre.textContent += frame.stderr_append; pre.textContent += frame.stderr_append;
if (atBottom) pre.scrollTop = pre.scrollHeight; if (atBottom) pre.scrollTop = pre.scrollHeight;
} }
if (frame.done) { if (frame.done) {
es.close(); es.close();
if (onDone) onDone(frame.status || 'done'); if (onDone) onDone(frame.status || "done");
} }
}; };
es.onerror = () => { es.onerror = () => {
// CONNECTING = the browser is auto-reconnecting; the stream replays from // CONNECTING = the browser is auto-reconnecting; the stream replays from
// the start, so clear the <pre> to avoid duplicated output and wait. // the start, so clear the <pre> to avoid duplicated output and wait.
if (es.readyState === EventSource.CONNECTING) { if (es.readyState === EventSource.CONNECTING) {
pre.textContent = ''; stderrSeen = false; pre.textContent = "";
stderrSeen = false;
return; return;
} }
es.close(); es.close();
@ -295,7 +362,7 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
// slotted content via a plain `hive-side-panel .md …` tag-name selector // slotted content via a plain `hive-side-panel .md …` tag-name selector
// (no compatibility class needed — the element's own tag name already // (no compatibility class needed — the element's own tag name already
// uniquely identifies it in the light DOM). // uniquely identifies it in the light DOM).
export const sidePanel = document.createElement('hive-side-panel'); export const sidePanel = document.createElement("hive-side-panel");
document.body.append(sidePanel); document.body.append(sidePanel);
// ─── path linkification ───────────────────────────────────────────────── // ─── path linkification ─────────────────────────────────────────────────
@ -309,45 +376,54 @@ document.body.append(sidePanel);
// to translate it). Prefer `/agents/<name>/state/...` in agent // to translate it). Prefer `/agents/<name>/state/...` in agent
// outputs and the link will resolve. // outputs and the link will resolve.
async function fetchStateFile(path) { async function fetchStateFile(path) {
const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path)); const resp = await fetch("/api/state-file?path=" + encodeURIComponent(path));
const text = await resp.text(); const text = await resp.text();
if (!resp.ok) throw new Error(text || ('http ' + resp.status)); if (!resp.ok) throw new Error(text || "http " + resp.status);
return text; return text;
} }
// A 2-tab file preview: a "rendered" tab (default) + a raw-text tab. // A 2-tab file preview: a "rendered" tab (default) + a raw-text tab.
// `renderRendered()` produces the rendered-tab node fresh on each // `renderRendered()` produces the rendered-tab node fresh on each
// switch; `plainText` backs the raw tab; `plainLabel` names it. // switch; `plainText` backs the raw tab; `plainLabel` names it.
function buildTabbedPreview(renderRendered, plainText, plainLabel) { function buildTabbedPreview(renderRendered, plainText, plainLabel) {
const tabs = el('div', { class: 'diff-base-tabs' }); const tabs = el("div", { class: "diff-base-tabs" });
const host = el('div', { class: 'preview-host' }); const host = el("div", { class: "preview-host" });
function show(mode) { function show(mode) {
for (const b of tabs.children) { for (const b of tabs.children) {
b.classList.toggle('active', b.dataset.mode === mode); b.classList.toggle("active", b.dataset.mode === mode);
} }
host.replaceChildren(mode === 'plain' host.replaceChildren(
? el('pre', { class: 'path-preview-body' }, plainText) mode === "plain"
: renderRendered()); ? el("pre", { class: "path-preview-body" }, plainText)
: renderRendered(),
);
} }
for (const [mode, label] of [['rendered', 'rendered'], ['plain', plainLabel]]) { for (const [mode, label] of [
const b = el('button', ["rendered", "rendered"],
{ type: 'button', class: 'diff-base-tab', 'data-mode': mode }, label); ["plain", plainLabel],
b.addEventListener('click', () => show(mode)); ]) {
const b = el(
"button",
{ type: "button", class: "diff-base-tab", "data-mode": mode },
label,
);
b.addEventListener("click", () => show(mode));
tabs.append(b); tabs.append(b);
} }
show('rendered'); show("rendered");
return el('div', {}, tabs, host); return el("div", {}, tabs, host);
} }
// Rendered <img> for an SVG, loaded via an <img> data: URI — // Rendered <img> for an SVG, loaded via an <img> data: URI —
// <img>-loaded SVG runs in the browser's secure static mode (no // <img>-loaded SVG runs in the browser's secure static mode (no
// scripts, no external fetches), so an untrusted SVG from an // scripts, no external fetches), so an untrusted SVG from an
// agent's state dir can't execute code in the dashboard. // agent's state dir can't execute code in the dashboard.
function svgImage(text) { function svgImage(text) {
const img = el('img', { class: 'img-preview', alt: 'SVG preview' }); const img = el("img", { class: "img-preview", alt: "SVG preview" });
img.addEventListener('error', () => { img.addEventListener("error", () => {
img.replaceWith(el('div', { class: 'meta' }, img.replaceWith(
'(could not render — see the source tab)')); el("div", { class: "meta" }, "(could not render — see the source tab)"),
);
}); });
img.src = 'data:image/svg+xml,' + encodeURIComponent(text); img.src = "data:image/svg+xml," + encodeURIComponent(text);
return img; return img;
} }
// Marked-rendered markdown node (raw text fallback if `marked` // Marked-rendered markdown node (raw text fallback if `marked`
@ -357,15 +433,15 @@ function svgImage(text) {
// tags that `marked` itself no longer strips (v5+ dropped the built-in // tags that `marked` itself no longer strips (v5+ dropped the built-in
// sanitizer). // sanitizer).
function mdNode(text) { function mdNode(text) {
const div = el('div', { class: 'md' }); const div = el("div", { class: "md" });
if (window.marked && typeof window.marked.parse === 'function') { if (window.marked && typeof window.marked.parse === "function") {
window.marked.setOptions({ breaks: true, gfm: true }); window.marked.setOptions({ breaks: true, gfm: true });
div.innerHTML = DOMPurify.sanitize(window.marked.parse(text)); div.innerHTML = DOMPurify.sanitize(window.marked.parse(text));
// marked autolinks URLs but leaves them same-tab — open externally // marked autolinks URLs but leaves them same-tab — open externally
// so a click never navigates away from the dashboard. // so a click never navigates away from the dashboard.
div.querySelectorAll('a[href]').forEach((a) => { div.querySelectorAll("a[href]").forEach((a) => {
a.target = '_blank'; a.target = "_blank";
a.rel = 'noopener noreferrer'; a.rel = "noopener noreferrer";
}); });
} else { } else {
div.textContent = text; div.textContent = text;
@ -381,38 +457,53 @@ const RASTER_RE = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i;
// render as an <img>; every other file stays raw text in a <pre>. // render as an <img>; every other file stays raw text in a <pre>.
async function openFilePanel(path) { async function openFilePanel(path) {
if (RASTER_RE.test(path)) { if (RASTER_RE.test(path)) {
const img = el('img', { class: 'img-preview', alt: path }); const img = el("img", { class: "img-preview", alt: path });
img.addEventListener('error', () => { img.addEventListener("error", () => {
img.replaceWith(el('pre', { class: 'path-preview-body' }, img.replaceWith(
'(could not load image — it may be missing or over the preview size cap)')); el(
"pre",
{ class: "path-preview-body" },
"(could not load image — it may be missing or over the preview size cap)",
),
);
}); });
img.src = '/api/state-file?path=' + encodeURIComponent(path); img.src = "/api/state-file?path=" + encodeURIComponent(path);
sidePanel.open('↳ ' + path, img); sidePanel.open("↳ " + path, img);
return; return;
} }
const isMd = /\.(md|markdown)$/i.test(path); const isMd = /\.(md|markdown)$/i.test(path);
const isSvg = /\.svg$/i.test(path); const isSvg = /\.svg$/i.test(path);
const view = el('div'); const view = el("div");
view.textContent = '(fetching…)'; view.textContent = "(fetching…)";
sidePanel.open('↳ ' + path, view); sidePanel.open("↳ " + path, view);
try { try {
const text = await fetchStateFile(path); const text = await fetchStateFile(path);
if (isSvg) { if (isSvg) {
view.replaceChildren(buildTabbedPreview(() => svgImage(text), text, 'source')); view.replaceChildren(
buildTabbedPreview(() => svgImage(text), text, "source"),
);
} else if (isMd) { } else if (isMd) {
view.replaceChildren(buildTabbedPreview(() => mdNode(text), text, 'plain')); view.replaceChildren(
buildTabbedPreview(() => mdNode(text), text, "plain"),
);
} else { } else {
view.replaceChildren(el('pre', { class: 'path-preview-body' }, text)); view.replaceChildren(el("pre", { class: "path-preview-body" }, text));
} }
} catch (e) { } catch (e) {
view.textContent = 'error: ' + (e.message || e); view.textContent = "error: " + (e.message || e);
} }
} }
export function makePathLink(path) { export function makePathLink(path) {
const anchor = el('a', { const anchor = el(
href: '#', class: 'path-link', title: 'open ' + path + ' in panel', "a",
}, path); {
anchor.addEventListener('click', (e) => { href: "#",
class: "path-link",
title: "open " + path + " in panel",
},
path,
);
anchor.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
openFilePanel(path); openFilePanel(path);
}); });
@ -453,7 +544,11 @@ export function appendLinkified(parent, text, refs) {
for (const t of tokens) { for (const t of tokens) {
const idx = str.indexOf(t, i); const idx = str.indexOf(t, i);
if (idx === -1) continue; if (idx === -1) continue;
if (bestStart === -1 || idx < bestStart || (idx === bestStart && t.length > bestToken.length)) { if (
bestStart === -1 ||
idx < bestStart ||
(idx === bestStart && t.length > bestToken.length)
) {
bestStart = idx; bestStart = idx;
bestToken = t; bestToken = t;
} }
@ -479,55 +574,63 @@ export function appendLinkified(parent, text, refs) {
// localhost) — on other origins the API is unavailable and we hide // localhost) — on other origins the API is unavailable and we hide
// the controls. // the controls.
export const NOTIF = (() => { export const NOTIF = (() => {
const supported = typeof Notification !== 'undefined'; const supported = typeof Notification !== "undefined";
const MUTED_KEY = 'hyperhive.notify.muted'; const MUTED_KEY = "hyperhive.notify.muted";
const isMuted = () => localStorage.getItem(MUTED_KEY) === '1'; const isMuted = () => localStorage.getItem(MUTED_KEY) === "1";
const setMuted = (v) => v const setMuted = (v) =>
? localStorage.setItem(MUTED_KEY, '1') v
: localStorage.removeItem(MUTED_KEY); ? localStorage.setItem(MUTED_KEY, "1")
: localStorage.removeItem(MUTED_KEY);
function renderControls() { function renderControls() {
const enable = $('notif-enable'); const enable = $("notif-enable");
const mute = $('notif-mute'); const mute = $("notif-mute");
const unmute = $('notif-unmute'); const unmute = $("notif-unmute");
const status = $('notif-status'); const status = $("notif-status");
if (!enable || !mute || !unmute || !status) return; if (!enable || !mute || !unmute || !status) return;
if (!supported) { if (!supported) {
enable.hidden = mute.hidden = unmute.hidden = true; enable.hidden = mute.hidden = unmute.hidden = true;
status.hidden = false; status.hidden = false;
status.textContent = 'notifications unsupported in this browser'; status.textContent = "notifications unsupported in this browser";
return; return;
} }
const perm = Notification.permission; const perm = Notification.permission;
enable.hidden = perm === 'granted'; enable.hidden = perm === "granted";
mute.hidden = perm !== 'granted' || isMuted(); mute.hidden = perm !== "granted" || isMuted();
unmute.hidden = perm !== 'granted' || !isMuted(); unmute.hidden = perm !== "granted" || !isMuted();
status.hidden = perm !== 'denied'; status.hidden = perm !== "denied";
if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings'; if (perm === "denied")
status.textContent = "notifications blocked — grant in site settings";
} }
function bind() { function bind() {
const enable = $('notif-enable'); const enable = $("notif-enable");
const mute = $('notif-mute'); const mute = $("notif-mute");
const unmute = $('notif-unmute'); const unmute = $("notif-unmute");
if (!supported || !enable || !mute || !unmute) return; if (!supported || !enable || !mute || !unmute) return;
enable.addEventListener('click', async () => { enable.addEventListener("click", async () => {
await Notification.requestPermission(); await Notification.requestPermission();
renderControls(); renderControls();
}); });
mute.addEventListener('click', () => { setMuted(true); renderControls(); }); mute.addEventListener("click", () => {
unmute.addEventListener('click', () => { setMuted(false); renderControls(); }); setMuted(true);
renderControls();
});
unmute.addEventListener("click", () => {
setMuted(false);
renderControls();
});
renderControls(); renderControls();
} }
function show(title, body, tag) { function show(title, body, tag) {
if (!supported) { if (!supported) {
console.debug('notify: Notification API not supported'); console.debug("notify: Notification API not supported");
return; return;
} }
if (Notification.permission !== 'granted') { if (Notification.permission !== "granted") {
console.debug('notify: permission not granted', Notification.permission); console.debug("notify: permission not granted", Notification.permission);
return; return;
} }
if (isMuted()) { if (isMuted()) {
console.debug('notify: muted'); console.debug("notify: muted");
return; return;
} }
try { try {
@ -537,12 +640,15 @@ export const NOTIF = (() => {
// because that one tag would replace itself on every fire. // because that one tag would replace itself on every fire.
const n = new Notification(title, { const n = new Notification(title, {
body, body,
tag: tag || ('hyperhive:' + Date.now()), tag: tag || "hyperhive:" + Date.now(),
}); });
n.onclick = () => { window.focus(); n.close(); }; n.onclick = () => {
console.debug('notify: shown', title, 'tag=', tag); window.focus();
n.close();
};
console.debug("notify: shown", title, "tag=", tag);
} catch (err) { } catch (err) {
console.warn('notification show failed', err); console.warn("notification show failed", err);
} }
} }
return { bind, show, renderControls }; return { bind, show, renderControls };
@ -564,11 +670,11 @@ export const NOTIF = (() => {
// page's existing chrome element; pages without a chrome (e.g. the H0M3 // page's existing chrome element; pages without a chrome (e.g. the H0M3
// hub) get a banner-only sticky region at the top of <body>. // hub) get a banner-only sticky region at the top of <body>.
function ensureStickyTop() { function ensureStickyTop() {
let top = document.querySelector('.sticky-top'); let top = document.querySelector(".sticky-top");
if (top) return top; if (top) return top;
top = document.createElement('div'); top = document.createElement("div");
top.className = 'sticky-top'; top.className = "sticky-top";
const chrome = document.querySelector('.dashboard-chrome, .page-header'); const chrome = document.querySelector(".dashboard-chrome, .page-header");
if (chrome && chrome.parentNode) { if (chrome && chrome.parentNode) {
chrome.parentNode.insertBefore(top, chrome); chrome.parentNode.insertBefore(top, chrome);
top.append(chrome); top.append(chrome);
@ -579,12 +685,12 @@ function ensureStickyTop() {
} }
function ensureServerWarningsBar() { function ensureServerWarningsBar() {
let bar = document.getElementById('server-warnings'); let bar = document.getElementById("server-warnings");
if (!bar) { if (!bar) {
bar = document.createElement('div'); bar = document.createElement("div");
bar.id = 'server-warnings'; bar.id = "server-warnings";
bar.className = 'server-warnings'; bar.className = "server-warnings";
bar.setAttribute('role', 'alert'); bar.setAttribute("role", "alert");
bar.hidden = true; bar.hidden = true;
ensureStickyTop().prepend(bar); ensureStickyTop().prepend(bar);
} }
@ -601,10 +707,12 @@ export function renderServerWarnings(warnings) {
return; return;
} }
for (const w of warnings) { for (const w of warnings) {
const row = el('div', { const row = el("div", {
class: 'server-warn server-warn-' + (w && w.level === 'crit' ? 'crit' : 'warn'), class:
"server-warn server-warn-" +
(w && w.level === "crit" ? "crit" : "warn"),
}); });
appendText(row, '⚠ ' + ((w && w.message) || '')); appendText(row, "⚠ " + ((w && w.message) || ""));
bar.append(row); bar.append(row);
} }
bar.hidden = false; bar.hidden = false;
@ -616,8 +724,10 @@ export function renderServerWarnings(warnings) {
/// live updates instead. /// live updates instead.
export function initServerWarnings() { export function initServerWarnings() {
ensureServerWarningsBar(); ensureServerWarningsBar();
fetch('/api/state') fetch("/api/state")
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.then((s) => renderServerWarnings(s && s.server_warnings)) .then((s) => renderServerWarnings(s && s.server_warnings))
.catch(() => { /* non-fatal: no banner if the snapshot is unreachable */ }); .catch(() => {
/* non-fatal: no banner if the snapshot is unreachable */
});
} }

View file

@ -20,7 +20,9 @@ body.core-shell {
/* createTabStrip toggles the native `hidden` attribute on inactive /* createTabStrip toggles the native `hidden` attribute on inactive
panes; ensure it wins over any inherited display. */ panes; ensure it wins over any inherited display. */
.core-pane[hidden] { display: none; } .core-pane[hidden] {
display: none;
}
/* K3PT ST4T3 container cards /* K3PT ST4T3 container cards
core.html doesn't load dashboard.css (that's the operator SPA), core.html doesn't load dashboard.css (that's the operator SPA),
@ -57,7 +59,9 @@ body.core-shell {
font-size: 1.05em; font-size: 1.05em;
font-weight: bold; font-weight: bold;
} }
.container-row.tombstone .head .name { color: var(--muted); } .container-row.tombstone .head .name {
color: var(--muted);
}
.container-row .head .meta { .container-row .head .meta {
margin-left: auto; margin-left: auto;
font-size: 0.88em; font-size: 0.88em;
@ -68,4 +72,7 @@ body.core-shell {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.4em; gap: 0.4em;
} }
.container-row .actions form.inline { display: inline-block; margin: 0; } .container-row .actions form.inline {
display: inline-block;
margin: 0;
}

View file

@ -1,18 +1,17 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // C0R3</title> <title>hyperhive // C0R3</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css"> <link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css"> <link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/core.css"> <link rel="stylesheet" href="/static/core.css" />
</head> </head>
<body class="core-shell"> <body class="core-shell">
<!-- C0R3: the host/coordinator surface. Standalone page (served at
<!-- C0R3: the host/coordinator surface. Standalone page (served at
/core.html) carved out of the dashboard's old SYST3M tab so the /core.html) carved out of the dashboard's old SYST3M tab so the
dashboard tab strip stays lean. Same minimal chrome as dashboard tab strip stays lean. Same minimal chrome as
/logs.html — a `← home` back-link to the H0M3 hub + a /logs.html — a `← home` back-link to the H0M3 hub + a
@ -24,41 +23,62 @@
(`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`) have no (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`) have no
dashboard panel — `hivectl stop`/`start`/`restart` is the only dashboard panel — `hivectl stop`/`start`/`restart` is the only
control surface. --> control surface. -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← home</a>
<hive-tab-strip class="hive-tabbar core-tabbar" id="core-tabbar" prefix="core" <hive-tab-strip
role="tablist"></hive-tab-strip> class="hive-tabbar core-tabbar"
</header> id="core-tabbar"
prefix="core"
role="tablist"
></hive-tab-strip>
</header>
<main class="core-main"> <main class="core-main">
<!-- K3PT ST4T3: tombstoned-agent kept state + stale permission entries.
<!-- K3PT ST4T3: tombstoned-agent kept state + stale permission entries.
tombstones-section shows destroyed agents (purge button). tombstones-section shows destroyed agents (purge button).
tombstones-stale-perms is lazy-loaded on tab activation and shows tombstones-stale-perms is lazy-loaded on tab activation and shows
agents with explicit capability/tool-group entries but no live agents with explicit capability/tool-group entries but no live
container (typically renamed/deleted agents whose JSON entries container (typically renamed/deleted agents whose JSON entries
persisted). Each stale entry gets a "✕ clear perms" button. --> persisted). Each stale entry gets a "✕ clear perms" button. -->
<section class="core-pane" id="core-pane-kept" data-tab-pane="kept" <section
role="tabpanel" aria-labelledby="core-tab-kept"> class="core-pane"
<p class="meta">kept state from previously tombstoned agents — recreating an agent with the same name reuses it.</p> id="core-pane-kept"
<div id="tombstones-section"> data-tab-pane="kept"
<p class="meta">loading…</p> role="tabpanel"
</div> aria-labelledby="core-tab-kept"
<div id="tombstones-stale-perms"></div> >
</section> <p class="meta">
kept state from previously tombstoned agents — recreating an agent
with the same name reuses it.
</p>
<div id="tombstones-section">
<p class="meta">loading…</p>
</div>
<div id="tombstones-stale-perms"></div>
</section>
<!-- C0NT41N3R L04D: live cpu + memory per agent container, from <!-- C0NT41N3R L04D: live cpu + memory per agent container, from
cgroup v2 on the host. Polled only while this sub-tab is active. --> cgroup v2 on the host. Polled only while this sub-tab is active. -->
<section class="core-pane" id="core-pane-load" data-tab-pane="load" <section
role="tabpanel" aria-labelledby="core-tab-load"> class="core-pane"
<p class="meta">live cpu + memory per agent container, from cgroup v2 on the host. cpu is % of total host capacity (all cores), sampled over ~200ms each refresh; polled every 5s while this tab is open. network is omitted on purpose — agents share the host netns, so there is no per-container counter (see <code>docs/networking/network.md</code>).</p> id="core-pane-load"
<div id="container-load-section"> data-tab-pane="load"
<p class="meta">loading…</p> role="tabpanel"
</div> aria-labelledby="core-tab-load"
</section> >
<p class="meta">
live cpu + memory per agent container, from cgroup v2 on the host. cpu
is % of total host capacity (all cores), sampled over ~200ms each
refresh; polled every 5s while this tab is open. network is omitted on
purpose — agents share the host netns, so there is no per-container
counter (see <code>docs/networking/network.md</code>).
</p>
<div id="container-load-section">
<p class="meta">loading…</p>
</div>
</section>
</main>
</main> <script type="module" src="/static/core.js" defer></script>
</body>
<script type="module" src="/static/core.js" defer></script>
</body>
</html> </html>

View file

@ -9,10 +9,10 @@
// (the same broker event channel the dashboard uses), maintaining its // (the same broker event channel the dashboard uses), maintaining its
// own copy of the tombstones state. // own copy of the tombstones state.
import { $, form, openStream, initServerWarnings } from './common.js'; import { $, form, openStream, initServerWarnings } from "./common.js";
import { el } from '@hive/shared/dom.js'; import { el } from "@hive/shared/dom.js";
import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js'; import { asyncBtn, bindAsyncForms } from "@hive/shared/forms.js";
import '@hive/shared/hive-tab-strip.js'; import "@hive/shared/hive-tab-strip.js";
// ─── derived state (own copies; this bundle has its own runtime) ────────── // ─── derived state (own copies; this bundle has its own runtime) ──────────
let tombstonesState = []; let tombstonesState = [];
@ -26,60 +26,75 @@ function syncFromSnapshot(s) {
// ─── kept state (tombstones) ────────────────────────────────────────────── // ─── kept state (tombstones) ──────────────────────────────────────────────
function renderTombstones(s) { function renderTombstones(s) {
const root = $('tombstones-section'); const root = $("tombstones-section");
if (!root) return; if (!root) return;
root.replaceChildren(); root.replaceChildren();
if (!s.tombstones || !s.tombstones.length) { if (!s.tombstones || !s.tombstones.length) {
root.append(el('p', { class: 'empty' }, 'no kept state — clean')); root.append(el("p", { class: "empty" }, "no kept state — clean"));
return; return;
} }
const fmtBytes = (n) => { const fmtBytes = (n) => {
if (n < 1024) return n + ' B'; if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB'; if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
}; };
const fmtAgeDays = (ts) => { const fmtAgeDays = (ts) => {
if (!ts) return '?'; if (!ts) return "?";
const d = Math.floor((Date.now() / 1000 - ts) / 86400); const d = Math.floor((Date.now() / 1000 - ts) / 86400);
if (d <= 0) return 'today'; if (d <= 0) return "today";
if (d === 1) return '1 day ago'; if (d === 1) return "1 day ago";
return d + ' days ago'; return d + " days ago";
}; };
// Only shown alongside actual rows — a caveat over an empty list is noise. // Only shown alongside actual rows — a caveat over an empty list is noise.
// Wording is deliberately about what the list *is* rather than what it // Wording is deliberately about what the list *is* rather than what it
// isn't: nothing records a destroy, so "container absent" is the only thing // isn't: nothing records a destroy, so "container absent" is the only thing
// the backend can actually tell. // the backend can actually tell.
const warn = el('hive-warn', { level: 'warning' }); const warn = el("hive-warn", { level: "warning" });
warn.append( warn.append(
el('strong', {}, 'shows every agent whose container is absent'), el("strong", {}, "shows every agent whose container is absent"),
' — not only destroyed ones. An agent part-way through being spawned ' + " — not only destroyed ones. An agent part-way through being spawned " +
'looks identical here, because nothing records a destroy. Check it is ' + "looks identical here, because nothing records a destroy. Check it is " +
'really gone before you PURG3.', "really gone before you PURG3.",
); );
root.append(warn); root.append(warn);
const ul = el('ul', { class: 'containers' }); const ul = el("ul", { class: "containers" });
for (const t of s.tombstones) { for (const t of s.tombstones) {
const li = el('li', { class: 'container-row tombstone' }); const li = el("li", { class: "container-row tombstone" });
const head = el('div', { class: 'head' }); const head = el("div", { class: "head" });
head.append( head.append(
el('span', { class: 'name' }, t.name), el("span", { class: "name" }, t.name),
// Was `destroyed`, which the backend cannot actually know — see the // Was `destroyed`, which the backend cannot actually know — see the
// caveat above. `offline` is what the absence of a container proves. // caveat above. `offline` is what the absence of a container proves.
el('span', { class: 'hive-pill-sm badge-muted' }, 'offline'), el("span", { class: "hive-pill-sm badge-muted" }, "offline"),
);
if (t.has_creds)
head.append(
el("span", { class: "hive-pill-sm badge-muted" }, "creds kept"),
);
head.append(
el(
"span",
{ class: "meta" },
`${fmtBytes(t.state_bytes)} · ${fmtAgeDays(t.last_seen)}`,
),
); );
if (t.has_creds) head.append(el('span', { class: 'hive-pill-sm badge-muted' }, 'creds kept'));
head.append(el('span', { class: 'meta' },
`${fmtBytes(t.state_bytes)} · ${fmtAgeDays(t.last_seen)}`));
li.append(head); li.append(head);
const actions = el('div', { class: 'actions' }); const actions = el("div", { class: "actions" });
actions.append(form( actions.append(
'/api/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3', form(
'PURGE ' + t.name + '? config history, claude creds, ' "/api/purge-tombstone/" + t.name,
+ 'and notes are all WIPED. no undo.', "btn-destroy",
{}, { noRefresh: true }, "PURG3",
)); "PURGE " +
t.name +
"? config history, claude creds, " +
"and notes are all WIPED. no undo.",
{},
{ noRefresh: true },
),
);
li.append(actions); li.append(actions);
ul.append(li); ul.append(li);
} }
@ -98,37 +113,53 @@ let stalePermsLoaded = false;
function renderStalePerms(root, ghosts) { function renderStalePerms(root, ghosts) {
root.replaceChildren(); root.replaceChildren();
if (!ghosts.length) return; if (!ghosts.length) return;
root.append(el('p', { class: 'tombstones-stale-heading' }, 'stale permission entries')); root.append(
root.append(el('p', { class: 'meta' }, el("p", { class: "tombstones-stale-heading" }, "stale permission entries"),
'agents with explicit capability or tool-group entries but no live container ' );
+ 'or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).')); root.append(
const errP = el('p', { class: 'tombstones-stale-err', hidden: true }); el(
const ul = el('ul', { class: 'tombstones-stale-list' }); "p",
{ class: "meta" },
"agents with explicit capability or tool-group entries but no live container " +
"or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).",
),
);
const errP = el("p", { class: "tombstones-stale-err", hidden: true });
const ul = el("ul", { class: "tombstones-stale-list" });
for (const name of ghosts) { for (const name of ghosts) {
const li = el('li', { class: 'tombstones-stale-row' }); const li = el("li", { class: "tombstones-stale-row" });
li.append(el('span', { class: 'tombstones-stale-name' }, name)); li.append(el("span", { class: "tombstones-stale-name" }, name));
li.append(el('span', { class: 'hive-pill-sm badge-muted' }, 'stale perms')); li.append(el("span", { class: "hive-pill-sm badge-muted" }, "stale perms"));
const btn = el('button', { const btn = el(
type: 'button', "button",
class: 'btn btn-destroy', {
title: 'remove explicit capability and tool-group entries for ' + name, type: "button",
}, '✕ clear perms'); class: "btn btn-destroy",
btn.addEventListener('click', () => asyncBtn(btn, async () => { title: "remove explicit capability and tool-group entries for " + name,
errP.hidden = true; },
try { "✕ clear perms",
const resp = await fetch('/api/permissions/' + encodeURIComponent(name), { method: 'DELETE' }); );
if (!resp.ok) { btn.addEventListener("click", () =>
const msg = await resp.text().catch(() => String(resp.status)); asyncBtn(btn, async () => {
errP.textContent = 'failed to clear perms for ' + name + ': ' + msg; errP.hidden = true;
try {
const resp = await fetch(
"/api/permissions/" + encodeURIComponent(name),
{ method: "DELETE" },
);
if (!resp.ok) {
const msg = await resp.text().catch(() => String(resp.status));
errP.textContent = "failed to clear perms for " + name + ": " + msg;
errP.hidden = false;
return;
}
await fetchAndRenderStalePerms();
} catch (err) {
errP.textContent = "failed to clear perms for " + name + ": " + err;
errP.hidden = false; errP.hidden = false;
return;
} }
await fetchAndRenderStalePerms(); }),
} catch (err) { );
errP.textContent = 'failed to clear perms for ' + name + ': ' + err;
errP.hidden = false;
}
}));
li.append(btn); li.append(btn);
ul.append(li); ul.append(li);
} }
@ -140,16 +171,18 @@ function renderStalePerms(root, ghosts) {
// but are absent from both the live roster and the kept-state tombstones. // but are absent from both the live roster and the kept-state tombstones.
// One call, no client-side roster cache, always authoritative. // One call, no client-side roster cache, always authoritative.
async function fetchAndRenderStalePerms() { async function fetchAndRenderStalePerms() {
const root = $('tombstones-stale-perms'); const root = $("tombstones-stale-perms");
if (!root) return; if (!root) return;
try { try {
const resp = await fetch('/api/permissions/stale'); const resp = await fetch("/api/permissions/stale");
if (!resp.ok) throw new Error('http ' + resp.status); if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json(); const data = await resp.json();
renderStalePerms(root, data.stale || []); renderStalePerms(root, data.stale || []);
} catch (err) { } catch (err) {
root.replaceChildren(); root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'failed to load stale perm data: ' + err)); root.append(
el("p", { class: "meta" }, "failed to load stale perm data: " + err),
);
} }
stalePermsLoaded = true; stalePermsLoaded = true;
} }
@ -158,21 +191,25 @@ async function fetchAndRenderStalePerms() {
let containerLoadTimer = null; let containerLoadTimer = null;
function cloadFmtBytes(n) { function cloadFmtBytes(n) {
if (!Number.isFinite(n) || n <= 0) return '0'; if (!Number.isFinite(n) || n <= 0) return "0";
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; const u = ["B", "KiB", "MiB", "GiB", "TiB"];
let i = 0; let v = n; let i = 0;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i += 1; } let v = n;
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + ' ' + u[i]; while (v >= 1024 && i < u.length - 1) {
v /= 1024;
i += 1;
}
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + " " + u[i];
} }
function cloadMeter(pct) { function cloadMeter(pct) {
const p = Math.max(0, Math.min(100, pct)); const p = Math.max(0, Math.min(100, pct));
const cls = p >= 90 ? 'hot' : (p >= 70 ? 'warn' : ''); const cls = p >= 90 ? "hot" : p >= 70 ? "warn" : "";
const m = document.createElement('span'); const m = document.createElement("span");
m.className = 'cload-meter'; m.className = "cload-meter";
m.title = p.toFixed(0) + '%'; m.title = p.toFixed(0) + "%";
const fill = document.createElement('span'); const fill = document.createElement("span");
fill.className = 'fill' + (cls ? ' ' + cls : ''); fill.className = "fill" + (cls ? " " + cls : "");
fill.style.width = p + '%'; fill.style.width = p + "%";
m.append(fill); m.append(fill);
return m; return m;
} }
@ -183,12 +220,14 @@ let lastLoadRows = [];
function renderContainerLoad(rows) { function renderContainerLoad(rows) {
lastLoadRows = rows; lastLoadRows = rows;
const root = $('container-load-section'); const root = $("container-load-section");
if (!root) return; if (!root) return;
if (!Array.isArray(rows) || rows.length === 0) { if (!Array.isArray(rows) || rows.length === 0) {
root.replaceChildren(); root.replaceChildren();
const p = document.createElement('p'); p.className = 'meta'; const p = document.createElement("p");
p.textContent = 'no running agent containers'; root.append(p); p.className = "meta";
p.textContent = "no running agent containers";
root.append(p);
return; return;
} }
@ -197,83 +236,105 @@ function renderContainerLoad(rows) {
// resolved by the server) next to the live cgroup readings. // resolved by the server) next to the live cgroup readings.
const cvByName = new Map(containersState.map((c) => [c.name, c])); const cvByName = new Map(containersState.map((c) => [c.name, c]));
const table = document.createElement('table'); const table = document.createElement("table");
table.className = 'hive-stats-table'; table.className = "hive-stats-table";
// "cpu cap" / "mem cap" show the configured ceilings from ContainerView // "cpu cap" / "mem cap" show the configured ceilings from ContainerView
// (effective drop-in values, take effect on next start/restart). // (effective drop-in values, take effect on next start/restart).
// "limit" remains the live cgroup memory ceiling from /api/container-resources. // "limit" remains the live cgroup memory ceiling from /api/container-resources.
table.innerHTML = '<thead><tr><th>agent</th><th>cpu</th><th>memory</th>' table.innerHTML =
+ '<th>peak</th><th>limit</th><th>disk</th>' "<thead><tr><th>agent</th><th>cpu</th><th>memory</th>" +
+ '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">cpu cap</th>' "<th>peak</th><th>limit</th><th>disk</th>" +
+ '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">mem cap</th>' '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">cpu cap</th>' +
+ '<th></th></tr></thead>'; '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">mem cap</th>' +
const tb = document.createElement('tbody'); "<th></th></tr></thead>";
const tb = document.createElement("tbody");
for (const r of rows) { for (const r of rows) {
const cv = cvByName.get(r.name); const cv = cvByName.get(r.name);
const tr = document.createElement('tr'); const tr = document.createElement("tr");
const name = document.createElement('td'); name.textContent = r.name; tr.append(name); const name = document.createElement("td");
const cpu = document.createElement('td'); cpu.className = 'num'; name.textContent = r.name;
cpu.append((Number(r.cpu_pct) || 0).toFixed(1) + '%', cloadMeter(Number(r.cpu_pct) || 0)); tr.append(name);
const cpu = document.createElement("td");
cpu.className = "num";
cpu.append(
(Number(r.cpu_pct) || 0).toFixed(1) + "%",
cloadMeter(Number(r.cpu_pct) || 0),
);
tr.append(cpu); tr.append(cpu);
const mem = document.createElement('td'); mem.className = 'num'; const mem = document.createElement("td");
mem.className = "num";
const memCur = Number(r.mem_current_bytes) || 0; const memCur = Number(r.mem_current_bytes) || 0;
if (r.mem_max_bytes) { if (r.mem_max_bytes) {
mem.append(cloadFmtBytes(memCur), cloadMeter(100 * memCur / r.mem_max_bytes)); mem.append(
cloadFmtBytes(memCur),
cloadMeter((100 * memCur) / r.mem_max_bytes),
);
} else { } else {
mem.textContent = cloadFmtBytes(memCur); mem.textContent = cloadFmtBytes(memCur);
} }
tr.append(mem); tr.append(mem);
const peak = document.createElement('td'); peak.className = 'num'; const peak = document.createElement("td");
peak.textContent = r.mem_peak_bytes ? cloadFmtBytes(Number(r.mem_peak_bytes)) : '—'; peak.className = "num";
peak.textContent = r.mem_peak_bytes
? cloadFmtBytes(Number(r.mem_peak_bytes))
: "—";
tr.append(peak); tr.append(peak);
const lim = document.createElement('td'); lim.className = 'num'; const lim = document.createElement("td");
lim.textContent = r.mem_max_bytes ? cloadFmtBytes(Number(r.mem_max_bytes)) : '∞'; lim.className = "num";
lim.textContent = r.mem_max_bytes
? cloadFmtBytes(Number(r.mem_max_bytes))
: "∞";
tr.append(lim); tr.append(lim);
// Disk: on-disk footprint (state dir + container writable rootfs, shared // Disk: on-disk footprint (state dir + container writable rootfs, shared
// nix store excluded). Sampled out-of-band every few minutes server-side, // nix store excluded). Sampled out-of-band every few minutes server-side,
// so it's null until the first sample lands — show an em-dash then. // so it's null until the first sample lands — show an em-dash then.
const disk = document.createElement('td'); disk.className = 'num'; const disk = document.createElement("td");
disk.title = 'state dir + container writable rootfs (shared nix store excluded); sampled every few minutes'; disk.className = "num";
disk.textContent = (r.disk_bytes != null) ? cloadFmtBytes(Number(r.disk_bytes)) : '—'; disk.title =
"state dir + container writable rootfs (shared nix store excluded); sampled every few minutes";
disk.textContent =
r.disk_bytes != null ? cloadFmtBytes(Number(r.disk_bytes)) : "—";
tr.append(disk); tr.append(disk);
// Configured CPU / memory caps from ContainerView (resolved effective // Configured CPU / memory caps from ContainerView (resolved effective
// values: per-agent override when set, hive-wide default otherwise). // values: per-agent override when set, hive-wide default otherwise).
const cpuCap = document.createElement('td'); cpuCap.className = 'num cload-cap'; const cpuCap = document.createElement("td");
cpuCap.title = 'configured ceiling — takes effect on next start'; cpuCap.className = "num cload-cap";
cpuCap.textContent = cv?.cpu_quota || '—'; cpuCap.title = "configured ceiling — takes effect on next start";
cpuCap.textContent = cv?.cpu_quota || "—";
tr.append(cpuCap); tr.append(cpuCap);
const memCap = document.createElement('td'); memCap.className = 'num cload-cap'; const memCap = document.createElement("td");
memCap.title = 'configured ceiling — takes effect on next start'; memCap.className = "num cload-cap";
memCap.textContent = cv?.memory_max || '—'; memCap.title = "configured ceiling — takes effect on next start";
memCap.textContent = cv?.memory_max || "—";
tr.append(memCap); tr.append(memCap);
// S3T button toggles the inline edit row for this agent. // S3T button toggles the inline edit row for this agent.
const actTd = document.createElement('td'); const actTd = document.createElement("td");
const setBtn = document.createElement('button'); const setBtn = document.createElement("button");
setBtn.type = 'button'; setBtn.type = "button";
setBtn.className = 'btn btn-sm cload-set-btn'; setBtn.className = "btn btn-sm cload-set-btn";
setBtn.textContent = 'S3T'; setBtn.textContent = "S3T";
setBtn.title = 'set CPU / memory cap for ' + r.name; setBtn.title = "set CPU / memory cap for " + r.name;
tr.append(actTd); tr.append(actTd);
actTd.append(setBtn); actTd.append(setBtn);
tb.append(tr); tb.append(tr);
// Inline edit row (hidden by default, toggled by the S3T button). // Inline edit row (hidden by default, toggled by the S3T button).
const editRow = document.createElement('tr'); const editRow = document.createElement("tr");
editRow.className = 'cload-edit-row'; editRow.className = "cload-edit-row";
editRow.hidden = true; editRow.hidden = true;
const editTd = document.createElement('td'); const editTd = document.createElement("td");
editTd.colSpan = 9; editTd.colSpan = 9;
editTd.className = 'cload-edit-cell'; editTd.className = "cload-edit-cell";
const editForm = document.createElement('form'); const editForm = document.createElement("form");
editForm.className = 'cload-edit-form'; editForm.className = "cload-edit-form";
editForm.addEventListener('submit', async (e) => { editForm.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const cpuInput = editForm.querySelector('.cload-cpu-input'); const cpuInput = editForm.querySelector(".cload-cpu-input");
const memInput = editForm.querySelector('.cload-mem-input'); const memInput = editForm.querySelector(".cload-mem-input");
const errSpan = editForm.querySelector('.cload-edit-err'); const errSpan = editForm.querySelector(".cload-edit-err");
const submitBtn = editForm.querySelector('[type="submit"]'); const submitBtn = editForm.querySelector('[type="submit"]');
errSpan.hidden = true; errSpan.hidden = true;
submitBtn.disabled = true; submitBtn.disabled = true;
@ -283,11 +344,13 @@ function renderContainerLoad(rows) {
memory_max: memInput.value.trim(), memory_max: memInput.value.trim(),
}); });
const resp = await fetch( const resp = await fetch(
'/api/resource-limits/' + encodeURIComponent(r.name), "/api/resource-limits/" + encodeURIComponent(r.name),
{ method: 'POST', body }, { method: "POST", body },
); );
if (!resp.ok) { if (!resp.ok) {
errSpan.textContent = await resp.text().catch(() => 'error ' + resp.status); errSpan.textContent = await resp
.text()
.catch(() => "error " + resp.status);
errSpan.hidden = false; errSpan.hidden = false;
return; return;
} }
@ -295,7 +358,7 @@ function renderContainerLoad(rows) {
// ContainerView data (cpu_quota/memory_max) to containersState, // ContainerView data (cpu_quota/memory_max) to containersState,
// triggering a re-render of the cap columns via refreshContainerLoad. // triggering a re-render of the cap columns via refreshContainerLoad.
editRow.hidden = true; editRow.hidden = true;
setBtn.textContent = 'S3T'; setBtn.textContent = "S3T";
} catch (err) { } catch (err) {
errSpan.textContent = String(err); errSpan.textContent = String(err);
errSpan.hidden = false; errSpan.hidden = false;
@ -304,44 +367,50 @@ function renderContainerLoad(rows) {
} }
}); });
const cpuLabel = document.createElement('label'); const cpuLabel = document.createElement("label");
cpuLabel.className = 'cload-edit-label'; cpuLabel.className = "cload-edit-label";
cpuLabel.textContent = 'cpu quota'; cpuLabel.textContent = "cpu quota";
const cpuInput = document.createElement('input'); const cpuInput = document.createElement("input");
cpuInput.type = 'text'; cpuInput.className = 'cload-cpu-input'; cpuInput.type = "text";
cpuInput.placeholder = cv ? cv.cpu_quota : 'e.g. 200%'; cpuInput.className = "cload-cpu-input";
cpuInput.title = 'systemd CPUQuota= value (e.g. "400%"). empty = use hive default'; cpuInput.placeholder = cv ? cv.cpu_quota : "e.g. 200%";
cpuInput.title =
'systemd CPUQuota= value (e.g. "400%"). empty = use hive default';
cpuLabel.append(cpuInput); cpuLabel.append(cpuInput);
const memLabel = document.createElement('label'); const memLabel = document.createElement("label");
memLabel.className = 'cload-edit-label'; memLabel.className = "cload-edit-label";
memLabel.textContent = 'mem max'; memLabel.textContent = "mem max";
const memInput = document.createElement('input'); const memInput = document.createElement("input");
memInput.type = 'text'; memInput.className = 'cload-mem-input'; memInput.type = "text";
memInput.placeholder = cv ? cv.memory_max : 'e.g. 8G'; memInput.className = "cload-mem-input";
memInput.title = 'systemd MemoryMax= value (e.g. "8G", "50%", "infinity"). empty = use hive default'; memInput.placeholder = cv ? cv.memory_max : "e.g. 8G";
memInput.title =
'systemd MemoryMax= value (e.g. "8G", "50%", "infinity"). empty = use hive default';
memLabel.append(memInput); memLabel.append(memInput);
const submitBtn = document.createElement('button'); const submitBtn = document.createElement("button");
submitBtn.type = 'submit'; submitBtn.className = 'btn btn-restart cload-save-btn'; submitBtn.type = "submit";
submitBtn.textContent = 'S4V3'; submitBtn.className = "btn btn-restart cload-save-btn";
submitBtn.textContent = "S4V3";
const hintSpan = document.createElement('span'); const hintSpan = document.createElement("span");
hintSpan.className = 'meta cload-edit-hint'; hintSpan.className = "meta cload-edit-hint";
hintSpan.textContent = '↺ restart to apply to a running container'; hintSpan.textContent = "↺ restart to apply to a running container";
const errSpan = document.createElement('span'); const errSpan = document.createElement("span");
errSpan.className = 'cload-edit-err'; errSpan.hidden = true; errSpan.className = "cload-edit-err";
errSpan.hidden = true;
editForm.append(cpuLabel, memLabel, submitBtn, hintSpan, errSpan); editForm.append(cpuLabel, memLabel, submitBtn, hintSpan, errSpan);
editTd.append(editForm); editTd.append(editForm);
editRow.append(editTd); editRow.append(editTd);
tb.append(editRow); tb.append(editRow);
setBtn.addEventListener('click', () => { setBtn.addEventListener("click", () => {
const open = !editRow.hidden; const open = !editRow.hidden;
editRow.hidden = open; editRow.hidden = open;
setBtn.textContent = open ? 'S3T' : '✕'; setBtn.textContent = open ? "S3T" : "✕";
}); });
} }
table.append(tb); table.append(tb);
@ -350,15 +419,17 @@ function renderContainerLoad(rows) {
async function refreshContainerLoad() { async function refreshContainerLoad() {
try { try {
const resp = await fetch('/api/container-resources'); const resp = await fetch("/api/container-resources");
if (!resp.ok) throw new Error('http ' + resp.status); if (!resp.ok) throw new Error("http " + resp.status);
renderContainerLoad(await resp.json()); renderContainerLoad(await resp.json());
} catch (e) { } catch (e) {
const root = $('container-load-section'); const root = $("container-load-section");
if (root) { if (root) {
root.replaceChildren(); root.replaceChildren();
const p = document.createElement('p'); p.className = 'meta'; const p = document.createElement("p");
p.textContent = 'container load fetch failed: ' + e; root.append(p); p.className = "meta";
p.textContent = "container load fetch failed: " + e;
root.append(p);
} }
} }
} }
@ -368,7 +439,10 @@ function startContainerLoadPolling() {
containerLoadTimer = setInterval(refreshContainerLoad, 5000); containerLoadTimer = setInterval(refreshContainerLoad, 5000);
} }
function stopContainerLoadPolling() { function stopContainerLoadPolling() {
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; } if (containerLoadTimer) {
clearInterval(containerLoadTimer);
containerLoadTimer = null;
}
} }
// ─── render-all (cold load + any full re-render) ────────────────────────── // ─── render-all (cold load + any full re-render) ──────────────────────────
@ -412,7 +486,7 @@ const SSE_HANDLERS = {
// purge actions that opt out of `data-no-refresh`). // purge actions that opt out of `data-no-refresh`).
async function refreshState() { async function refreshState() {
try { try {
const resp = await fetch('/api/state'); const resp = await fetch("/api/state");
if (resp.ok) syncFromSnapshot(await resp.json()); if (resp.ok) syncFromSnapshot(await resp.json());
} catch { } catch {
// best-effort: the page keeps its last-rendered state // best-effort: the page keeps its last-rendered state
@ -431,18 +505,18 @@ async function init() {
// Hash-routed sub-tab strip; default K3PT ST4T3. Container-load // Hash-routed sub-tab strip; default K3PT ST4T3. Container-load
// polling runs only while the LOAD sub-tab is open (cpu is a short // polling runs only while the LOAD sub-tab is open (cpu is a short
// two-sample read each refresh on the server). // two-sample read each refresh on the server).
document.getElementById('core-tabbar').configure({ document.getElementById("core-tabbar").configure({
tabs: [ tabs: [
{ id: 'kept', label: 'K3PT ST4T3' }, { id: "kept", label: "K3PT ST4T3" },
{ id: 'load', label: 'C0NT41N3R L04D' }, { id: "load", label: "C0NT41N3R L04D" },
], ],
defaultId: 'kept', defaultId: "kept",
onShow: (id) => { onShow: (id) => {
if (id === 'load') startContainerLoadPolling(); if (id === "load") startContainerLoadPolling();
else stopContainerLoadPolling(); else stopContainerLoadPolling();
// Lazy-load stale-perms on first K3PT ST4T3 activation; always // Lazy-load stale-perms on first K3PT ST4T3 activation; always
// re-fetch on subsequent visits in case perms changed. // re-fetch on subsequent visits in case perms changed.
if (id === 'kept') fetchAndRenderStalePerms(); if (id === "kept") fetchAndRenderStalePerms();
}, },
}); });
@ -454,13 +528,17 @@ async function init() {
// (subscription discipline, part 1 of the dashboard-event-stream- // (subscription discipline, part 1 of the dashboard-event-stream-
// split issue). // split issue).
const es = openStream( const es = openStream(
'/api/dashboard/stream?kinds=tombstones_changed,container_state_changed,' + "/api/dashboard/stream?kinds=tombstones_changed,container_state_changed," +
'capabilities_changed,tool_groups_changed', "capabilities_changed,tool_groups_changed",
); );
if (es) { if (es) {
es.onmessage = (e) => { es.onmessage = (e) => {
let ev; let ev;
try { ev = JSON.parse(e.data); } catch { return; } try {
ev = JSON.parse(e.data);
} catch {
return;
}
const h = SSE_HANDLERS[ev.kind]; const h = SSE_HANDLERS[ev.kind];
if (h) h(ev); if (h) h(ev);
}; };

View file

@ -25,9 +25,13 @@ body.cred-shell {
padding: 1.2em 1.25rem 3rem; padding: 1.2em 1.25rem 3rem;
} }
.cred-pane[hidden] { display: none; } .cred-pane[hidden] {
display: none;
}
.gh-status { margin: 0.5rem 0 1.2rem; } .gh-status {
margin: 0.5rem 0 1.2rem;
}
.gh-status-line { .gh-status-line {
display: flex; display: flex;
align-items: center; align-items: center;
@ -39,10 +43,18 @@ body.cred-shell {
border-radius: 50%; border-radius: 50%;
flex: none; flex: none;
} }
.gh-dot.present { background: var(--green); } .gh-dot.present {
.gh-dot.absent { background: var(--muted); } background: var(--green);
.gh-status-text.present { color: var(--green); } }
.gh-status-text.absent { color: var(--muted); } .gh-dot.absent {
background: var(--muted);
}
.gh-status-text.present {
color: var(--green);
}
.gh-status-text.absent {
color: var(--muted);
}
.ma-field { .ma-field {
display: flex; display: flex;
@ -50,7 +62,10 @@ body.cred-shell {
gap: 0.25rem; gap: 0.25rem;
margin: 0.55rem 0; margin: 0.55rem 0;
} }
.ma-field > span { font-size: 0.8rem; color: var(--muted); } .ma-field > span {
font-size: 0.8rem;
color: var(--muted);
}
.ma-field input, .ma-field input,
.ma-field select { .ma-field select {
padding: 0.4rem 0.5rem; padding: 0.4rem 0.5rem;
@ -72,13 +87,28 @@ body.cred-shell {
padding: 0.45rem 0.75rem 0.6rem; padding: 0.45rem 0.75rem 0.6rem;
margin: 0.85rem 0; margin: 0.85rem 0;
} }
.ma-mode legend { font-size: 0.8rem; color: var(--muted); padding: 0 0.3rem; } .ma-mode legend {
.ma-mode label { margin-right: 1.3rem; cursor: pointer; } font-size: 0.8rem;
color: var(--muted);
padding: 0 0.3rem;
}
.ma-mode label {
margin-right: 1.3rem;
cursor: pointer;
}
.ma-modefields { margin: 0.4rem 0; } .ma-modefields {
margin: 0.4rem 0;
}
.ma-list { margin: 0.5rem 0 1.2rem; } .ma-list {
.ma-accounts { list-style: none; padding: 0; margin: 0; } margin: 0.5rem 0 1.2rem;
}
.ma-accounts {
list-style: none;
padding: 0;
margin: 0;
}
.ma-account { .ma-account {
display: flex; display: flex;
align-items: center; align-items: center;
@ -96,10 +126,16 @@ body.cred-shell {
`offline`/`stale` are the amber states (provisioned-not-live / container-down `offline`/`stale` are the amber states (provisioned-not-live / container-down
daemon-down). `absent` = no token. */ daemon-down). `absent` = no token. */
.ma-dot.ok, .ma-dot.ok,
.ma-dot.live { background: var(--green); } .ma-dot.live {
background: var(--green);
}
.ma-dot.offline, .ma-dot.offline,
.ma-dot.stale { background: var(--amber); } .ma-dot.stale {
.ma-dot.absent { background: var(--muted); } background: var(--amber);
}
.ma-dot.absent {
background: var(--muted);
}
/* `live stale-age`: snapshot still says live but the daemon heartbeat stalled /* `live stale-age`: snapshot still says live but the daemon heartbeat stalled
(> ~90s). Keep the green hue but dim + desaturate so it reads "was online, (> ~90s). Keep the green hue but dim + desaturate so it reads "was online,
now uncertain" visually distinct from the solid amber container-down now uncertain" visually distinct from the solid amber container-down
@ -109,23 +145,52 @@ body.cred-shell {
opacity: 0.4; opacity: 0.4;
filter: saturate(0.45); filter: saturate(0.45);
} }
.ma-name { font-weight: 600; color: var(--fg); } .ma-name {
.ma-uid { color: var(--muted); font-size: 0.8rem; margin-left: 0.4em; } font-weight: 600;
.ma-hs { color: var(--muted); font-size: 0.85rem; } color: var(--fg);
.ma-status { margin-left: auto; font-size: 0.8rem; } }
.ma-uid {
color: var(--muted);
font-size: 0.8rem;
margin-left: 0.4em;
}
.ma-hs {
color: var(--muted);
font-size: 0.85rem;
}
.ma-status {
margin-left: auto;
font-size: 0.8rem;
}
.ma-status.ok, .ma-status.ok,
.ma-status.live { color: var(--green); } .ma-status.live {
color: var(--green);
}
.ma-status.offline, .ma-status.offline,
.ma-status.stale { color: var(--amber); } .ma-status.stale {
.ma-status.absent { color: var(--muted); } color: var(--amber);
.ma-status.live.stale-age { color: var(--green); opacity: 0.6; } }
.ma-status.absent {
color: var(--muted);
}
.ma-status.live.stale-age {
color: var(--green);
opacity: 0.6;
}
.ma-result { .ma-result {
margin-top: 0.7rem; margin-top: 0.7rem;
font-size: 0.9rem; font-size: 0.9rem;
min-height: 1.2em; min-height: 1.2em;
} }
.ma-result.ok { color: var(--green); } .ma-result.ok {
.ma-result.err { color: var(--red); } color: var(--green);
}
.ma-result.err {
color: var(--red);
}
.ma-list .err { color: var(--red); font-size: 0.9rem; } .ma-list .err {
color: var(--red);
font-size: 0.9rem;
}

View file

@ -1,156 +1,253 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // CR3D3NTIALS</title> <title>hyperhive // CR3D3NTIALS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css"> <link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css"> <link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/credentials.css"> <link rel="stylesheet" href="/static/credentials.css" />
</head> </head>
<body class="cred-shell"> <body class="cred-shell">
<!-- Minimal chrome: back link + sub-tab strip, same pattern as
<!-- Minimal chrome: back link + sub-tab strip, same pattern as
logs.html (MATRIX / GITHUB instead of AGENT/INFRA/SYSTEM). Back logs.html (MATRIX / GITHUB instead of AGENT/INFRA/SYSTEM). Back
link points to the H0M3 hub (served at /). --> link points to the H0M3 hub (served at /). -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← home</a>
<hive-tab-strip class="hive-tabbar cred-tabbar" id="cred-tabbar" prefix="cred" <hive-tab-strip
role="tablist"></hive-tab-strip> class="hive-tabbar cred-tabbar"
</header> id="cred-tabbar"
prefix="cred"
role="tablist"
></hive-tab-strip>
</header>
<main class="cred-main"> <main class="cred-main">
<!-- Agent picker: shared across both tabs (one agent selected at a
<!-- Agent picker: shared across both tabs (one agent selected at a
time drives both the matrix account list and the github status). --> time drives both the matrix account list and the github status). -->
<h3>◇ agent</h3> <h3>◇ agent</h3>
<label class="ma-field"> <label class="ma-field">
<span>agent</span> <span>agent</span>
<select id="ma-agent"></select> <select id="ma-agent"></select>
</label> </label>
<!-- MATRIX tab: unchanged from the old /matrix-accounts.html, just <!-- MATRIX tab: unchanged from the old /matrix-accounts.html, just
moved under a tab pane. --> moved under a tab pane. -->
<section class="cred-pane" id="cred-pane-matrix" data-tab-pane="matrix" <section
role="tabpanel" aria-labelledby="cred-tab-matrix"> class="cred-pane"
<p class="meta">provision or log in an <strong>external</strong> matrix account for an agent and store its access token. the token is written to the agent's <code>matrixAccounts.&lt;account&gt;.tokenFile</code> by the host coordinator &mdash; it is never displayed back on this page.</p> id="cred-pane-matrix"
data-tab-pane="matrix"
role="tabpanel"
aria-labelledby="cred-tab-matrix"
>
<p class="meta">
provision or log in an <strong>external</strong> matrix account for an
agent and store its access token. the token is written to the agent's
<code>matrixAccounts.&lt;account&gt;.tokenFile</code> by the host
coordinator &mdash; it is never displayed back on this page.
</p>
<h3>◇ provisioned accounts</h3> <h3>◇ provisioned accounts</h3>
<p class="meta">accounts that have a stored token (provision one below to add it here); a config-declared account that hasn't been provisioned yet won't appear until it has a token. status reflects whether a <em>token is stored</em>, not a live session &mdash; a true online/offline indicator is a follow-up that needs the daemon's account registry.</p> <p class="meta">
<div id="ma-list" class="ma-list"><p class="meta">select an agent to see its matrix accounts.</p></div> accounts that have a stored token (provision one below to add it
here); a config-declared account that hasn't been provisioned yet
<h3>◇ provision / log in</h3> won't appear until it has a token. status reflects whether a
<form id="ma-form" class="ma-form" autocomplete="off"> <em>token is stored</em>, not a live session &mdash; a true
<label class="ma-field"> online/offline indicator is a follow-up that needs the daemon's
<span>account name</span> account registry.
<input type="text" name="account" placeholder="e.g. public" required> </p>
</label> <div id="ma-list" class="ma-list">
<label class="ma-field"> <p class="meta">select an agent to see its matrix accounts.</p>
<span>homeserver</span>
<input type="text" name="homeserver" placeholder="https://matrix.org" required>
</label>
<fieldset class="ma-mode">
<legend>login method</legend>
<label><input type="radio" name="mode" value="password" checked> password</label>
<label><input type="radio" name="mode" value="token"> existing token</label>
</fieldset>
<div id="ma-pw-fields" class="ma-modefields">
<label class="ma-field">
<span>user id</span>
<input type="text" name="user_id" placeholder="@user:matrix.org" autocomplete="username">
</label>
<label class="ma-field">
<span>password</span>
<input type="password" name="password" autocomplete="new-password">
</label>
</div> </div>
<div id="ma-token-fields" class="ma-modefields" hidden> <h3>◇ provision / log in</h3>
<form id="ma-form" class="ma-form" autocomplete="off">
<label class="ma-field"> <label class="ma-field">
<span>access token</span> <span>account name</span>
<input type="password" name="token" autocomplete="off"> <input
type="text"
name="account"
placeholder="e.g. public"
required
/>
</label> </label>
<label class="ma-field"> <label class="ma-field">
<span>user id <span class="meta">(optional &mdash; derived via whoami)</span></span> <span>homeserver</span>
<input type="text" name="user_id" placeholder="@user:matrix.org"> <input
type="text"
name="homeserver"
placeholder="https://matrix.org"
required
/>
</label> </label>
</div>
<button type="submit" class="btn btn-spawn">log in &amp; store token</button> <fieldset class="ma-mode">
<p id="ma-result" class="ma-result" aria-live="polite"></p> <legend>login method</legend>
</form> <label
</section> ><input type="radio" name="mode" value="password" checked />
password</label
>
<label
><input type="radio" name="mode" value="token" /> existing
token</label
>
</fieldset>
<!-- GITHUB tab: single-account PAT paste. No login flow — the <div id="ma-pw-fields" class="ma-modefields">
<label class="ma-field">
<span>user id</span>
<input
type="text"
name="user_id"
placeholder="@user:matrix.org"
autocomplete="username"
/>
</label>
<label class="ma-field">
<span>password</span>
<input
type="password"
name="password"
autocomplete="new-password"
/>
</label>
</div>
<div id="ma-token-fields" class="ma-modefields" hidden>
<label class="ma-field">
<span>access token</span>
<input type="password" name="token" autocomplete="off" />
</label>
<label class="ma-field">
<span
>user id
<span class="meta"
>(optional &mdash; derived via whoami)</span
></span
>
<input
type="text"
name="user_id"
placeholder="@user:matrix.org"
/>
</label>
</div>
<button type="submit" class="btn btn-spawn">
log in &amp; store token
</button>
<p id="ma-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
<!-- GITHUB tab: single-account PAT paste. No login flow — the
operator pastes an existing PAT for a dedicated bot account. operator pastes an existing PAT for a dedicated bot account.
Security-warning banner + a link to generate a PAT. --> Security-warning banner + a link to generate a PAT. -->
<section class="cred-pane" id="cred-pane-github" data-tab-pane="github" <section
role="tabpanel" aria-labelledby="cred-tab-github" hidden> class="cred-pane"
<hive-warn level="warning"> id="cred-pane-github"
⚠ use a <strong>dedicated bot account</strong>, not a human's &mdash; data-tab-pane="github"
and a <strong>minimally-scoped</strong> personal access token (only role="tabpanel"
the repos/scopes the agent actually needs, e.g. <code>repo</code> + aria-labelledby="cred-tab-github"
<code>workflow</code>). the container boundary is the enforcement: hidden
anything within the token's scopes is reachable if the agent is >
ever compromised. the token is injected into the agent's state dir <hive-warn level="warning">
and is <strong>never displayed back</strong> on this page. ⚠ use a <strong>dedicated bot account</strong>, not a human's &mdash;
</hive-warn> and a <strong>minimally-scoped</strong> personal access token (only
the repos/scopes the agent actually needs, e.g. <code>repo</code> +
<code>workflow</code>). the container boundary is the enforcement:
anything within the token's scopes is reachable if the agent is ever
compromised. the token is injected into the agent's state dir and is
<strong>never displayed back</strong> on this page.
</hive-warn>
<h3>◇ status</h3> <h3>◇ status</h3>
<div id="gh-status" class="gh-status"><p class="meta">select an agent to see its github credential status.</p></div> <div id="gh-status" class="gh-status">
<p class="meta">
select an agent to see its github credential status.
</p>
</div>
<h3>◇ provision</h3> <h3>◇ provision</h3>
<p class="meta">generate a token at <p class="meta">
<a href="https://github.com/settings/tokens" target="_blank" rel="noopener">github.com/settings/tokens</a> generate a token at
and paste it below. one account per agent &mdash; pasting a new token replaces the stored one.</p> <a
<form id="gh-form" class="ma-form" autocomplete="off"> href="https://github.com/settings/tokens"
<label class="ma-field"> target="_blank"
<span>personal access token</span> rel="noopener"
<input type="password" name="token" autocomplete="off" required> >github.com/settings/tokens</a
</label> >
<button type="submit" class="btn btn-spawn">store token</button> and paste it below. one account per agent &mdash; pasting a new token
<p id="gh-result" class="ma-result" aria-live="polite"></p> replaces the stored one.
</form> </p>
</section> <form id="gh-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>personal access token</span>
<input type="password" name="token" autocomplete="off" required />
</label>
<button type="submit" class="btn btn-spawn">store token</button>
<p id="gh-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
<!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges. <!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges.
Entirely dashboard-provisioned, no host-side nix config &mdash; same Entirely dashboard-provisioned, no host-side nix config &mdash; same
shape as GITHUB plus a base-URL field (like MATRIX's homeserver). shape as GITHUB plus a base-URL field (like MATRIX's homeserver).
The operator creates a token on the external forge themselves The operator creates a token on the external forge themselves
(however that forge lets them) and pastes label + URL + token (however that forge lets them) and pastes label + URL + token
below. No remote account minting/revoking &mdash; purely local. --> below. No remote account minting/revoking &mdash; purely local. -->
<section class="cred-pane" id="cred-pane-forges" data-tab-pane="forges" <section
role="tabpanel" aria-labelledby="cred-tab-forges" hidden> class="cred-pane"
<p class="meta">store a <strong>label + base URL + access token</strong> for an external Forgejo/Gitea/Codeberg-compatible forge, per agent. no account is created on the remote forge &mdash; create the token there yourself first. the token is never displayed back on this page.</p> id="cred-pane-forges"
data-tab-pane="forges"
role="tabpanel"
aria-labelledby="cred-tab-forges"
hidden
>
<p class="meta">
store a <strong>label + base URL + access token</strong> for an
external Forgejo/Gitea/Codeberg-compatible forge, per agent. no
account is created on the remote forge &mdash; create the token there
yourself first. the token is never displayed back on this page.
</p>
<h3>◇ provisioned forges</h3> <h3>◇ provisioned forges</h3>
<div id="ef-list" class="ef-list"><p class="meta">select an agent to see its forge accounts.</p></div> <div id="ef-list" class="ef-list">
<p class="meta">select an agent to see its forge accounts.</p>
</div>
<h3>◇ add forge account</h3> <h3>◇ add forge account</h3>
<form id="ef-form" class="ma-form" autocomplete="off"> <form id="ef-form" class="ma-form" autocomplete="off">
<label class="ma-field"> <label class="ma-field">
<span>label</span> <span>label</span>
<input type="text" name="label" placeholder="e.g. codeberg" required> <input
</label> type="text"
<label class="ma-field"> name="label"
<span>base url</span> placeholder="e.g. codeberg"
<input type="text" name="base_url" placeholder="https://codeberg.org" required> required
</label> />
<label class="ma-field"> </label>
<span>access token</span> <label class="ma-field">
<input type="password" name="token" autocomplete="off" required> <span>base url</span>
</label> <input
<button type="submit" class="btn btn-spawn">store account</button> type="text"
<p id="ef-result" class="ma-result" aria-live="polite"></p> name="base_url"
</form> placeholder="https://codeberg.org"
</section> required
/>
</label>
<label class="ma-field">
<span>access token</span>
<input type="password" name="token" autocomplete="off" required />
</label>
<button type="submit" class="btn btn-spawn">store account</button>
<p id="ef-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
</main>
</main> <script type="module" src="/static/credentials.js" defer></script>
</body>
<script type="module" src="/static/credentials.js" defer></script>
</body>
</html> </html>

View file

@ -19,11 +19,11 @@
// themselves and pastes it in, same trust model as GITHUB. // themselves and pastes it in, same trust model as GITHUB.
// Per-tab detail comments live next to their section below. // Per-tab detail comments live next to their section below.
import { $, esc, fmtAgeSecs, renderServerWarnings } from './common.js'; import { $, esc, fmtAgeSecs, renderServerWarnings } from "./common.js";
import { el } from '@hive/shared/dom.js'; import { el } from "@hive/shared/dom.js";
import '@hive/shared/hive-tab-strip.js'; import "@hive/shared/hive-tab-strip.js";
import { themedConfirm, themedToast } from '@hive/shared/modal.js'; import { themedConfirm, themedToast } from "@hive/shared/modal.js";
import { readApiError, problemMessage } from '@hive/shared/api-error.js'; import { readApiError, problemMessage } from "@hive/shared/api-error.js";
let agents = []; let agents = [];
// agent name → container running (bool), from /api/state. Cross-referenced by // agent name → container running (bool), from /api/state. Cross-referenced by
@ -35,7 +35,7 @@ const containerRunning = new Map();
async function loadState() { async function loadState() {
try { try {
const resp = await fetch('/api/state'); const resp = await fetch("/api/state");
if (!resp.ok) return; if (!resp.ok) return;
const s = await resp.json(); const s = await resp.json();
renderServerWarnings(s.server_warnings); renderServerWarnings(s.server_warnings);
@ -43,7 +43,7 @@ async function loadState() {
// object carrying `.name` + `.running`); there is no top-level `agents` // object carrying `.name` + `.running`); there is no top-level `agents`
// field, so the picker stays compatible with both string + object shapes. // field, so the picker stays compatible with both string + object shapes.
const containers = (s.containers || []) const containers = (s.containers || [])
.map((a) => (typeof a === 'string' ? { name: a } : a)) .map((a) => (typeof a === "string" ? { name: a } : a))
.filter((c) => c && c.name); .filter((c) => c && c.name);
agents = containers.map((c) => c.name).sort(); agents = containers.map((c) => c.name).sort();
containerRunning.clear(); containerRunning.clear();
@ -56,14 +56,14 @@ async function loadState() {
} }
function renderAgentPicker() { function renderAgentPicker() {
const sel = $('ma-agent'); const sel = $("ma-agent");
sel.replaceChildren(); sel.replaceChildren();
if (!agents.length) { if (!agents.length) {
sel.append(el('option', { value: '' }, '— no agents —')); sel.append(el("option", { value: "" }, "— no agents —"));
return; return;
} }
sel.append(el('option', { value: '' }, '— select agent —')); sel.append(el("option", { value: "" }, "— select agent —"));
for (const a of agents) sel.append(el('option', { value: a }, a)); for (const a of agents) sel.append(el("option", { value: a }, a));
} }
// Shape-agnostic error-body parsing (shared by both tabs' submit handlers) // Shape-agnostic error-body parsing (shared by both tabs' submit handlers)
@ -86,91 +86,116 @@ function renderAgentPicker() {
// v1 backend (no `live` field) falls back to token-present rendering. // v1 backend (no `live` field) falls back to token-present rendering.
async function loadAccounts(agent) { async function loadAccounts(agent) {
const list = $('ma-list'); const list = $("ma-list");
if (!agent) { if (!agent) {
list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its matrix accounts.')); list.replaceChildren(
el("p", { class: "meta" }, "select an agent to see its matrix accounts."),
);
return; return;
} }
list.replaceChildren(el('p', { class: 'meta' }, 'loading…')); list.replaceChildren(el("p", { class: "meta" }, "loading…"));
let data; let data;
try { try {
const resp = await fetch('/api/matrix-accounts?agent=' + encodeURIComponent(agent)); const resp = await fetch(
if (!resp.ok) throw new Error('HTTP ' + resp.status); "/api/matrix-accounts?agent=" + encodeURIComponent(agent),
);
if (!resp.ok) throw new Error("HTTP " + resp.status);
data = await resp.json(); data = await resp.json();
} catch (err) { } catch (err) {
list.replaceChildren(el('p', { class: 'err' }, list.replaceChildren(
'could not load accounts: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).')); el(
"p",
{ class: "err" },
"could not load accounts: " +
esc(String(err)) +
" (the backend endpoint may not be deployed yet).",
),
);
return; return;
} }
const accounts = data.accounts || []; const accounts = data.accounts || [];
const asOf = typeof data.as_of_unix === 'number' ? data.as_of_unix : null; const asOf = typeof data.as_of_unix === "number" ? data.as_of_unix : null;
// `false` only when the container is explicitly down; `undefined` (unknown, // `false` only when the container is explicitly down; `undefined` (unknown,
// e.g. a failed /api/state read) is treated as not-down so we never flag a // e.g. a failed /api/state read) is treated as not-down so we never flag a
// false stale. // false stale.
const running = containerRunning.get(agent); const running = containerRunning.get(agent);
// The daemon force-rewrites its snapshot every ~30s, so `as_of_unix` advances // The daemon force-rewrites its snapshot every ~30s, so `as_of_unix` advances
// while it's alive — this is a heartbeat, and a stalled value is meaningful. // while it's alive — this is a heartbeat, and a stalled value is meaningful.
const ageSecs = asOf != null const ageSecs =
? Math.max(0, Math.floor(Date.now() / 1000) - asOf) asOf != null ? Math.max(0, Math.floor(Date.now() / 1000) - asOf) : null;
: null; const asOfText =
const asOfText = asOf != null asOf != null
? 'matrix snapshot · live as of ' + fmtAgeSecs(ageSecs) + ' ago' ? "matrix snapshot · live as of " + fmtAgeSecs(ageSecs) + " ago"
: 'no daemon snapshot yet'; : "no daemon snapshot yet";
// 3 missed ~30s heartbeats. Past this a `live` snapshot whose container is // 3 missed ~30s heartbeats. Past this a `live` snapshot whose container is
// NOT down means the daemon stopped publishing (dead/wedged) — dim its dot. // NOT down means the daemon stopped publishing (dead/wedged) — dim its dot.
const STALE_AGE_SECS = 90; const STALE_AGE_SECS = 90;
const staleByAge = ageSecs != null && ageSecs > STALE_AGE_SECS; const staleByAge = ageSecs != null && ageSecs > STALE_AGE_SECS;
list.replaceChildren(); list.replaceChildren();
if (!accounts.length) { if (!accounts.length) {
list.append(el('p', { class: 'meta' }, 'no matrix accounts configured for this agent.')); list.append(
el(
"p",
{ class: "meta" },
"no matrix accounts configured for this agent.",
),
);
return; return;
} }
const ul = el('ul', { class: 'ma-accounts' }); const ul = el("ul", { class: "ma-accounts" });
for (const acc of accounts) { for (const acc of accounts) {
const present = !!acc.token_present; const present = !!acc.token_present;
// 3-state dot. `live` is absent on the v1 backend (pre BE-4); when // 3-state dot. `live` is absent on the v1 backend (pre BE-4); when
// undefined, fall back to the v1 token-present rendering so the page // undefined, fall back to the v1 token-present rendering so the page
// degrades cleanly before the snapshot backend deploys. // degrades cleanly before the snapshot backend deploys.
let cls; let statusText; let dotTitle; let cls;
let statusText;
let dotTitle;
if (acc.live === undefined) { if (acc.live === undefined) {
cls = present ? 'ok' : 'absent'; cls = present ? "ok" : "absent";
statusText = present ? 'token stored ✓' : 'no token'; statusText = present ? "token stored ✓" : "no token";
dotTitle = present ? 'token stored' : 'no token yet'; dotTitle = present ? "token stored" : "no token yet";
} else if (acc.live && running === false) { } else if (acc.live && running === false) {
// container down ⟹ daemon down ⟹ a "live" snapshot is stale. // container down ⟹ daemon down ⟹ a "live" snapshot is stale.
cls = 'stale'; cls = "stale";
statusText = 'container stopped'; statusText = "container stopped";
dotTitle = 'container is stopped — live status is stale. ' + asOfText; dotTitle = "container is stopped — live status is stale. " + asOfText;
} else if (acc.live && staleByAge) { } else if (acc.live && staleByAge) {
// Snapshot says live, but the heartbeat (snapshot mtime = as_of) hasn't // Snapshot says live, but the heartbeat (snapshot mtime = as_of) hasn't
// advanced in > ~90s while the container is NOT down — the daemon stopped // advanced in > ~90s while the container is NOT down — the daemon stopped
// publishing, so the "live" is no longer trustworthy. Keep the green // publishing, so the "live" is no longer trustworthy. Keep the green
// family but dim it (distinct from the amber container-down 'stale'). // family but dim it (distinct from the amber container-down 'stale').
cls = 'live stale-age'; cls = "live stale-age";
statusText = 'online · no heartbeat'; statusText = "online · no heartbeat";
dotTitle = 'snapshot says live but the daemon heartbeat stalled ' dotTitle =
+ fmtAgeSecs(ageSecs) + ' ago (publishes every ~30s) — likely dead or wedged. ' "snapshot says live but the daemon heartbeat stalled " +
+ asOfText; fmtAgeSecs(ageSecs) +
" ago (publishes every ~30s) — likely dead or wedged. " +
asOfText;
} else if (acc.live) { } else if (acc.live) {
cls = 'live'; cls = "live";
statusText = 'online ✓'; statusText = "online ✓";
dotTitle = asOfText; dotTitle = asOfText;
} else if (present) { } else if (present) {
cls = 'offline'; cls = "offline";
statusText = 'token stored · offline'; statusText = "token stored · offline";
dotTitle = 'provisioned but not live. ' + asOfText; dotTitle = "provisioned but not live. " + asOfText;
} else { } else {
cls = 'absent'; cls = "absent";
statusText = 'no token'; statusText = "no token";
dotTitle = 'no token yet'; dotTitle = "no token yet";
} }
ul.append(el('li', { class: 'ma-account' }, ul.append(
el('span', { class: 'ma-dot ' + cls, title: dotTitle }), el(
el('span', { class: 'ma-name' }, acc.name || '(unnamed)'), "li",
acc.user_id ? el('span', { class: 'ma-uid' }, acc.user_id) : null, { class: "ma-account" },
el('span', { class: 'ma-hs' }, acc.homeserver || '—'), el("span", { class: "ma-dot " + cls, title: dotTitle }),
el('span', { class: 'ma-status ' + cls, title: asOfText }, statusText), el("span", { class: "ma-name" }, acc.name || "(unnamed)"),
)); acc.user_id ? el("span", { class: "ma-uid" }, acc.user_id) : null,
el("span", { class: "ma-hs" }, acc.homeserver || "—"),
el("span", { class: "ma-status " + cls, title: asOfText }, statusText),
),
);
} }
list.append(ul); list.append(ul);
} }
@ -181,72 +206,90 @@ async function loadAccounts(agent) {
// both — would be submitted). // both — would be submitted).
function toggleModeFields() { function toggleModeFields() {
const mode = document.querySelector('input[name="mode"]:checked'); const mode = document.querySelector('input[name="mode"]:checked');
const value = mode ? mode.value : 'password'; const value = mode ? mode.value : "password";
const pw = $('ma-pw-fields'); const pw = $("ma-pw-fields");
const tok = $('ma-token-fields'); const tok = $("ma-token-fields");
pw.hidden = value !== 'password'; pw.hidden = value !== "password";
tok.hidden = value !== 'token'; tok.hidden = value !== "token";
pw.querySelectorAll('input').forEach((i) => { i.disabled = pw.hidden; }); pw.querySelectorAll("input").forEach((i) => {
tok.querySelectorAll('input').forEach((i) => { i.disabled = tok.hidden; }); i.disabled = pw.hidden;
});
tok.querySelectorAll("input").forEach((i) => {
i.disabled = tok.hidden;
});
} }
function clearSecrets(formEl) { function clearSecrets(formEl) {
formEl.querySelectorAll('input[type="password"], input[name="token"]') formEl
.forEach((i) => { i.value = ''; }); .querySelectorAll('input[type="password"], input[name="token"]')
.forEach((i) => {
i.value = "";
});
} }
async function submitLogin(e) { async function submitLogin(e) {
e.preventDefault(); e.preventDefault();
const formEl = e.target; const formEl = e.target;
const out = $('ma-result'); const out = $("ma-result");
out.className = 'ma-result'; out.className = "ma-result";
out.textContent = ''; out.textContent = "";
const agent = $('ma-agent').value; const agent = $("ma-agent").value;
if (!agent) { if (!agent) {
out.className = 'ma-result err'; out.className = "ma-result err";
out.textContent = 'select an agent first.'; out.textContent = "select an agent first.";
return; return;
} }
const fd = new FormData(formEl); const fd = new FormData(formEl);
fd.set('agent', agent); fd.set("agent", agent);
const btn = formEl.querySelector('button[type="submit"]'); const btn = formEl.querySelector('button[type="submit"]');
const orig = btn.textContent; const orig = btn.textContent;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'logging in…'; btn.textContent = "logging in…";
try { try {
const resp = await fetch('/api/matrix-account-login', { const resp = await fetch("/api/matrix-account-login", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd), body: new URLSearchParams(fd),
}); });
if (resp.ok) { if (resp.ok) {
// Success is 200 + JSON { ok, user_id }. // Success is 200 + JSON { ok, user_id }.
let body = {}; let body = {};
try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ } try {
body = await resp.json();
} catch {
/* tolerate odd 2xx body */
}
if (body.ok) { if (body.ok) {
out.className = 'ma-result ok'; out.className = "ma-result ok";
out.textContent = '✓ logged in as ' + (body.user_id || '(unknown)') + ' — token stored.'; out.textContent =
"✓ logged in as " +
(body.user_id || "(unknown)") +
" — token stored.";
clearSecrets(formEl); clearSecrets(formEl);
loadAccounts(agent); loadAccounts(agent);
} else { } else {
out.className = 'ma-result err'; out.className = "ma-result err";
out.textContent = '✗ login failed (unexpected response).'; out.textContent = "✗ login failed (unexpected response).";
clearSecrets(formEl); clearSecrets(formEl);
} }
} else { } else {
const msg = problemMessage(await readApiError(resp)); const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err'; out.className = "ma-result err";
out.textContent = '✗ ' + (msg || ('login failed (HTTP ' + resp.status + ')')); out.textContent =
"✗ " + (msg || "login failed (HTTP " + resp.status + ")");
clearSecrets(formEl); clearSecrets(formEl);
} }
} catch (err) { } catch (err) {
out.className = 'ma-result err'; out.className = "ma-result err";
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).'; out.textContent =
"✗ request failed: " +
String(err) +
" (the backend endpoint may not be deployed yet).";
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = orig; btn.textContent = orig;
@ -256,81 +299,111 @@ async function submitLogin(e) {
// ─── GITHUB tab ───────────────────────────────────────────────────────── // ─── GITHUB tab ─────────────────────────────────────────────────────────
async function loadGithubStatus(agent) { async function loadGithubStatus(agent) {
const status = $('gh-status'); const status = $("gh-status");
if (!agent) { if (!agent) {
status.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its github credential status.')); status.replaceChildren(
el(
"p",
{ class: "meta" },
"select an agent to see its github credential status.",
),
);
return; return;
} }
status.replaceChildren(el('p', { class: 'meta' }, 'loading…')); status.replaceChildren(el("p", { class: "meta" }, "loading…"));
let data; let data;
try { try {
const resp = await fetch('/api/github-account?agent=' + encodeURIComponent(agent)); const resp = await fetch(
if (!resp.ok) throw new Error('HTTP ' + resp.status); "/api/github-account?agent=" + encodeURIComponent(agent),
);
if (!resp.ok) throw new Error("HTTP " + resp.status);
data = await resp.json(); data = await resp.json();
} catch (err) { } catch (err) {
status.replaceChildren(el('p', { class: 'err' }, status.replaceChildren(
'could not load status: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).')); el(
"p",
{ class: "err" },
"could not load status: " +
esc(String(err)) +
" (the backend endpoint may not be deployed yet).",
),
);
return; return;
} }
const present = !!data.present; const present = !!data.present;
status.replaceChildren(el('div', { class: 'gh-status-line' }, status.replaceChildren(
el('span', { class: 'gh-dot ' + (present ? 'present' : 'absent') }), el(
el('span', { class: 'gh-status-text ' + (present ? 'present' : 'absent') }, "div",
present ? 'token stored ✓' : 'not set'), { class: "gh-status-line" },
)); el("span", { class: "gh-dot " + (present ? "present" : "absent") }),
el(
"span",
{ class: "gh-status-text " + (present ? "present" : "absent") },
present ? "token stored ✓" : "not set",
),
),
);
} }
async function submitGithub(e) { async function submitGithub(e) {
e.preventDefault(); e.preventDefault();
const formEl = e.target; const formEl = e.target;
const out = $('gh-result'); const out = $("gh-result");
out.className = 'ma-result'; out.className = "ma-result";
out.textContent = ''; out.textContent = "";
const agent = $('ma-agent').value; const agent = $("ma-agent").value;
if (!agent) { if (!agent) {
out.className = 'ma-result err'; out.className = "ma-result err";
out.textContent = 'select an agent first.'; out.textContent = "select an agent first.";
return; return;
} }
const fd = new FormData(formEl); const fd = new FormData(formEl);
fd.set('agent', agent); fd.set("agent", agent);
const btn = formEl.querySelector('button[type="submit"]'); const btn = formEl.querySelector('button[type="submit"]');
const orig = btn.textContent; const orig = btn.textContent;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'storing…'; btn.textContent = "storing…";
try { try {
const resp = await fetch('/api/github-account', { const resp = await fetch("/api/github-account", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd), body: new URLSearchParams(fd),
}); });
if (resp.ok) { if (resp.ok) {
let body = {}; let body = {};
try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ } try {
body = await resp.json();
} catch {
/* tolerate odd 2xx body */
}
if (body.ok) { if (body.ok) {
out.className = 'ma-result ok'; out.className = "ma-result ok";
out.textContent = '✓ token stored.'; out.textContent = "✓ token stored.";
clearSecrets(formEl); clearSecrets(formEl);
loadGithubStatus(agent); loadGithubStatus(agent);
} else { } else {
out.className = 'ma-result err'; out.className = "ma-result err";
out.textContent = '✗ store failed (unexpected response).'; out.textContent = "✗ store failed (unexpected response).";
clearSecrets(formEl); clearSecrets(formEl);
} }
} else { } else {
const msg = problemMessage(await readApiError(resp)); const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err'; out.className = "ma-result err";
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')')); out.textContent =
"✗ " + (msg || "store failed (HTTP " + resp.status + ")");
clearSecrets(formEl); clearSecrets(formEl);
} }
} catch (err) { } catch (err) {
out.className = 'ma-result err'; out.className = "ma-result err";
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).'; out.textContent =
"✗ request failed: " +
String(err) +
" (the backend endpoint may not be deployed yet).";
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = orig; btn.textContent = orig;
@ -347,39 +420,56 @@ async function submitGithub(e) {
// forge themselves. // forge themselves.
async function loadForgeAccounts(agent) { async function loadForgeAccounts(agent) {
const list = $('ef-list'); const list = $("ef-list");
if (!agent) { if (!agent) {
list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its forge accounts.')); list.replaceChildren(
el("p", { class: "meta" }, "select an agent to see its forge accounts."),
);
return; return;
} }
list.replaceChildren(el('p', { class: 'meta' }, 'loading…')); list.replaceChildren(el("p", { class: "meta" }, "loading…"));
let forges; let forges;
try { try {
const resp = await fetch('/api/extra-forges?agent=' + encodeURIComponent(agent)); const resp = await fetch(
if (!resp.ok) throw new Error('HTTP ' + resp.status); "/api/extra-forges?agent=" + encodeURIComponent(agent),
);
if (!resp.ok) throw new Error("HTTP " + resp.status);
forges = (await resp.json()).forges || []; forges = (await resp.json()).forges || [];
} catch (err) { } catch (err) {
list.replaceChildren(el('p', { class: 'err' }, list.replaceChildren(
'could not load forge accounts: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).')); el(
"p",
{ class: "err" },
"could not load forge accounts: " +
esc(String(err)) +
" (the backend endpoint may not be deployed yet).",
),
);
return; return;
} }
list.replaceChildren(); list.replaceChildren();
if (!forges.length) { if (!forges.length) {
list.replaceChildren(el('p', { class: 'meta' }, 'no forge accounts stored for this agent.')); list.replaceChildren(
el("p", { class: "meta" }, "no forge accounts stored for this agent."),
);
return; return;
} }
const ul = el('ul', { class: 'ma-accounts' }); const ul = el("ul", { class: "ma-accounts" });
for (const forge of forges) { for (const forge of forges) {
const btn = el('button', { class: 'btn', type: 'button' }, 'remove'); const btn = el("button", { class: "btn", type: "button" }, "remove");
btn.addEventListener('click', () => onForgeRemoveClick(agent, forge, btn)); btn.addEventListener("click", () => onForgeRemoveClick(agent, forge, btn));
ul.append(el('li', { class: 'ma-account' }, ul.append(
el('span', { class: 'ma-dot ok' }), el(
el('span', { class: 'ma-name' }, forge.label), "li",
el('span', { class: 'ma-hs' }, forge.base_url || '—'), { class: "ma-account" },
el('span', { class: 'ma-status ok' }, 'token stored ✓'), el("span", { class: "ma-dot ok" }),
btn, el("span", { class: "ma-name" }, forge.label),
)); el("span", { class: "ma-hs" }, forge.base_url || "—"),
el("span", { class: "ma-status ok" }, "token stored ✓"),
btn,
),
);
} }
list.append(ul); list.append(ul);
} }
@ -388,18 +478,22 @@ async function onForgeRemoveClick(agent, forge, btn) {
const r = await themedConfirm({ const r = await themedConfirm({
message: `remove ${agent}'s stored token for ${forge.label}? this only deletes the local copy — nothing changes on the remote forge.`, message: `remove ${agent}'s stored token for ${forge.label}? this only deletes the local copy — nothing changes on the remote forge.`,
danger: true, danger: true,
confirmLabel: '⊘ remove', confirmLabel: "⊘ remove",
}); });
if (!r) return; if (!r) return;
btn.disabled = true; btn.disabled = true;
const orig = btn.textContent; const orig = btn.textContent;
btn.textContent = 'removing…'; btn.textContent = "removing…";
try { try {
const resp = await fetch('/api/extra-forge-account', { const resp = await fetch("/api/extra-forge-account", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ agent, label: forge.label, action: 'remove' }), body: new URLSearchParams({
agent,
label: forge.label,
action: "remove",
}),
}); });
if (resp.ok) { if (resp.ok) {
loadForgeAccounts(agent); loadForgeAccounts(agent);
@ -408,66 +502,76 @@ async function onForgeRemoveClick(agent, forge, btn) {
const msg = problemMessage(await readApiError(resp)); const msg = problemMessage(await readApiError(resp));
btn.textContent = orig; btn.textContent = orig;
btn.disabled = false; btn.disabled = false;
themedToast('✗ ' + (msg || ('remove failed (HTTP ' + resp.status + ')')), { type: 'error' }); themedToast("✗ " + (msg || "remove failed (HTTP " + resp.status + ")"), {
type: "error",
});
} catch (err) { } catch (err) {
btn.textContent = orig; btn.textContent = orig;
btn.disabled = false; btn.disabled = false;
themedToast('✗ request failed: ' + String(err), { type: 'error' }); themedToast("✗ request failed: " + String(err), { type: "error" });
} }
} }
async function submitForgeAccount(e) { async function submitForgeAccount(e) {
e.preventDefault(); e.preventDefault();
const formEl = e.target; const formEl = e.target;
const out = $('ef-result'); const out = $("ef-result");
out.className = 'ma-result'; out.className = "ma-result";
out.textContent = ''; out.textContent = "";
const agent = $('ma-agent').value; const agent = $("ma-agent").value;
if (!agent) { if (!agent) {
out.className = 'ma-result err'; out.className = "ma-result err";
out.textContent = 'select an agent first.'; out.textContent = "select an agent first.";
return; return;
} }
const fd = new FormData(formEl); const fd = new FormData(formEl);
fd.set('agent', agent); fd.set("agent", agent);
fd.set('action', 'add'); fd.set("action", "add");
const btn = formEl.querySelector('button[type="submit"]'); const btn = formEl.querySelector('button[type="submit"]');
const orig = btn.textContent; const orig = btn.textContent;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'storing…'; btn.textContent = "storing…";
try { try {
const resp = await fetch('/api/extra-forge-account', { const resp = await fetch("/api/extra-forge-account", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd), body: new URLSearchParams(fd),
}); });
if (resp.ok) { if (resp.ok) {
let body = {}; let body = {};
try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ } try {
body = await resp.json();
} catch {
/* tolerate odd 2xx body */
}
if (body.ok) { if (body.ok) {
out.className = 'ma-result ok'; out.className = "ma-result ok";
out.textContent = '✓ forge account stored.'; out.textContent = "✓ forge account stored.";
clearSecrets(formEl); clearSecrets(formEl);
loadForgeAccounts(agent); loadForgeAccounts(agent);
} else { } else {
out.className = 'ma-result err'; out.className = "ma-result err";
out.textContent = '✗ store failed (unexpected response).'; out.textContent = "✗ store failed (unexpected response).";
clearSecrets(formEl); clearSecrets(formEl);
} }
} else { } else {
const msg = problemMessage(await readApiError(resp)); const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err'; out.className = "ma-result err";
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')')); out.textContent =
"✗ " + (msg || "store failed (HTTP " + resp.status + ")");
clearSecrets(formEl); clearSecrets(formEl);
} }
} catch (err) { } catch (err) {
out.className = 'ma-result err'; out.className = "ma-result err";
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).'; out.textContent =
"✗ request failed: " +
String(err) +
" (the backend endpoint may not be deployed yet).";
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = orig; btn.textContent = orig;
@ -485,24 +589,27 @@ async function onAgentChange(agent) {
async function init() { async function init() {
await loadState(); await loadState();
renderAgentPicker(); renderAgentPicker();
$('ma-agent').addEventListener('change', (e) => onAgentChange(e.target.value)); $("ma-agent").addEventListener("change", (e) =>
document.querySelectorAll('input[name="mode"]') onAgentChange(e.target.value),
.forEach((r) => r.addEventListener('change', toggleModeFields)); );
document
.querySelectorAll('input[name="mode"]')
.forEach((r) => r.addEventListener("change", toggleModeFields));
toggleModeFields(); toggleModeFields();
$('ma-form').addEventListener('submit', submitLogin); $("ma-form").addEventListener("submit", submitLogin);
$('gh-form').addEventListener('submit', submitGithub); $("gh-form").addEventListener("submit", submitGithub);
$('ef-form').addEventListener('submit', submitForgeAccount); $("ef-form").addEventListener("submit", submitForgeAccount);
document.getElementById('cred-tabbar').configure({ document.getElementById("cred-tabbar").configure({
tabs: [ tabs: [
{ id: 'matrix', label: 'MATRIX' }, { id: "matrix", label: "MATRIX" },
{ id: 'github', label: 'GITHUB' }, { id: "github", label: "GITHUB" },
{ id: 'forges', label: 'FORGES' }, { id: "forges", label: "FORGES" },
], ],
defaultId: 'matrix', defaultId: "matrix",
}); });
onAgentChange(''); onAgentChange("");
} }
init(); init();

View file

@ -56,7 +56,9 @@ body.dashboard-shell {
font-size: 0.82em; font-size: 0.82em;
white-space: nowrap; white-space: nowrap;
} }
.dash-home-back:hover { text-decoration: underline; } .dash-home-back:hover {
text-decoration: underline;
}
.banner-thin { .banner-thin {
text-align: center; text-align: center;
margin: 0; margin: 0;
@ -95,7 +97,9 @@ body.dashboard-shell {
border: 0; border: 0;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: color 0.15s ease, background 0.15s ease; transition:
color 0.15s ease,
background 0.15s ease;
} }
.tabbar .tab:hover { .tabbar .tab:hover {
color: var(--fg); color: var(--fg);
@ -105,7 +109,10 @@ body.dashboard-shell {
color: var(--purple); color: var(--purple);
background: var(--border); background: var(--border);
} }
.tab-label { font-weight: bold; white-space: nowrap; } .tab-label {
font-weight: bold;
white-space: nowrap;
}
.tab-count { .tab-count {
display: inline-block; display: inline-block;
background: var(--purple-dim); background: var(--purple-dim);
@ -124,11 +131,12 @@ body.dashboard-shell {
color: var(--red); color: var(--red);
} }
/* Tab pane visibility createTabStrip (@hive/shared/tabs.js) sets the /* Tab pane visibility createTabStrip (@hive/shared/tabs.js) sets the
`hidden` attribute on every inactive pane (resolved from the URL hash; `hidden` attribute on every inactive pane (resolved from the URL hash;
default no hash is SW4RM). Only the active pane stays visible. */ default no hash is SW4RM). Only the active pane stays visible. */
.tab-pane[hidden] { display: none; } .tab-pane[hidden] {
display: none;
}
/* FL0W is a separate page (`/flow.html`) its full-viewport /* FL0W is a separate page (`/flow.html`) its full-viewport
styling lives in flow.css. */ styling lives in flow.css. */
@ -148,14 +156,20 @@ body.dashboard-shell {
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--purple) 45%, transparent)); filter: drop-shadow(
0 0 6px color-mix(in srgb, var(--purple) 45%, transparent)
);
} }
.banner.active { .banner.active {
animation: banner-shimmer 1.8s linear infinite; animation: banner-shimmer 1.8s linear infinite;
} }
@keyframes banner-shimmer { @keyframes banner-shimmer {
from { background-position: 200% 0; } from {
to { background-position: -100% 0; } background-position: 200% 0;
}
to {
background-position: -100% 0;
}
} }
.role { .role {
@ -172,24 +186,44 @@ body.dashboard-shell {
/* Container rows: a full-height square agent icon on the left, the /* Container rows: a full-height square agent icon on the left, the
identity / actions / drill-in lines stacked in the card body on the identity / actions / drill-in lines stacked in the card body on the
right. Pending rows dim everything except the pending indicator. */ right. Pending rows dim everything except the pending indicator. */
.containers { display: flex; flex-direction: column; gap: 0.4em; } .containers {
display: flex;
flex-direction: column;
gap: 0.4em;
}
.container-row { .container-row {
padding: 0.6em 0.8em; padding: 0.6em 0.8em;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
background: color-mix(in srgb, var(--bg-elev) 55%, transparent); background: color-mix(in srgb, var(--bg-elev) 55%, transparent);
transition: opacity 200ms ease, border-color 200ms ease; transition:
opacity 200ms ease,
border-color 200ms ease;
} }
/* Topology indent ladder. See docs/web-ui.md::Topology tree (Indent /* Topology indent ladder. See docs/web-ui.md::Topology tree (Indent
+ lane geometry paragraph) for the 1.8em-per-depth-level + lane geometry paragraph) for the 1.8em-per-depth-level
rationale + CSS-attr()-not-yet-portable caveat. */ rationale + CSS-attr()-not-yet-portable caveat. */
.container-row[data-depth] { position: relative; } .container-row[data-depth] {
.container-row[data-depth="1"] { margin-left: 1.8em; } position: relative;
.container-row[data-depth="2"] { margin-left: 3.6em; } }
.container-row[data-depth="3"] { margin-left: 5.4em; } .container-row[data-depth="1"] {
.container-row[data-depth="4"] { margin-left: 7.2em; } margin-left: 1.8em;
.container-row[data-depth="5"] { margin-left: 9em; } }
.container-row[data-depth="6"] { margin-left: 10.8em; } .container-row[data-depth="2"] {
margin-left: 3.6em;
}
.container-row[data-depth="3"] {
margin-left: 5.4em;
}
.container-row[data-depth="4"] {
margin-left: 7.2em;
}
.container-row[data-depth="5"] {
margin-left: 9em;
}
.container-row[data-depth="6"] {
margin-left: 10.8em;
}
/* Tree prefix lanes — DOM-painted, not text-glyph-painted. */ /* Tree prefix lanes — DOM-painted, not text-glyph-painted. */
.container-row .tree-prefix { .container-row .tree-prefix {
position: absolute; position: absolute;
@ -201,19 +235,31 @@ body.dashboard-shell {
user-select: none; user-select: none;
color: var(--purple-dim); color: var(--purple-dim);
} }
.container-row[data-depth="1"] .tree-prefix { left: -1.8em; } .container-row[data-depth="1"] .tree-prefix {
.container-row[data-depth="2"] .tree-prefix { left: -3.6em; } left: -1.8em;
.container-row[data-depth="3"] .tree-prefix { left: -5.4em; } }
.container-row[data-depth="4"] .tree-prefix { left: -7.2em; } .container-row[data-depth="2"] .tree-prefix {
.container-row[data-depth="5"] .tree-prefix { left: -9em; } left: -3.6em;
.container-row[data-depth="6"] .tree-prefix { left: -10.8em; } }
.container-row[data-depth="3"] .tree-prefix {
left: -5.4em;
}
.container-row[data-depth="4"] .tree-prefix {
left: -7.2em;
}
.container-row[data-depth="5"] .tree-prefix {
left: -9em;
}
.container-row[data-depth="6"] .tree-prefix {
left: -10.8em;
}
.tree-prefix .tree-lane { .tree-prefix .tree-lane {
flex: 0 0 1.8em; flex: 0 0 1.8em;
position: relative; position: relative;
} }
.tree-prefix .lane-line::before, .tree-prefix .lane-line::before,
.tree-prefix .lane-joint::before { .tree-prefix .lane-joint::before {
content: ''; content: "";
position: absolute; position: absolute;
left: 0.6em; left: 0.6em;
top: 0; top: 0;
@ -225,7 +271,7 @@ body.dashboard-shell {
height: 3.1em; height: 3.1em;
} }
.tree-prefix .lane-joint::after { .tree-prefix .lane-joint::after {
content: ''; content: "";
position: absolute; position: absolute;
left: 0.6em; left: 0.6em;
top: 3.1em; top: 3.1em;
@ -246,7 +292,9 @@ body.dashboard-shell {
border-radius: 6px; border-radius: 6px;
background-color: color-mix(in srgb, var(--crust) 60%, transparent); background-color: color-mix(in srgb, var(--crust) 60%, transparent);
cursor: pointer; cursor: pointer;
transition: box-shadow 120ms ease, transform 120ms ease; transition:
box-shadow 120ms ease,
transform 120ms ease;
} }
.container-row:not(.tombstone) > .container-icon:hover { .container-row:not(.tombstone) > .container-icon:hover {
box-shadow: 0 0 0 2px var(--purple); box-shadow: 0 0 0 2px var(--purple);
@ -261,7 +309,9 @@ body.dashboard-shell {
background: color-mix(in srgb, var(--purple) 6%, transparent); background: color-mix(in srgb, var(--purple) 6%, transparent);
} }
.container-row.selected > .container-icon { .container-row.selected > .container-icon {
box-shadow: 0 0 0 2px var(--purple), 0 0 12px -4px var(--purple); box-shadow:
0 0 0 2px var(--purple),
0 0 12px -4px var(--purple);
} }
.container-row:not(.tombstone) > .container-icon > .container-icon-img { .container-row:not(.tombstone) > .container-icon > .container-icon-img {
position: absolute; position: absolute;
@ -352,17 +402,24 @@ hive-agent-menu {
driven only now see swarm.js's pending-badge derivation comment; driven only now see swarm.js's pending-badge derivation comment;
there is no separate queued-but-not-running row state to tell apart there is no separate queued-but-not-running row state to tell apart
from this one anymore). */ from this one anymore). */
.container-row.pending-running .actions { opacity: 0.4; pointer-events: none; } .container-row.pending-running .actions {
opacity: 0.4;
pointer-events: none;
}
.container-row.pending-running { .container-row.pending-running {
border-color: var(--amber); border-color: var(--amber);
background: color-mix(in srgb, var(--amber) 5%, transparent); background: color-mix(in srgb, var(--amber) 5%, transparent);
} }
@keyframes container-icon-spin { @keyframes container-icon-spin {
from { transform: rotate(0deg); } from {
to { transform: rotate(360deg); } transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }
.container-row.pending-running > .container-icon::after { .container-row.pending-running > .container-icon::after {
content: ''; content: "";
position: absolute; position: absolute;
inset: 0; inset: 0;
border-radius: 50%; border-radius: 50%;
@ -382,7 +439,9 @@ hive-agent-menu {
font-size: 1.05em; font-size: 1.05em;
font-weight: bold; font-weight: bold;
} }
.container-row .head .meta { margin-left: auto; } .container-row .head .meta {
margin-left: auto;
}
.container-row .head .nav-strip { .container-row .head .nav-strip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -396,7 +455,9 @@ hive-agent-menu {
padding: 0.15em 0.35em; padding: 0.15em 0.35em;
border-radius: 3px; border-radius: 3px;
text-decoration: none; text-decoration: none;
transition: background 0.12s ease, color 0.12s ease; transition:
background 0.12s ease,
color 0.12s ease;
} }
.nav-link:hover { .nav-link:hover {
background: color-mix(in srgb, var(--purple) 12%, transparent); background: color-mix(in srgb, var(--purple) 12%, transparent);
@ -408,7 +469,10 @@ hive-agent-menu {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.4em; gap: 0.4em;
} }
.container-row .actions form.inline { display: inline-block; margin: 0; } .container-row .actions form.inline {
display: inline-block;
margin: 0;
}
.agent-status { .agent-status {
font-size: 0.82em; font-size: 0.82em;
@ -433,7 +497,9 @@ hive-agent-menu {
overflow: hidden; overflow: hidden;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.agent-status .status-icon { opacity: 0.65; } .agent-status .status-icon {
opacity: 0.65;
}
.agent-status .status-age { .agent-status .status-age {
flex: 0 0 auto; flex: 0 0 auto;
opacity: 0.5; opacity: 0.5;
@ -446,7 +512,9 @@ hive-agent-menu {
background: color-mix(in srgb, var(--bg-elev) 35%, transparent); background: color-mix(in srgb, var(--bg-elev) 35%, transparent);
opacity: 0.85; opacity: 0.85;
} }
.container-row.tombstone .name { color: var(--muted); } .container-row.tombstone .name {
color: var(--muted);
}
/* K3PT ST4T3 caveat and the port-collision banner both moved to the /* K3PT ST4T3 caveat and the port-collision banner both moved to the
shared <hive-warn> component amber/no-pulse for shared <hive-warn> component amber/no-pulse for
@ -497,13 +565,22 @@ hive-agent-menu {
animation: badge-pulse 1.6s ease-in-out infinite; animation: badge-pulse 1.6s ease-in-out infinite;
} }
@keyframes badge-pulse { @keyframes badge-pulse {
0%, 100% { opacity: 1; } 0%,
50% { opacity: 0.7; } 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
} }
/* Pending approval: a card with three stacked sections identity /* Pending approval: a card with three stacked sections identity
header, what-changed body, decision actions. */ header, what-changed body, decision actions. */
.approvals { list-style: none; padding: 0; margin: 0.4em 0 0; } .approvals {
list-style: none;
padding: 0;
margin: 0.4em 0 0;
}
.approval-card { .approval-card {
background: var(--bg-elev); background: var(--bg-elev);
border: 1px solid var(--border); border: 1px solid var(--border);
@ -543,7 +620,9 @@ hive-agent-menu {
padding-top: 0.45em; padding-top: 0.45em;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.approval-actions form.inline { display: inline; } .approval-actions form.inline {
display: inline;
}
/* Inline drill-in triggers (logs / config repo / view diff). */ /* Inline drill-in triggers (logs / config repo / view diff). */
.drill-ins { .drill-ins {
display: flex; display: flex;
@ -551,7 +630,9 @@ hive-agent-menu {
gap: 0.15em 1.1em; gap: 0.15em 1.1em;
margin-top: 0.4em; margin-top: 0.4em;
} }
.drill-ins .panel-trigger { margin-top: 0; } .drill-ins .panel-trigger {
margin-top: 0;
}
.approval-tabs { .approval-tabs {
display: flex; display: flex;
@ -567,9 +648,14 @@ hive-agent-menu {
letter-spacing: 0.08em; letter-spacing: 0.08em;
padding: 0.25em 0.9em; padding: 0.25em 0.9em;
cursor: pointer; cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; transition:
color 0.15s ease,
border-color 0.15s ease,
background 0.15s ease;
}
.approval-tab:hover {
color: var(--fg);
} }
.approval-tab:hover { color: var(--fg); }
.approval-tab.active { .approval-tab.active {
color: var(--purple); color: var(--purple);
border-color: var(--purple); border-color: var(--purple);
@ -587,18 +673,43 @@ hive-agent-menu {
padding: 0.35em 0.8em; padding: 0.35em 0.8em;
margin-bottom: 0.4em; margin-bottom: 0.4em;
} }
.approvals-history li:has(.glyph-approved) { border-left-color: var(--green); } .approvals-history li:has(.glyph-approved) {
.approvals-history li:has(.glyph-denied) { border-left-color: var(--red); } border-left-color: var(--green);
.approvals-history li:has(.glyph-failed) { border-left-color: var(--amber); } }
.approvals-history .status { font-size: 0.85em; padding: 0 0.5em; } .approvals-history li:has(.glyph-denied) {
.status-approved { color: var(--green); } border-left-color: var(--red);
.status-denied { color: var(--red); } }
.status-failed { color: var(--amber); } .approvals-history li:has(.glyph-failed) {
.status-cancelled { color: var(--muted); } border-left-color: var(--amber);
.glyph-approved { color: var(--green); } }
.glyph-denied { color: var(--red); } .approvals-history .status {
.glyph-failed { color: var(--amber); } font-size: 0.85em;
.glyph-cancelled { color: var(--muted); } padding: 0 0.5em;
}
.status-approved {
color: var(--green);
}
.status-denied {
color: var(--red);
}
.status-failed {
color: var(--amber);
}
.status-cancelled {
color: var(--muted);
}
.glyph-approved {
color: var(--green);
}
.glyph-denied {
color: var(--red);
}
.glyph-failed {
color: var(--amber);
}
.glyph-cancelled {
color: var(--muted);
}
.history-note { .history-note {
margin-left: 1.8em; margin-left: 1.8em;
@ -608,9 +719,14 @@ hive-agent-menu {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
ul form.inline { display: inline-block; } ul form.inline {
display: inline-block;
}
.role-pending { color: var(--amber); border-color: var(--amber); } .role-pending {
color: var(--amber);
border-color: var(--amber);
}
.btn-inline { .btn-inline {
font-family: inherit; font-family: inherit;
background: transparent; background: transparent;
@ -619,7 +735,9 @@ ul form.inline { display: inline-block; }
} }
/* Off-palette warm-amber hover tint: not an exact theme var (brighter than /* Off-palette warm-amber hover tint: not an exact theme var (brighter than
--amber), left as a literal pending a dedicated named var. */ --amber), left as a literal pending a dedicated named var. */
.btn-inline:hover { background: rgba(255, 184, 77, 0.1); } .btn-inline:hover {
background: rgba(255, 184, 77, 0.1);
}
.kind { .kind {
display: inline-block; display: inline-block;
margin-left: 0.4em; margin-left: 0.4em;
@ -631,8 +749,13 @@ ul form.inline { display: inline-block; }
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
} }
.kind-spawn { color: var(--amber); border-color: var(--amber); } .kind-spawn {
details { margin-top: 0.5em; } color: var(--amber);
border-color: var(--amber);
}
details {
margin-top: 0.5em;
}
summary { summary {
cursor: pointer; cursor: pointer;
color: var(--muted); color: var(--muted);
@ -640,7 +763,9 @@ summary {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
} }
summary:hover { color: var(--purple); } summary:hover {
color: var(--purple);
}
.diff { .diff {
background: var(--bg-elev); background: var(--bg-elev);
border: 1px solid var(--border); border: 1px solid var(--border);
@ -652,12 +777,25 @@ summary:hover { color: var(--purple); }
color: var(--muted); color: var(--muted);
white-space: pre; white-space: pre;
} }
.diff span { display: block; } .diff span {
.diff .diff-add { color: var(--green); } display: block;
.diff .diff-del { color: var(--red); } }
.diff .diff-hunk { color: var(--cyan); } .diff .diff-add {
.diff .diff-file { color: var(--purple); font-weight: bold; } color: var(--green);
.diff .diff-ctx { color: var(--fg); } }
.diff .diff-del {
color: var(--red);
}
.diff .diff-hunk {
color: var(--cyan);
}
.diff .diff-file {
color: var(--purple);
font-weight: bold;
}
.diff .diff-ctx {
color: var(--fg);
}
footer { footer {
margin-top: 4em; margin-top: 4em;
@ -665,7 +803,9 @@ footer {
color: var(--muted); color: var(--muted);
font-size: 0.9em; font-size: 0.9em;
} }
footer a { color: var(--purple); } footer a {
color: var(--purple);
}
footer .banner-thin { footer .banner-thin {
margin-bottom: 0.8em; margin-bottom: 0.8em;
} }
@ -674,8 +814,15 @@ footer .banner-thin {
Agents × capabilities matrix in the P3RM1SS10NS tab. Same layout Agents × capabilities matrix in the P3RM1SS10NS tab. Same layout
as the tool-groups table below. Horizontally scrollable on narrow as the tool-groups table below. Horizontally scrollable on narrow
viewports. */ viewports. */
.cap-table-wrap { overflow-x: auto; margin-top: 0.5em; } .cap-table-wrap {
.cap-table { border-collapse: collapse; font-size: 0.82em; min-width: 100%; } overflow-x: auto;
margin-top: 0.5em;
}
.cap-table {
border-collapse: collapse;
font-size: 0.82em;
min-width: 100%;
}
.cap-table th, .cap-table th,
.cap-table td { .cap-table td {
padding: 0.35em 0.6em; padding: 0.35em 0.6em;
@ -714,16 +861,48 @@ footer .banner-thin {
Agents × tool-groups matrix in the P3RM1SS10NS tab. Same layout Agents × tool-groups matrix in the P3RM1SS10NS tab. Same layout
as the capabilities table above. Horizontally scrollable on narrow as the capabilities table above. Horizontally scrollable on narrow
viewports. */ viewports. */
.tg-table-wrap { overflow-x: auto; margin-top: 0.5em; } .tg-table-wrap {
.tg-table { border-collapse: collapse; font-size: 0.82em; min-width: 100%; } overflow-x: auto;
margin-top: 0.5em;
}
.tg-table {
border-collapse: collapse;
font-size: 0.82em;
min-width: 100%;
}
.tg-table th, .tg-table th,
.tg-table td { padding: 0.35em 0.6em; border: 1px solid var(--border); text-align: center; vertical-align: middle; } .tg-table td {
.tg-table thead th { background: var(--bg-elev); color: var(--muted); letter-spacing: 0.05em; white-space: nowrap; } padding: 0.35em 0.6em;
.tg-agent-col { text-align: left !important; min-width: 8em; } border: 1px solid var(--border);
.tg-group-col { min-width: 5em; } text-align: center;
.tg-agent-name { color: var(--fg); font-weight: 600; } vertical-align: middle;
.tg-cb { cursor: pointer; width: 1em; height: 1em; accent-color: var(--purple); } }
.tg-row:hover td { background: var(--bg-elev); } .tg-table thead th {
background: var(--bg-elev);
color: var(--muted);
letter-spacing: 0.05em;
white-space: nowrap;
}
.tg-agent-col {
text-align: left !important;
min-width: 8em;
}
.tg-group-col {
min-width: 5em;
}
.tg-agent-name {
color: var(--fg);
font-weight: 600;
}
.tg-cb {
cursor: pointer;
width: 1em;
height: 1em;
accent-color: var(--purple);
}
.tg-row:hover td {
background: var(--bg-elev);
}
/* permissions save bar (save-all) /* permissions save bar (save-all)
One page-level save button for the whole P3RM1SS10NS tab. Sticks to One page-level save button for the whole P3RM1SS10NS tab. Sticks to
@ -747,10 +926,14 @@ footer .banner-thin {
opacity: 0.45; opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
} }
.perm-save-err { color: var(--red); } .perm-save-err {
color: var(--red);
}
/* Stale permission entry (agent not in the live roster) */ /* Stale permission entry (agent not in the live roster) */
.perm-row-stale td { opacity: 0.7; } .perm-row-stale td {
opacity: 0.7;
}
.perm-stale-label { .perm-stale-label {
font-size: 0.75em; font-size: 0.75em;
color: var(--muted); color: var(--muted);
@ -768,8 +951,13 @@ footer .banner-thin {
font-family: inherit; font-family: inherit;
opacity: 0.8; opacity: 0.8;
} }
.perm-remove-btn:hover { opacity: 1; } .perm-remove-btn:hover {
.perm-remove-btn:disabled { opacity: 0.4; cursor: not-allowed; } opacity: 1;
}
.perm-remove-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* scheduled prompts tab /* scheduled prompts tab
Creation form at the top, list of queued schedule cards below. */ Creation form at the top, list of queued schedule cards below. */
@ -816,7 +1004,10 @@ footer .banner-thin {
border-radius: 3px; border-radius: 3px;
padding: 0.35em 0.5em; padding: 0.35em 0.5em;
} }
.schedule-field textarea { resize: vertical; min-height: 4em; } .schedule-field textarea {
resize: vertical;
min-height: 4em;
}
.schedule-targets { .schedule-targets {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -833,13 +1024,17 @@ footer .banner-thin {
cursor: pointer; cursor: pointer;
background: color-mix(in srgb, var(--bg-elev) 40%, transparent); background: color-mix(in srgb, var(--bg-elev) 40%, transparent);
} }
.schedule-target-chip:hover { border-color: var(--purple-dim); } .schedule-target-chip:hover {
border-color: var(--purple-dim);
}
.schedule-target-chip:has(input:checked) { .schedule-target-chip:has(input:checked) {
border-color: var(--purple); border-color: var(--purple);
color: var(--purple); color: var(--purple);
background: color-mix(in srgb, var(--purple) 8%, transparent); background: color-mix(in srgb, var(--purple) 8%, transparent);
} }
.schedule-target-chip input { margin: 0; } .schedule-target-chip input {
margin: 0;
}
.schedule-actions { .schedule-actions {
display: flex; display: flex;
gap: 0.5em; gap: 0.5em;
@ -886,9 +1081,15 @@ footer .banner-thin {
font-size: 0.9em; font-size: 0.9em;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.schedule-interval-preview-oneshot { color: var(--muted); font-style: italic; } .schedule-interval-preview-oneshot {
color: var(--muted);
font-style: italic;
}
.schedules-table-wrap { overflow-x: auto; margin-top: 0.5em; } .schedules-table-wrap {
overflow-x: auto;
margin-top: 0.5em;
}
.schedules-table { .schedules-table {
min-width: 100%; min-width: 100%;
border-collapse: collapse; border-collapse: collapse;
@ -908,13 +1109,26 @@ footer .banner-thin {
letter-spacing: 0.05em; letter-spacing: 0.05em;
white-space: nowrap; white-space: nowrap;
} }
.schedules-table-id { width: 3em; text-align: center; } .schedules-table-id {
width: 3em;
text-align: center;
}
/* next / every columns: sized to typical content (short durations). /* next / every columns: sized to typical content (short durations).
Previous 8em / 7em wasted horizontal space. */ Previous 8em / 7em wasted horizontal space. */
.schedules-table-next-col { width: 5.5em; white-space: nowrap; } .schedules-table-next-col {
.schedules-table-every-col { width: 5em; white-space: nowrap; } width: 5.5em;
.schedules-table-body-th { min-width: 12em; } white-space: nowrap;
.schedules-table-actions-th { width: 7em; } }
.schedules-table-every-col {
width: 5em;
white-space: nowrap;
}
.schedules-table-body-th {
min-width: 12em;
}
.schedules-table-actions-th {
width: 7em;
}
/* Agent-name columns: 90° vertical text (writing-mode) so names are fully /* Agent-name columns: 90° vertical text (writing-mode) so names are fully
readable without truncation. Previously used a -45° CSS transform which readable without truncation. Previously used a -45° CSS transform which
clipped names mid-glyph. writing-mode + rotate(180deg) is in-flow and clipped names mid-glyph. writing-mode + rotate(180deg) is in-flow and
@ -930,7 +1144,7 @@ footer .banner-thin {
overflow: hidden; overflow: hidden;
} }
.schedules-table-agent-th > div { .schedules-table-agent-th > div {
font-family: ui-monospace, 'JetBrains Mono', monospace; font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.85em; font-size: 0.85em;
color: var(--fg); color: var(--fg);
/* Rotate 90° bottom-to-top (conventional rotated column header). */ /* Rotate 90° bottom-to-top (conventional rotated column header). */
@ -953,17 +1167,28 @@ footer .banner-thin {
text-decoration: line-through; text-decoration: line-through;
opacity: 0.75; opacity: 0.75;
} }
.schedules-table-row-cancelled td { opacity: 0.55; } .schedules-table-row-cancelled td {
.schedules-table-row-paused td { opacity: 0.75; } opacity: 0.55;
.sched-paused-label { color: var(--yellow); font-size: 0.9em; } }
.btn-pause-schedule { color: var(--teal); } .schedules-table-row-paused td {
.btn-resume-schedule { color: var(--green); } opacity: 0.75;
}
.sched-paused-label {
color: var(--yellow);
font-size: 0.9em;
}
.btn-pause-schedule {
color: var(--teal);
}
.btn-resume-schedule {
color: var(--green);
}
.schedules-table-body-cell { .schedules-table-body-cell {
max-width: 30em; max-width: 30em;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-family: ui-monospace, 'JetBrains Mono', monospace; font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.9em; font-size: 0.9em;
} }
.schedules-table-check { .schedules-table-check {
@ -1019,7 +1244,7 @@ footer .banner-thin {
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--fg); color: var(--fg);
font-family: ui-monospace, 'JetBrains Mono', monospace; font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.85em; font-size: 0.85em;
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
border-radius: 2px; border-radius: 2px;
@ -1111,13 +1336,18 @@ footer .banner-thin {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.6em; gap: 0.6em;
padding: 0.55em 1em; padding: 0.55em 1em;
background: var(--flow-frost-bg, color-mix(in srgb, var(--bg) 74%, transparent)); background: var(
--flow-frost-bg,
color-mix(in srgb, var(--bg) 74%, transparent)
);
-webkit-backdrop-filter: blur(12px) saturate(140%); -webkit-backdrop-filter: blur(12px) saturate(140%);
backdrop-filter: blur(12px) saturate(140%); backdrop-filter: blur(12px) saturate(140%);
border-top: 1px solid var(--purple); border-top: 1px solid var(--purple);
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.4); box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.4);
} }
.selection-bar[hidden] { display: none; } .selection-bar[hidden] {
display: none;
}
.selection-count { .selection-count {
color: var(--purple); color: var(--purple);
font-weight: bold; font-weight: bold;
@ -1150,7 +1380,9 @@ footer .banner-thin {
} }
/* Pad the dashboard body so the sticky bar doesn't cover the /* Pad the dashboard body so the sticky bar doesn't cover the
bottom of the agent list. Only applies when bar is visible. */ bottom of the agent list. Only applies when bar is visible. */
body.dashboard-shell.has-selection { padding-bottom: 4.5em; } body.dashboard-shell.has-selection {
padding-bottom: 4.5em;
}
.move-picker { .move-picker {
display: inline-flex; display: inline-flex;
@ -1178,4 +1410,3 @@ body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
shared `.hive-stats-table` moved to common.css (the SYST3M shared `.hive-stats-table` moved to common.css (the SYST3M
C0NT41N3R L04D table in system-sections.css / on /core.html still C0NT41N3R L04D table in system-sections.css / on /core.html still
uses it). */ uses it). */

View file

@ -1,199 +1,291 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // h1ve-c0re</title> <title>hyperhive // h1ve-c0re</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css"> <link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css"> <link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/dashboard.css"> <link rel="stylesheet" href="/static/dashboard.css" />
</head> </head>
<body class="dashboard-shell"> <body class="dashboard-shell">
<!-- Sticky chrome — just the tab strip. The "WE ARE THE WIRED"
<!-- Sticky chrome — just the tab strip. The "WE ARE THE WIRED"
slug lives at the page footer below `<main>`; chrome is slug lives at the page footer below `<main>`; chrome is
navigation only. Tabs route via the URL hash so F5 / back- navigation only. Tabs route via the URL hash so F5 / back-
button / shared links keep you on the same view. JS owns button / shared links keep you on the same view. JS owns
the show/hide. --> the show/hide. -->
<header class="dashboard-chrome"> <header class="dashboard-chrome">
<!-- Back to the H0M3 menu hub (served at /). Sub-pages and the <!-- Back to the H0M3 menu hub (served at /). Sub-pages and the
dashboard all link back to the landing page rather than to each dashboard all link back to the landing page rather than to each
other — H0M3 is the single navigation hub. --> other — H0M3 is the single navigation hub. -->
<a class="dash-home-back" href="/" title="back to the H0M3 menu">← home</a> <a class="dash-home-back" href="/" title="back to the H0M3 menu"
<nav class="tabbar" id="tabbar" role="tablist"> >← home</a
<a class="tab" id="tab-swarm" href="#swarm" role="tab" >
aria-controls="tab-pane-swarm" <nav class="tabbar" id="tabbar" role="tablist">
data-tab="swarm"> <a
<span class="tab-label">◆ SW4RM ◆</span> class="tab"
<span class="tab-count" id="tab-count-swarm" hidden></span> id="tab-swarm"
</a> href="#swarm"
<a class="tab" id="tab-call" href="#call" role="tab" role="tab"
aria-controls="tab-pane-call" aria-controls="tab-pane-swarm"
data-tab="call"> data-tab="swarm"
<span class="tab-label">◆ Y3R C4LL ◆</span> >
<span class="tab-count tab-count-attn" id="tab-count-call" hidden></span> <span class="tab-label">◆ SW4RM ◆</span>
</a> <span class="tab-count" id="tab-count-swarm" hidden></span>
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"), </a>
<a
class="tab"
id="tab-call"
href="#call"
role="tab"
aria-controls="tab-pane-call"
data-tab="call"
>
<span class="tab-label">◆ Y3R C4LL ◆</span>
<span
class="tab-count tab-count-attn"
id="tab-count-call"
hidden
></span>
</a>
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
reached from the H0M3 hub — keeps the dashboard tab strip lean. reached from the H0M3 hub — keeps the dashboard tab strip lean.
The rebuild-queue STATE still lives in tabs.js (it drives the The rebuild-queue STATE still lives in tabs.js (it drives the
"building…" badges on SW4RM agent cards); only the SYST3M tab + "building…" badges on SW4RM agent cards); only the SYST3M tab +
its section renderers moved out. --> its section renderers moved out. -->
<!-- P3RM1SS10NS: per-agent capability grants and tool-group <!-- P3RM1SS10NS: per-agent capability grants and tool-group
assignments. Both tables are fetched on tab activation. --> assignments. Both tables are fetched on tab activation. -->
<a class="tab" id="tab-permissions" href="#permissions" role="tab" <a
aria-controls="tab-pane-permissions" class="tab"
data-tab="permissions"> id="tab-permissions"
<span class="tab-label">◆ P3RM1SS10NS ◆</span> href="#permissions"
</a> role="tab"
aria-controls="tab-pane-permissions"
data-tab="permissions"
>
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
</a>
<!-- SCH3DUL3S: scheduled-prompts surface. List of queued <!-- SCH3DUL3S: scheduled-prompts surface. List of queued
schedules + an operator-direct creation form. Count pill schedules + an operator-direct creation form. Count pill
mirrors the active (non-cancelled) schedule count; hidden mirrors the active (non-cancelled) schedule count; hidden
when zero. --> when zero. -->
<a class="tab" id="tab-schedules" href="#schedules" role="tab" <a
aria-controls="tab-pane-schedules" class="tab"
data-tab="schedules"> id="tab-schedules"
<span class="tab-label">◆ SCH3DUL3S ◆</span> href="#schedules"
<span class="tab-count" id="tab-count-schedules" hidden></span> role="tab"
</a> aria-controls="tab-pane-schedules"
data-tab="schedules"
>
<span class="tab-label">◆ SCH3DUL3S ◆</span>
<span class="tab-count" id="tab-count-schedules" hidden></span>
</a>
<!-- ST4TS lives on its own page now (`/stats.html`), reached from <!-- ST4TS lives on its own page now (`/stats.html`), reached from
the H0M3 hub — the hive-wide rollup is a standalone read-only the H0M3 hub — the hive-wide rollup is a standalone read-only
view, not part of the operational tab strip. --> view, not part of the operational tab strip. -->
<!-- (Peer hives are no longer a tab — they render as a headline <!-- (Peer hives are no longer a tab — they render as a headline
under SW4RM. See tab-pane-swarm.) --> under SW4RM. See tab-pane-swarm.) -->
<!-- FL0W, L0GS, and M4TR1X are separate pages reachable from the <!-- FL0W, L0GS, and M4TR1X are separate pages reachable from the
H0M3 hub (served at /), not from the dashboard tab strip — the H0M3 hub (served at /), not from the dashboard tab strip — the
strip holds only real in-page tabs now. Contextual deep-links strip holds only real in-page tabs now. Contextual deep-links
into the log viewer (an agent's logs, a build entry's log) into the log viewer (an agent's logs, a build entry's log)
still live inside the relevant dashboard content. --> still live inside the relevant dashboard content. -->
</nav>
</header>
</nav> <!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
</header>
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
visible by toggling the `hidden` attribute, resolved from the URL visible by toggling the `hidden` attribute, resolved from the URL
hash (default SW4RM). Panes start `hidden` to avoid a flash before hash (default SW4RM). Panes start `hidden` to avoid a flash before
the script runs. --> the script runs. -->
<!-- Padded content wrapper: carries the 1.5em side gutter <!-- Padded content wrapper: carries the 1.5em side gutter
(.page-content, common.css) while <body> stays full-bleed so the (.page-content, common.css) while <body> stays full-bleed so the
sticky chrome above + the footer below span the full width. --> sticky chrome above + the footer below span the full width. -->
<div class="page-content"> <div class="page-content">
<main class="dashboard-main"> <main class="dashboard-main">
<!-- SW4RM: the swarm itself. Container cards (the central thing
<!-- SW4RM: the swarm itself. Container cards (the central thing
the operator looks at) and rebuild queue / cascade visualisation the operator looks at) and rebuild queue / cascade visualisation
that drives them. No inline `C0NTAINERS` h2 heading + divider that drives them. No inline `C0NTAINERS` h2 heading + divider
— the tab label SW4RM already says it. --> — the tab label SW4RM already says it. -->
<section class="tab-pane" id="tab-pane-swarm" data-tab-pane="swarm" hidden <section
role="tabpanel" aria-labelledby="tab-swarm"> class="tab-pane"
<!-- Swarm / hive identity headline. Populated by refreshState from id="tab-pane-swarm"
data-tab-pane="swarm"
hidden
role="tabpanel"
aria-labelledby="tab-swarm"
>
<!-- Swarm / hive identity headline. Populated by refreshState from
hive_name + swarm_name; stays hidden when neither is set. --> hive_name + swarm_name; stays hidden when neither is set. -->
<h2 id="swarm-identity" hidden></h2> <h2 id="swarm-identity" hidden></h2>
<!-- JobqRollup mount point, kept outside #containers-section <!-- JobqRollup mount point, kept outside #containers-section
deliberately — that section is wiped + rebuilt on every deliberately — that section is wiped + rebuilt on every
container-list render (see swarm.js::renderContainers), which container-list render (see swarm.js::renderContainers), which
would tear down and remount the Preact tree on every would tear down and remount the Preact tree on every
container-state tick. Mounted once by swarm.js::initJobqRollup, container-state tick. Mounted once by swarm.js::initJobqRollup,
refreshed via its own handle rather than by re-rendering. --> refreshed via its own handle rather than by re-rendering. -->
<div id="jobq-rollup-section"></div> <div id="jobq-rollup-section"></div>
<div id="containers-section"> <div id="containers-section">
<p class="meta">loading…</p> <p class="meta">loading…</p>
</div> </div>
</section> </section>
<!-- Y3R C4LL: things blocked on operator decision — the approval <!-- Y3R C4LL: things blocked on operator decision — the approval
queue; surfaces full bodies inline so the operator can decide queue; surfaces full bodies inline so the operator can decide
without leaving the pane. --> without leaving the pane. -->
<section class="tab-pane" id="tab-pane-call" data-tab-pane="call" hidden <section
role="tabpanel" aria-labelledby="tab-call"> class="tab-pane"
<!-- 1NB0X: unread agent→operator messages. Fetched on id="tab-pane-call"
data-tab-pane="call"
hidden
role="tabpanel"
aria-labelledby="tab-call"
>
<!-- 1NB0X: unread agent→operator messages. Fetched on
tab activation + cold load, appended live from the broker tab activation + cold load, appended live from the broker
stream, cleared via "mark all read". Count folds into the stream, cleared via "mark all read". Count folds into the
Y3R C4LL pill so messages aren't missed. --> Y3R C4LL pill so messages aren't missed. -->
<h2>◆ 1NB0X ◆</h2> <h2>◆ 1NB0X ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div> <div class="divider">
<div id="operator-inbox-section"> ══════════════════════════════════════════════════════════════
<p class="meta">loading…</p> </div>
</div> <div id="operator-inbox-section">
<p class="meta">loading…</p>
</div>
<h2>◆ P3NDING APPR0VALS ◆</h2> <h2>◆ P3NDING APPR0VALS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div> <div class="divider">
<div id="approvals-section"> ══════════════════════════════════════════════════════════════
<p class="meta">loading…</p> </div>
</div> <div id="approvals-section">
<p class="meta">loading…</p>
</div>
<!-- Operator-local preferences (browser notifications). Was its own <!-- Operator-local preferences (browser notifications). Was its own
S3TT1NGS page (`/settings.html`); mara moved it in here since a S3TT1NGS page (`/settings.html`); mara moved it in here since a
standalone page for one toggle didn't earn its own tile. State standalone page for one toggle didn't earn its own tile. State
lives in the browser's localStorage — NOTIF.bind() (common.js) lives in the browser's localStorage — NOTIF.bind() (common.js)
wires the buttons, same dispatch NOTIF.show() already used for wires the buttons, same dispatch NOTIF.show() already used for
approval/inbox notifications above. --> approval/inbox notifications above. -->
<h2>◆ PR3F3R3NC3S ◆</h2> <h2>◆ PR3F3R3NC3S ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div> <div class="divider">
<p class="meta">operator-local preferences. these live in the browser's localStorage — they do not sync between devices and do not survive a profile wipe.</p> ══════════════════════════════════════════════════════════════
<h3>◇ browser notifications</h3> </div>
<p class="meta">desktop notifications for new approvals and broker messages addressed to you. requires a secure context (https or localhost). mute silences the notifications without revoking the OS-level permission.</p> <p class="meta">
<div id="notif-row" class="notif-row"> operator-local preferences. these live in the browser's localStorage
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button> — they do not sync between devices and do not survive a profile
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button> wipe.
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button> </p>
<span id="notif-status" class="meta" hidden></span> <h3>◇ browser notifications</h3>
</div> <p class="meta">
</section> desktop notifications for new approvals and broker messages
addressed to you. requires a secure context (https or localhost).
mute silences the notifications without revoking the OS-level
permission.
</p>
<div id="notif-row" class="notif-row">
<button
type="button"
id="notif-enable"
class="btn btn-notif"
hidden
>
🔔 enable notifications
</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>
🔕 mute
</button>
<button
type="button"
id="notif-unmute"
class="btn btn-notif"
hidden
>
🔔 unmute
</button>
<span id="notif-status" class="meta" hidden></span>
</div>
</section>
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page): <!-- SYST3M pane moved to /core.html (the standalone "C0R3" page):
meta inputs, rebuild queue, kept state, container load. Reached meta inputs, rebuild queue, kept state, container load. Reached
from the H0M3 hub. --> from the H0M3 hub. -->
<!-- P3RM1SS10NS: per-agent capability grants + tool-group <!-- P3RM1SS10NS: per-agent capability grants + tool-group
assignments. Both tables are column-driven from the backend assignments. Both tables are column-driven from the backend
(GET /api/capabilities, GET /api/tool-groups) so new entries (GET /api/capabilities, GET /api/tool-groups) so new entries
require no UI change. Edits stage in-browser across both require no UI change. Edits stage in-browser across both
matrices; the single page-level "save all" button POSTs every matrices; the single page-level "save all" button POSTs every
changed agent to /api/permissions in one atomic batch (caps + changed agent to /api/permissions in one atomic batch (caps +
groups coalesce into one rebuild per agent). --> groups coalesce into one rebuild per agent). -->
<section class="tab-pane" id="tab-pane-permissions" data-tab-pane="permissions" hidden <section
role="tabpanel" aria-labelledby="tab-permissions"> class="tab-pane"
<h2>◆ C4P4B1L1T13S ◆</h2> id="tab-pane-permissions"
<div class="divider">══════════════════════════════════════════════════════════════</div> data-tab-pane="permissions"
<p class="meta">per-agent capability grants. capabilities unlock gated MCP tools and system access. toggle any cells across both tables, then hit "save all" at the bottom — each changed agent rebuilds exactly once.</p> hidden
<div id="capabilities-section"> role="tabpanel"
<p class="meta">loading…</p> aria-labelledby="tab-permissions"
</div> >
<h2>◆ C4P4B1L1T13S ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
per-agent capability grants. capabilities unlock gated MCP tools and
system access. toggle any cells across both tables, then hit "save
all" at the bottom — each changed agent rebuilds exactly once.
</p>
<div id="capabilities-section">
<p class="meta">loading…</p>
</div>
<!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents, <!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents,
cols = tool groups fetched from GET /api/tool-groups. cols = tool groups fetched from GET /api/tool-groups.
Checking / unchecking is staged in the UI; the single page-level Checking / unchecking is staged in the UI; the single page-level
"save all" button POSTs every changed agent to "save all" button POSTs every changed agent to
/api/permissions in one atomic batch, coalescing caps + groups /api/permissions in one atomic batch, coalescing caps + groups
per agent into one rebuild each. per agent into one rebuild each.
Absent agents default to the role default (shown in parens). --> Absent agents default to the role default (shown in parens). -->
<h2>◆ T00L GR0UPS ◆</h2> <h2>◆ T00L GR0UPS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div> <div class="divider">
<p class="meta">per-agent tool-group permissions. columns are filled from the backend — adding a new group requires no UI change. agents without an explicit entry use the role default (agents: messaging, meta, inbox, execution).</p> ══════════════════════════════════════════════════════════════
<div id="tool-groups-section"> </div>
<p class="meta">loading…</p> <p class="meta">
</div> per-agent tool-group permissions. columns are filled from the
backend — adding a new group requires no UI change. agents without
an explicit entry use the role default (agents: messaging, meta,
inbox, execution).
</p>
<div id="tool-groups-section">
<p class="meta">loading…</p>
</div>
<!-- Page-level save bar (save-all issue): one button for the whole <!-- Page-level save bar (save-all issue): one button for the whole
permissions page. Staged checkbox edits across BOTH matrices are permissions page. Staged checkbox edits across BOTH matrices are
diffed against their render-time baselines and POSTed together to diffed against their render-time baselines and POSTed together to
/api/permissions; caps + groups for one agent coalesce into a /api/permissions; caps + groups for one agent coalesce into a
single rebuild. Disabled until something is dirty. The atomic single rebuild. Disabled until something is dirty. The atomic
batch either fully lands (queued ✓ → rebuilding) or fully fails batch either fully lands (queued ✓ → rebuilding) or fully fails
(error note, nothing applied). --> (error note, nothing applied). -->
<div class="perm-save-bar"> <div class="perm-save-bar">
<button type="button" id="perm-save-all" class="btn btn-approve" disabled>save all</button> <button
<span id="perm-save-note" class="meta"></span> type="button"
</div> id="perm-save-all"
</section> class="btn btn-approve"
disabled
>
save all
</button>
<span id="perm-save-note" class="meta"></span>
</div>
</section>
<!-- SCH3DUL3S: scheduled prompts. Creation + edit are folded <!-- SCH3DUL3S: scheduled prompts. Creation + edit are folded
into the same table — empty bottom row is the create form into the same table — empty bottom row is the create form
(fill cells, click ), inline-edit-row expands on the `✎` (fill cells, click ), inline-edit-row expands on the `✎`
toggle for existing schedules. Schedules list driven by toggle for existing schedules. Schedules list driven by
@ -203,60 +295,91 @@
`schedules_changed` SSE; tab activation re-fetches as a `schedules_changed` SSE; tab activation re-fetches as a
safety net for disconnect windows. safety net for disconnect windows.
See docs/web-ui.md::SCH3DUL3S tab. --> See docs/web-ui.md::SCH3DUL3S tab. -->
<section class="tab-pane" id="tab-pane-schedules" data-tab-pane="schedules" hidden <section
role="tabpanel" aria-labelledby="tab-schedules"> class="tab-pane"
<h2>◆ SCH3DUL3S ◆</h2> id="tab-pane-schedules"
<div class="divider">══════════════════════════════════════════════════════════════</div> data-tab-pane="schedules"
<p class="meta">all schedules currently in the table. fill the bottom row to queue a new schedule (operator-direct, no approval gate; recurring when an interval is set). cancel a single target with the row button or the whole schedule with <code>✕ cancel all</code>.</p> hidden
<div id="schedules-section"> role="tabpanel"
<p class="meta">loading…</p> aria-labelledby="tab-schedules"
</div> >
<h2>◆ SCH3DUL3S ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
all schedules currently in the table. fill the bottom row to queue a
new schedule (operator-direct, no approval gate; recurring when an
interval is set). cancel a single target with the row button or the
whole schedule with <code>✕ cancel all</code>.
</p>
<div id="schedules-section">
<p class="meta">loading…</p>
</div>
</section>
</section> <!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
(`/stats.html`), reached from the H0M3 hub. The markup + the (`/stats.html`), reached from the H0M3 hub. The markup + the
hive-stats render JS moved there; when tabs.js boots on the hive-stats render JS moved there; when tabs.js boots on the
dashboard the renderers are simply gone (no stats tab to dashboard the renderers are simply gone (no stats tab to
activate). --> activate). -->
<!-- FL0W: lives on its own page now (`/flow.html`). The <!-- FL0W: lives on its own page now (`/flow.html`). The
message-flow + inbox + compose DOM only exists there — when message-flow + inbox + compose DOM only exists there — when
tabs.js boots on this page the corresponding renderers tabs.js boots on this page the corresponding renderers
no-op silently (each guard is `if (!el) return`). --> no-op silently (each guard is `if (!el) return`). -->
</main>
</div>
<!-- /.page-content -->
</main> <footer>
</div><!-- /.page-content --> <pre class="banner banner-thin">
░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre
>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p>
▲△▲
<a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a>
▲△▲ hive-c0re on this host ▲△▲
</p>
</footer>
<footer> <!-- Slide-in detail panel (clicked file previews, approval diffs,
<pre class="banner banner-thin">░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p>▲△▲ <a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
</footer>
<!-- Slide-in detail panel (clicked file previews, approval diffs,
journald logs, applied config) is a <hive-side-panel> element journald logs, applied config) is a <hive-side-panel> element
(@hive/shared/side-panel.js) — the Panel singleton in common.js (@hive/shared/side-panel.js) — the Panel singleton in common.js
creates + appends it to <body> lazily on first use, so nothing creates + appends it to <body> lazily on first use, so nothing
needs to be pre-declared here. --> needs to be pre-declared here. -->
<!-- Selection action bar. Sticky-bottom strip that slides into <!-- Selection action bar. Sticky-bottom strip that slides into
view when one or more agent cards is selected (click the icon view when one or more agent cards is selected (click the icon
to toggle). See docs/web-ui.md::Selection bar for the bulk to toggle). See docs/web-ui.md::Selection bar for the bulk
action gating + clear semantics. --> action gating + clear semantics. -->
<div id="selection-bar" class="selection-bar" hidden role="toolbar" <div
aria-label="bulk agent actions"> id="selection-bar"
<span class="selection-count" id="selection-count"></span> class="selection-bar"
<span class="selection-names" id="selection-names"></span> hidden
<span class="selection-actions" id="selection-actions"></span> role="toolbar"
<button type="button" class="btn selection-clear" id="selection-clear" aria-label="bulk agent actions"
title="clear selection (esc)">✕ clear</button> >
</div> <span class="selection-count" id="selection-count"></span>
<span class="selection-names" id="selection-names"></span>
<span class="selection-actions" id="selection-actions"></span>
<button
type="button"
class="btn selection-clear"
id="selection-clear"
title="clear selection (esc)"
>
✕ clear
</button>
</div>
<!-- Single bundled entry — tabs.js is the dashboard tabs surface; <!-- Single bundled entry — tabs.js is the dashboard tabs surface;
flow.html has its own flow.js bundle. esbuild folds flow.html has its own flow.js bundle. esbuild folds
@hive/shared/terminal.js and the marked npm package into @hive/shared/terminal.js and the marked npm package into
tabs.js. --> tabs.js. -->
<script type="module" src="/static/tabs.js" defer></script> <script type="module" src="/static/tabs.js" defer></script>
</body> </body>
</html> </html>

View file

@ -23,9 +23,11 @@ body.flow-shell {
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
background: background:
radial-gradient(ellipse 80% 60% at 50% 0%, radial-gradient(
color-mix(in srgb, var(--purple) 6%, transparent) 0%, ellipse 80% 60% at 50% 0%,
transparent 60%), color-mix(in srgb, var(--purple) 6%, transparent) 0%,
transparent 60%
),
var(--bg); var(--bg);
} }
@ -101,5 +103,9 @@ body.flow-shell .flow-main-slim {
padding: 0.25em 0.5em; padding: 0.25em 0.5em;
margin-left: auto; margin-left: auto;
} }
.flow-filter:focus { outline: 1px solid var(--purple); } .flow-filter:focus {
.msgrow.flow-hidden { display: none; } outline: 1px solid var(--purple);
}
.msgrow.flow-hidden {
display: none;
}

View file

@ -1,54 +1,64 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // FL0W</title> <title>hyperhive // FL0W</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css"> <link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css"> <link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/flow.css"> <link rel="stylesheet" href="/static/flow.css" />
</head> </head>
<body class="flow-shell"> <body class="flow-shell">
<!-- Minimal chrome: just a back link to the H0M3 hub (served at /).
<!-- Minimal chrome: just a back link to the H0M3 hub (served at /).
No full tabbar — the flow page is a dedicated full-viewport No full tabbar — the flow page is a dedicated full-viewport
terminal surface; navigating back to the menu is the only chrome terminal surface; navigating back to the menu is the only chrome
needed. Pages link back to H0M3, not the dashboard. --> needed. Pages link back to H0M3, not the dashboard. -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← home</a>
<span class="page-title">FL0W</span> <span class="page-title">FL0W</span>
<select id="flow-agent-filter" class="flow-filter" title="filter timeline by agent" aria-label="filter timeline by agent"> <select
<option value="">all agents</option> id="flow-agent-filter"
</select> class="flow-filter"
</header> title="filter timeline by agent"
aria-label="filter timeline by agent"
>
<option value="">all agents</option>
</select>
</header>
<!-- Main content: the full-viewport terminal. Padded for the <!-- Main content: the full-viewport terminal. Padded for the
overlay header + composer so the first/last rows stay overlay header + composer so the first/last rows stay
reachable. --> reachable. -->
<main class="flow-main flow-main-slim"> <main class="flow-main flow-main-slim">
<div class="terminal-wrap"> <div class="terminal-wrap">
<div id="msgflow" class="live terminal"><div class="meta">connecting…</div></div> <div id="msgflow" class="live terminal">
</div> <div class="meta">connecting…</div>
</main> </div>
</div>
</main>
<!-- Fixed-overlay composer at the bottom. Same frosted treatment <!-- Fixed-overlay composer at the bottom. Same frosted treatment
as the header — symmetric framing, terminal goes edge-to-edge as the header — symmetric framing, terminal goes edge-to-edge
between them. --> between them. -->
<footer class="flow-composer"> <footer class="flow-composer">
<div id="op-compose" class="op-compose"> <div id="op-compose" class="op-compose">
<span id="op-compose-prompt" class="op-compose-prompt">@—&gt;</span> <span id="op-compose-prompt" class="op-compose-prompt">@—&gt;</span>
<textarea id="op-compose-input" class="op-compose-input" <textarea
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)" id="op-compose-input"
rows="1" autocomplete="off"></textarea> class="op-compose-input"
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div> placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
</div> rows="1"
</footer> autocomplete="off"
></textarea>
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
</div>
</footer>
<!-- Flow-specific bundle. Contains the broker terminal init + the <!-- Flow-specific bundle. Contains the broker terminal init + the
@-mention composer. Tab renderers etc. live in @-mention composer. Tab renderers etc. live in
`/static/tabs.js` which /flow.html doesn't load. --> `/static/tabs.js` which /flow.html doesn't load. -->
<script type="module" src="/static/flow.js" defer></script> <script type="module" src="/static/flow.js" defer></script>
</body> </body>
</html> </html>

View file

@ -11,15 +11,16 @@
// initial /api/state fetch (compose autocomplete needs the live // initial /api/state fetch (compose autocomplete needs the live
// container list). // container list).
import { create as termCreate } from '@hive/shared/terminal.js'; import { create as termCreate } from "@hive/shared/terminal.js";
import { import {
$, $,
NOTIF, NOTIF,
appendLinkified, appendLinkified,
openStream, initServerWarnings, openStream,
} from './common.js'; initServerWarnings,
import { el } from '@hive/shared/dom.js'; } from "./common.js";
import { epochSec } from './util.js'; import { el } from "@hive/shared/dom.js";
import { epochSec } from "./util.js";
(() => { (() => {
NOTIF.bind(); NOTIF.bind();
@ -32,11 +33,16 @@ import { epochSec } from './util.js';
// by the same `container_state_changed` / `container_removed` events // by the same `container_state_changed` / `container_removed` events
// the dashboard would handle. // the dashboard would handle.
const flowContainers = new Map(); const flowContainers = new Map();
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => { fetch("/api/state")
if (!s || !Array.isArray(s.containers)) return; .then((r) => (r.ok ? r.json() : null))
for (const c of s.containers) flowContainers.set(c.name, c); .then((s) => {
populateAgentFilter(); if (!s || !Array.isArray(s.containers)) return;
}).catch(() => { /* graceful: compose just shows `*` and nothing else */ }); for (const c of s.containers) flowContainers.set(c.name, c);
populateAgentFilter();
})
.catch(() => {
/* graceful: compose just shows `*` and nothing else */
});
// ─── agent filter ─────────────────────────────────────────────── // ─── agent filter ───────────────────────────────────────────────
// A select in the FL0W header narrows the timeline to messages involving // A select in the FL0W header narrows the timeline to messages involving
@ -46,40 +52,40 @@ import { epochSec } from './util.js';
// filter re-scans existing rows. Selection persists in localStorage so a // filter re-scans existing rows. Selection persists in localStorage so a
// reload / tab-switch keeps the view. Uses `$('msgflow')` for row access // reload / tab-switch keeps the view. Uses `$('msgflow')` for row access
// so it works regardless of the message-flow IIFE's local scope. // so it works regardless of the message-flow IIFE's local scope.
let agentFilter = localStorage.getItem('flow-agent-filter') || ''; let agentFilter = localStorage.getItem("flow-agent-filter") || "";
function rowMatchesFilter(from, to) { function rowMatchesFilter(from, to) {
return !agentFilter || from === agentFilter || to === agentFilter; return !agentFilter || from === agentFilter || to === agentFilter;
} }
function applyAgentFilter() { function applyAgentFilter() {
const flow = $('msgflow'); const flow = $("msgflow");
if (!flow) return; if (!flow) return;
for (const row of flow.children) { for (const row of flow.children) {
const { from, to } = row.dataset; const { from, to } = row.dataset;
if (from === undefined && to === undefined) continue; // non-message row if (from === undefined && to === undefined) continue; // non-message row
row.classList.toggle('flow-hidden', !rowMatchesFilter(from, to)); row.classList.toggle("flow-hidden", !rowMatchesFilter(from, to));
} }
} }
function populateAgentFilter() { function populateAgentFilter() {
const sel = $('flow-agent-filter'); const sel = $("flow-agent-filter");
if (!sel) return; if (!sel) return;
const names = [...flowContainers.keys()].sort(); const names = [...flowContainers.keys()].sort();
sel.replaceChildren(); sel.replaceChildren();
sel.append(el('option', { value: '' }, 'all agents')); sel.append(el("option", { value: "" }, "all agents"));
for (const n of names) sel.append(el('option', { value: n }, n)); for (const n of names) sel.append(el("option", { value: n }, n));
// Preserve a saved selection even if that agent isn't in the live // Preserve a saved selection even if that agent isn't in the live
// container list (yet / anymore) so the filter doesn't silently reset. // container list (yet / anymore) so the filter doesn't silently reset.
if (agentFilter && !names.includes(agentFilter)) { if (agentFilter && !names.includes(agentFilter)) {
sel.append(el('option', { value: agentFilter }, agentFilter)); sel.append(el("option", { value: agentFilter }, agentFilter));
} }
sel.value = agentFilter; sel.value = agentFilter;
} }
{ {
const sel = $('flow-agent-filter'); const sel = $("flow-agent-filter");
if (sel) { if (sel) {
sel.addEventListener('change', () => { sel.addEventListener("change", () => {
agentFilter = sel.value; agentFilter = sel.value;
if (agentFilter) localStorage.setItem('flow-agent-filter', agentFilter); if (agentFilter) localStorage.setItem("flow-agent-filter", agentFilter);
else localStorage.removeItem('flow-agent-filter'); else localStorage.removeItem("flow-agent-filter");
applyAgentFilter(); applyAgentFilter();
}); });
} }
@ -91,7 +97,7 @@ import { epochSec } from './util.js';
// side effects (banner pulse, OS notifications on operator-bound // side effects (banner pulse, OS notifications on operator-bound
// traffic). // traffic).
(() => { (() => {
const flow = $('msgflow'); const flow = $("msgflow");
if (!flow) return; if (!flow) return;
flow.replaceChildren(); flow.replaceChildren();
const tsFmt = (ts) => new Date(ts).toISOString().slice(11, 19); const tsFmt = (ts) => new Date(ts).toISOString().slice(11, 19);
@ -100,13 +106,16 @@ import { epochSec } from './util.js';
// in the flow chrome — `pulseBanner` no-ops on /flow.html since // in the flow chrome — `pulseBanner` no-ops on /flow.html since
// there's no element to find. Kept for parity if a future chrome // there's no element to find. Kept for parity if a future chrome
// change reintroduces a banner. // change reintroduces a banner.
const banner = document.querySelector('.banner'); const banner = document.querySelector(".banner");
let bannerOffTimer = null; let bannerOffTimer = null;
function pulseBanner() { function pulseBanner() {
if (!banner) return; if (!banner) return;
banner.classList.add('active'); banner.classList.add("active");
if (bannerOffTimer) clearTimeout(bannerOffTimer); if (bannerOffTimer) clearTimeout(bannerOffTimer);
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000); bannerOffTimer = setTimeout(
() => banner.classList.remove("active"),
4000,
);
} }
// Map of broker row id → rendered row element. Lets reply rows add // Map of broker row id → rendered row element. Lets reply rows add
// a visual "↳ in reply to" indicator that links back to the parent. // a visual "↳ in reply to" indicator that links back to the parent.
@ -116,55 +125,60 @@ import { epochSec } from './util.js';
function renderMsg(ev, api, glyph) { function renderMsg(ev, api, glyph) {
const isReply = ev.in_reply_to != null; const isReply = ev.in_reply_to != null;
const cls = 'msgrow ' + ev.kind + (isReply ? ' msg-reply' : ''); const cls = "msgrow " + ev.kind + (isReply ? " msg-reply" : "");
const row = api.row(cls, ''); const row = api.row(cls, "");
// Build via DOM so path anchors stay live + escape rules are // Build via DOM so path anchors stay live + escape rules are
// automatic (text nodes don't need esc()). // automatic (text nodes don't need esc()).
const ts = document.createElement('span'); const ts = document.createElement("span");
ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at); ts.className = "msg-ts";
const arrow = document.createElement('span'); ts.textContent = tsFmt(ev.at);
arrow.className = 'msg-arrow'; arrow.textContent = glyph; const arrow = document.createElement("span");
const from = document.createElement('span'); arrow.className = "msg-arrow";
from.className = 'msg-from'; from.textContent = ev.from; arrow.textContent = glyph;
const sep = document.createElement('span'); const from = document.createElement("span");
sep.className = 'msg-sep'; sep.textContent = '→'; from.className = "msg-from";
const to = document.createElement('span'); from.textContent = ev.from;
to.className = 'msg-to'; to.textContent = ev.to; const sep = document.createElement("span");
const body = document.createElement('span'); sep.className = "msg-sep";
body.className = 'msg-body'; sep.textContent = "→";
const to = document.createElement("span");
to.className = "msg-to";
to.textContent = ev.to;
const body = document.createElement("span");
body.className = "msg-body";
appendLinkified(body, ev.body, ev.file_refs); appendLinkified(body, ev.body, ev.file_refs);
// Reply thread indicator: a small "↳ reply to <from>" hint that // Reply thread indicator: a small "↳ reply to <from>" hint that
// shows which message this is responding to. If we have the parent // shows which message this is responding to. If we have the parent
// in our row map, clicking scrolls it into view. // in our row map, clicking scrolls it into view.
if (isReply) { if (isReply) {
const replyTag = document.createElement('span'); const replyTag = document.createElement("span");
replyTag.className = 'msg-reply-tag'; replyTag.className = "msg-reply-tag";
const parentRow = msgRowMap.get(ev.in_reply_to); const parentRow = msgRowMap.get(ev.in_reply_to);
if (parentRow) { if (parentRow) {
const link = document.createElement('a'); const link = document.createElement("a");
link.href = '#'; link.href = "#";
link.textContent = '↳ reply'; link.textContent = "↳ reply";
link.title = 'scroll to parent message'; link.title = "scroll to parent message";
link.addEventListener('click', (e) => { link.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
parentRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); parentRow.scrollIntoView({ behavior: "smooth", block: "nearest" });
parentRow.classList.add('msg-highlight'); parentRow.classList.add("msg-highlight");
setTimeout(() => parentRow.classList.remove('msg-highlight'), 1500); setTimeout(() => parentRow.classList.remove("msg-highlight"), 1500);
}); });
replyTag.append(link); replyTag.append(link);
} else { } else {
replyTag.textContent = '↳ reply'; replyTag.textContent = "↳ reply";
} }
row.prepend(replyTag); row.prepend(replyTag);
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body); row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
} else { } else {
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body); row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
} }
// Tag with the participants so the agent filter can match // Tag with the participants so the agent filter can match
// on `from`/`to`, and hide the row up-front if a filter is active. // on `from`/`to`, and hide the row up-front if a filter is active.
row.dataset.from = ev.from; row.dataset.from = ev.from;
row.dataset.to = ev.to; row.dataset.to = ev.to;
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add('flow-hidden'); if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add("flow-hidden");
// Register this row so future replies can reference it. // Register this row so future replies can reference it.
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row); if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
return row; return row;
@ -193,13 +207,13 @@ import { epochSec } from './util.js';
if (ev.id == null || ev.id <= 0) return false; if (ev.id == null || ev.id <= 0) return false;
const s = recentSent.get(ev.id); const s = recentSent.get(ev.id);
if (!s || epochSec(ev.at) - s.at > COLLAPSE_SECS) return false; if (!s || epochSec(ev.at) - s.at > COLLAPSE_SECS) return false;
const arrow = s.row.querySelector('.msg-arrow'); const arrow = s.row.querySelector(".msg-arrow");
if (arrow) arrow.textContent = '✓'; if (arrow) arrow.textContent = "✓";
// Re-style the row as delivered (green ✓) — the collapsed line now // Re-style the row as delivered (green ✓) — the collapsed line now
// represents the delivered state; it was sent + delivered as one. // represents the delivered state; it was sent + delivered as one.
s.row.classList.remove('sent'); s.row.classList.remove("sent");
s.row.classList.add('delivered'); s.row.classList.add("delivered");
s.row.title = 'sent + delivered'; s.row.title = "sent + delivered";
recentSent.delete(ev.id); recentSent.delete(ev.id);
return true; return true;
} }
@ -207,11 +221,11 @@ import { epochSec } from './util.js';
// default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent // default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent
// page (Terminal-wrap) for the backdrop-filter stacking-context // page (Terminal-wrap) for the backdrop-filter stacking-context
// gotcha (same shape on the flow page). // gotcha (same shape on the flow page).
const flowMain = document.querySelector('.flow-main'); const flowMain = document.querySelector(".flow-main");
termCreate({ termCreate({
logEl: flow, logEl: flow,
pillAnchor: flowMain, pillAnchor: flowMain,
historyUrl: '/api/dashboard/history', historyUrl: "/api/dashboard/history",
// Server-side filter — only the kinds this page actually renders // Server-side filter — only the kinds this page actually renders
// or routes (sent/delivered → broker terminal, // or routes (sent/delivered → broker terminal,
// container_state_changed/_removed → local autocomplete cache). // container_state_changed/_removed → local autocomplete cache).
@ -220,18 +234,19 @@ import { epochSec } from './util.js';
// JSON-serialise is skipped entirely on irrelevant kinds. The // JSON-serialise is skipped entirely on irrelevant kinds. The
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe // dashboard tabs page (tabs.js) keeps the unfiltered subscribe
// since it routes every mutation kind into its derived stores. // since it routes every mutation kind into its derived stores.
streamUrl: '/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed', streamUrl:
"/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed",
// Route through the SharedWorker — see docs/web-ui.md (SSE // Route through the SharedWorker — see docs/web-ui.md (SSE
// multiplexing paragraph). Worker keys on the full URL incl. // multiplexing paragraph). Worker keys on the full URL incl.
// query string, so this filtered subscribe is its own upstream // query string, so this filtered subscribe is its own upstream
// and won't accidentally share with tabs.js's wider subscribe. // and won't accidentally share with tabs.js's wider subscribe.
streamFactory: openStream, streamFactory: openStream,
renderers: { renderers: {
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, '→')), sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, "→")),
delivered: (ev, api) => { delivered: (ev, api) => {
// Fold into the matching sent row when it just happened; // Fold into the matching sent row when it just happened;
// otherwise render the delivery as its own line. // otherwise render the delivery as its own line.
if (!collapseDelivered(ev)) renderMsg(ev, api, '✓'); if (!collapseDelivered(ev)) renderMsg(ev, api, "✓");
}, },
// Maintain the local containers cache from the same stream // Maintain the local containers cache from the same stream
// (compose autocomplete reads from `flowContainers`). The // (compose autocomplete reads from `flowContainers`). The
@ -259,21 +274,27 @@ import { epochSec } from './util.js';
// are never replayed, so without this the compose autocomplete // are never replayed, so without this the compose autocomplete
// could drift stale. // could drift stale.
onStreamOpen: () => { onStreamOpen: () => {
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => { fetch("/api/state")
if (!s || !Array.isArray(s.containers)) return; .then((r) => (r.ok ? r.json() : null))
flowContainers.clear(); .then((s) => {
for (const c of s.containers) flowContainers.set(c.name, c); if (!s || !Array.isArray(s.containers)) return;
}).catch(() => {}); flowContainers.clear();
for (const c of s.containers) flowContainers.set(c.name, c);
})
.catch(() => {});
}, },
onLiveEvent: (ev) => { onLiveEvent: (ev) => {
pulseBanner(); pulseBanner();
if (ev.kind === 'sent' && ev.to === 'operator') { if (ev.kind === "sent" && ev.to === "operator") {
NOTIF.show( NOTIF.show(
'◆ ' + ev.from + ' → operator', "◆ " + ev.from + " → operator",
String(ev.body || '').slice(0, 200), String(ev.body || "").slice(0, 200),
// Unique-per-arrival tag so a burst stacks instead of // Unique-per-arrival tag so a burst stacks instead of
// overwriting itself in the OS notification center. // overwriting itself in the OS notification center.
'hyperhive:msg:' + ev.at + ':' + Math.random().toString(36).slice(2, 6), "hyperhive:msg:" +
ev.at +
":" +
Math.random().toString(36).slice(2, 6),
); );
} }
}, },
@ -282,28 +303,27 @@ import { epochSec } from './util.js';
// ─── compose: @-mention with sticky recipient ─────────────────────────── // ─── compose: @-mention with sticky recipient ───────────────────────────
(() => { (() => {
const input = $('op-compose-input'); const input = $("op-compose-input");
const prompt = $('op-compose-prompt'); const prompt = $("op-compose-prompt");
const suggest = $('op-compose-suggest'); const suggest = $("op-compose-suggest");
if (!input || !prompt || !suggest) return; if (!input || !prompt || !suggest) return;
const STORAGE_KEY = 'hyperhive:op-compose:to'; const STORAGE_KEY = "hyperhive:op-compose:to";
let stickyTo = localStorage.getItem(STORAGE_KEY) || ''; let stickyTo = localStorage.getItem(STORAGE_KEY) || "";
let suggestActive = -1; let suggestActive = -1;
function renderPrompt() { function renderPrompt() {
prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>'; prompt.textContent = stickyTo ? `@${stickyTo}>` : "@—>";
} }
function knownAgents() { function knownAgents() {
// Read live from the flow-local containers cache so newly-spawned // Read live from the flow-local containers cache so newly-spawned
// agents become addressable without a manual reload. // agents become addressable without a manual reload.
const names = Array.from(flowContainers.values()) const names = Array.from(flowContainers.values()).map((c) => c.name);
.map((c) => c.name);
// `*` fans out to every registered agent (server-side // `*` fans out to every registered agent (server-side
// broadcast_send). // broadcast_send).
names.unshift('*'); names.unshift("*");
return names; return names;
} }
function autosize() { function autosize() {
input.style.height = 'auto'; input.style.height = "auto";
input.style.height = `${input.scrollHeight}px`; input.style.height = `${input.scrollHeight}px`;
} }
/// Parse "@name body…" — return {to, body} when the input opens /// Parse "@name body…" — return {to, body} when the input opens
@ -320,12 +340,15 @@ import { epochSec } from './util.js';
} }
function renderSuggest(matches) { function renderSuggest(matches) {
suggest.replaceChildren(); suggest.replaceChildren();
if (!matches.length) { hideSuggest(); return; } if (!matches.length) {
hideSuggest();
return;
}
for (let i = 0; i < matches.length; i += 1) { for (let i = 0; i < matches.length; i += 1) {
const item = document.createElement('div'); const item = document.createElement("div");
item.className = 'item' + (i === suggestActive ? ' active' : ''); item.className = "item" + (i === suggestActive ? " active" : "");
item.textContent = '@' + matches[i]; item.textContent = "@" + matches[i];
item.addEventListener('mousedown', (e) => { item.addEventListener("mousedown", (e) => {
e.preventDefault(); e.preventDefault();
applySuggestion(matches[i]); applySuggestion(matches[i]);
}); });
@ -338,7 +361,7 @@ import { epochSec } from './util.js';
const v = input.value; const v = input.value;
const m = v.match(/^@(\S*)/); const m = v.match(/^@(\S*)/);
if (m) { if (m) {
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, ''); input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, "");
} else { } else {
input.value = `@${name} ` + v; input.value = `@${name} ` + v;
} }
@ -353,11 +376,20 @@ import { epochSec } from './util.js';
// input — switching recipient is always "redirect this whole // input — switching recipient is always "redirect this whole
// line." Mid-message @-mentions stay literal. // line." Mid-message @-mentions stay literal.
const m = v.match(/^@(\S*)/); const m = v.match(/^@(\S*)/);
if (!m) { hideSuggest(); return; } if (!m) {
hideSuggest();
return;
}
const partial = m[1].toLowerCase(); const partial = m[1].toLowerCase();
const matches = knownAgents().filter((n) => n.toLowerCase().startsWith(partial)); const matches = knownAgents().filter((n) =>
if (!matches.length) { hideSuggest(); return; } n.toLowerCase().startsWith(partial),
if (suggestActive < 0 || suggestActive >= matches.length) suggestActive = 0; );
if (!matches.length) {
hideSuggest();
return;
}
if (suggestActive < 0 || suggestActive >= matches.length)
suggestActive = 0;
renderSuggest(matches); renderSuggest(matches);
} }
async function submit() { async function submit() {
@ -373,20 +405,20 @@ import { epochSec } from './util.js';
to = stickyTo; to = stickyTo;
body = raw; body = raw;
} else { } else {
flashError('no recipient — start with @name to address a message'); flashError("no recipient — start with @name to address a message");
return; return;
} }
if (!body) return; if (!body) return;
const fd = new FormData(); const fd = new FormData();
fd.append('to', to); fd.append("to", to);
fd.append('body', body); fd.append("body", body);
input.disabled = true; input.disabled = true;
try { try {
// /op-send returns 200. The SSE channel carries the resulting // /op-send returns 200. The SSE channel carries the resulting
// MessageEvent → the terminal renders the sent row on its own; // MessageEvent → the terminal renders the sent row on its own;
// no /api/state refetch needed. // no /api/state refetch needed.
const resp = await fetch('/api/op-send', { const resp = await fetch("/api/op-send", {
method: 'POST', method: "POST",
body: new URLSearchParams(fd), body: new URLSearchParams(fd),
}); });
if (!resp.ok) { if (!resp.ok) {
@ -401,17 +433,17 @@ import { epochSec } from './util.js';
} }
stickyTo = to; stickyTo = to;
localStorage.setItem(STORAGE_KEY, to); localStorage.setItem(STORAGE_KEY, to);
input.value = ''; input.value = "";
autosize(); autosize();
renderPrompt(); renderPrompt();
input.focus(); input.focus();
} }
function flashError(msg) { function flashError(msg) {
const flow = $('msgflow'); const flow = $("msgflow");
if (!flow) return; if (!flow) return;
const row = document.createElement('div'); const row = document.createElement("div");
row.className = 'msgrow meta'; row.className = "msgrow meta";
row.textContent = '✗ ' + msg; row.textContent = "✗ " + msg;
// Append at the bottom so the error is visible — the terminal // Append at the bottom so the error is visible — the terminal
// renders newest-last, so inserting before firstChild would place // renders newest-last, so inserting before firstChild would place
// the error at the top (oldest end) and hide it from view. // the error at the top (oldest end) and hide it from view.
@ -420,43 +452,46 @@ import { epochSec } from './util.js';
const wrap = flow.parentElement; const wrap = flow.parentElement;
if (wrap) wrap.scrollTop = wrap.scrollHeight; if (wrap) wrap.scrollTop = wrap.scrollHeight;
} }
input.addEventListener('input', () => { autosize(); updateSuggest(); }); input.addEventListener("input", () => {
input.addEventListener('keydown', (e) => { autosize();
updateSuggest();
});
input.addEventListener("keydown", (e) => {
if (!suggest.hidden) { if (!suggest.hidden) {
if (e.key === 'ArrowDown') { if (e.key === "ArrowDown") {
const items = suggest.querySelectorAll('.item'); const items = suggest.querySelectorAll(".item");
suggestActive = (suggestActive + 1) % items.length; suggestActive = (suggestActive + 1) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1))); renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault(); e.preventDefault();
return; return;
} }
if (e.key === 'ArrowUp') { if (e.key === "ArrowUp") {
const items = suggest.querySelectorAll('.item'); const items = suggest.querySelectorAll(".item");
suggestActive = (suggestActive - 1 + items.length) % items.length; suggestActive = (suggestActive - 1 + items.length) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1))); renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault(); e.preventDefault();
return; return;
} }
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) { if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
const active = suggest.querySelector('.item.active'); const active = suggest.querySelector(".item.active");
if (active) { if (active) {
applySuggestion(active.textContent.slice(1)); applySuggestion(active.textContent.slice(1));
e.preventDefault(); e.preventDefault();
return; return;
} }
} }
if (e.key === 'Escape') { if (e.key === "Escape") {
hideSuggest(); hideSuggest();
e.preventDefault(); e.preventDefault();
return; return;
} }
} }
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
submit(); submit();
} }
}); });
input.addEventListener('blur', () => { input.addEventListener("blur", () => {
// Defer so a click on a suggestion item (mousedown) lands first. // Defer so a click on a suggestion item (mousedown) lands first.
setTimeout(hideSuggest, 100); setTimeout(hideSuggest, 100);
}); });

View file

@ -57,13 +57,17 @@ body.home-shell {
background: color-mix(in srgb, var(--bg-elev) 55%, transparent); background: color-mix(in srgb, var(--bg-elev) 55%, transparent);
text-decoration: none; text-decoration: none;
color: var(--fg); color: var(--fg);
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease, transition:
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease; box-shadow 0.15s ease;
} }
.home-tile:hover { .home-tile:hover {
border-color: var(--purple); border-color: var(--purple);
background: color-mix(in srgb, var(--purple) 6%, transparent); background: color-mix(in srgb, var(--purple) 6%, transparent);
box-shadow: 0 -2px 14px -6px color-mix(in srgb, var(--purple) 50%, transparent); box-shadow: 0 -2px 14px -6px
color-mix(in srgb, var(--purple) 50%, transparent);
} }
/* Icon + label share the top row of each tile; the description sits /* Icon + label share the top row of each tile; the description sits

View file

@ -7,14 +7,14 @@
// It also drives the decorative matrix-rain backdrop (#matrix-rain) — // It also drives the decorative matrix-rain backdrop (#matrix-rain) —
// see startMatrixRain() at the foot. // see startMatrixRain() at the foot.
import { renderServerWarnings } from './common.js'; import { renderServerWarnings } from "./common.js";
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
async function init() { async function init() {
let state; let state;
try { try {
const resp = await fetch('/api/state'); const resp = await fetch("/api/state");
if (!resp.ok) return; if (!resp.ok) return;
state = await resp.json(); state = await resp.json();
} catch { } catch {
@ -23,10 +23,10 @@ async function init() {
renderServerWarnings(state.server_warnings); renderServerWarnings(state.server_warnings);
const ident = $('hive-identity'); const ident = $("hive-identity");
if (ident && (state.swarm_name || state.hive_name)) { if (ident && (state.swarm_name || state.hive_name)) {
const parts = [state.swarm_name, state.hive_name].filter(Boolean); const parts = [state.swarm_name, state.hive_name].filter(Boolean);
ident.textContent = parts.join(' / '); ident.textContent = parts.join(" / ");
ident.hidden = false; ident.hidden = false;
} }
@ -41,10 +41,12 @@ async function init() {
// rather than truncating from the tail, so two different builds don't // rather than truncating from the tail, so two different builds don't
// render as the same string. Falls back to a plain head-slice for a // render as the same string. Falls back to a plain head-slice for a
// non-store path (e.g. a bare local dir during dev). // non-store path (e.g. a bare local dir during dev).
const rev = $('hive-rev'); const rev = $("hive-rev");
if (rev && state.hyperhive_rev) { if (rev && state.hyperhive_rev) {
const storeMatch = state.hyperhive_rev.match(/^\/nix\/store\/([^-]+)/); const storeMatch = state.hyperhive_rev.match(/^\/nix\/store\/([^-]+)/);
const short = storeMatch ? storeMatch[1].slice(0, 12) : state.hyperhive_rev.slice(0, 12); const short = storeMatch
? storeMatch[1].slice(0, 12)
: state.hyperhive_rev.slice(0, 12);
rev.textContent = `rev ${short}`; rev.textContent = `rev ${short}`;
rev.title = state.hyperhive_rev; rev.title = state.hyperhive_rev;
rev.hidden = false; rev.hidden = false;
@ -61,34 +63,40 @@ init();
// steppy cadence is the look and costs little CPU); paused while the tab is // steppy cadence is the look and costs little CPU); paused while the tab is
// hidden, and skipped entirely under prefers-reduced-motion. // hidden, and skipped entirely under prefers-reduced-motion.
function startMatrixRain() { function startMatrixRain() {
const canvas = $('matrix-rain'); const canvas = $("matrix-rain");
if (!canvas || !canvas.getContext) return; if (!canvas || !canvas.getContext) return;
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; if (
const ctx = canvas.getContext('2d'); window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
)
return;
const ctx = canvas.getContext("2d");
// getComputedStyle on a custom property returns its *declared* value // getComputedStyle on a custom property returns its *declared* value
// (e.g. "var(--base0B)") unresolved, so resolve to a concrete rgb() by // (e.g. "var(--base0B)") unresolved, so resolve to a concrete rgb() by
// probing a real `color` computation instead. // probing a real `color` computation instead.
function resolveColor(varName, fallback) { function resolveColor(varName, fallback) {
const probe = document.createElement('span'); const probe = document.createElement("span");
probe.style.color = `var(${varName})`; probe.style.color = `var(${varName})`;
probe.style.display = 'none'; probe.style.display = "none";
document.body.appendChild(probe); document.body.appendChild(probe);
const c = getComputedStyle(probe).color; const c = getComputedStyle(probe).color;
probe.remove(); probe.remove();
return /^rgb/.test(c) ? c : fallback; return /^rgb/.test(c) ? c : fallback;
} }
const glyphColor = resolveColor('--green', 'rgb(166, 227, 161)'); const glyphColor = resolveColor("--green", "rgb(166, 227, 161)");
// Fade-trail = the bg at low alpha. Pull r,g,b numerically rather than // Fade-trail = the bg at low alpha. Pull r,g,b numerically rather than
// string-rewriting the rgb() — robust to whatever rgb()/rgba() spacing // string-rewriting the rgb() — robust to whatever rgb()/rgba() spacing
// getComputedStyle hands back, so the trail always fades (never fills // getComputedStyle hands back, so the trail always fades (never fills
// solid) even if the format shifts. // solid) even if the format shifts.
const bgRGB = resolveColor('--bg', 'rgb(30, 30, 46)').match(/(\d+)[^\d]+(\d+)[^\d]+(\d+)/); const bgRGB = resolveColor("--bg", "rgb(30, 30, 46)").match(
/(\d+)[^\d]+(\d+)[^\d]+(\d+)/,
);
const fadeColor = bgRGB const fadeColor = bgRGB
? `rgba(${bgRGB[1]}, ${bgRGB[2]}, ${bgRGB[3]}, 0.09)` ? `rgba(${bgRGB[1]}, ${bgRGB[2]}, ${bgRGB[3]}, 0.09)`
: 'rgba(30, 30, 46, 0.09)'; : "rgba(30, 30, 46, 0.09)";
const GLYPHS = 'アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦'; const GLYPHS = "アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦";
const CELL = 16; // glyph cell size (px) const CELL = 16; // glyph cell size (px)
let cols = 0; let cols = 0;
let drops = []; let drops = [];
@ -109,18 +117,28 @@ function startMatrixRain() {
for (let i = 0; i < cols; i++) { for (let i = 0; i < cols; i++) {
const ch = GLYPHS[(Math.random() * GLYPHS.length) | 0]; const ch = GLYPHS[(Math.random() * GLYPHS.length) | 0];
ctx.fillText(ch, i * CELL, drops[i] * CELL); ctx.fillText(ch, i * CELL, drops[i] * CELL);
if (drops[i] * CELL > canvas.height && Math.random() > 0.975) drops[i] = 0; if (drops[i] * CELL > canvas.height && Math.random() > 0.975)
drops[i] = 0;
drops[i]++; drops[i]++;
} }
} }
let timer = null; let timer = null;
const play = () => { if (!timer) timer = setInterval(tick, 55); }; const play = () => {
const pause = () => { if (timer) { clearInterval(timer); timer = null; } }; if (!timer) timer = setInterval(tick, 55);
};
const pause = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
resize(); resize();
window.addEventListener('resize', resize); window.addEventListener("resize", resize);
document.addEventListener('visibilitychange', () => (document.hidden ? pause() : play())); document.addEventListener("visibilitychange", () =>
document.hidden ? pause() : play(),
);
play(); play();
} }

View file

@ -1,112 +1,122 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // h0m3</title> <title>hyperhive // h0m3</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css"> <link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css"> <link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/home.css"> <link rel="stylesheet" href="/static/home.css" />
</head> </head>
<body class="home-shell"> <body class="home-shell">
<!-- Decorative matrix-rain backdrop: a dimmed full-viewport canvas of
<!-- Decorative matrix-rain backdrop: a dimmed full-viewport canvas of
falling glyphs behind everything (home.js drives it). aria-hidden + falling glyphs behind everything (home.js drives it). aria-hidden +
pointer-events:none so it's purely cosmetic; glyph/bg colours are pointer-events:none so it's purely cosmetic; glyph/bg colours are
read from the stylix palette at runtime so a theme swap re-colours read from the stylix palette at runtime so a theme swap re-colours
it, and it's disabled under prefers-reduced-motion. --> it, and it's disabled under prefers-reduced-motion. -->
<canvas id="matrix-rain" aria-hidden="true"></canvas> <canvas id="matrix-rain" aria-hidden="true"></canvas>
<!-- H0M3: the menu hub. A plain grid of links to every top-level <!-- H0M3: the menu hub. A plain grid of links to every top-level
surface. This is the page served at `/` — the landing page — with surface. This is the page served at `/` — the landing page — with
the dashboard relocated to /dashboard.html. No tabbar / SSE — the dashboard relocated to /dashboard.html. No tabbar / SSE —
it's a static portal. --> it's a static portal. -->
<!-- Padded content wrapper: carries the 1.5em side gutter <!-- Padded content wrapper: carries the 1.5em side gutter
(.page-content, common.css) while <body> stays full-bleed so the (.page-content, common.css) while <body> stays full-bleed so the
server-warnings banner spans the full width. --> server-warnings banner spans the full width. -->
<div class="page-content"> <div class="page-content">
<header class="home-header"> <header class="home-header">
<p class="banner-thin" id="hive-identity" hidden></p> <p class="banner-thin" id="hive-identity" hidden></p>
<pre class="banner">░▒▓█▓▒░ ░▒▓█▓▒░ H Y P E R H I V E · H0M3 ░▒▓█▓▒░ ░▒▓█▓▒░</pre> <pre class="banner">
<p class="banner-thin" id="hive-rev" hidden></p> ░▒▓█▓▒░ ░▒▓█▓▒░ H Y P E R H I V E · H0M3 ░▒▓█▓▒░ ░▒▓█▓▒░</pre
</header> >
<p class="banner-thin" id="hive-rev" hidden></p>
</header>
<main class="home-main"> <main class="home-main">
<nav class="home-menu" aria-label="hyperhive surfaces"> <nav class="home-menu" aria-label="hyperhive surfaces">
<a class="home-tile" href="/dashboard.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🖥</span>
<span class="home-tile-label">Dashboard</span>
</span>
<span class="home-tile-desc"
>containers · approvals · permissions · schedules · system</span
>
</a>
<a class="home-tile" href="/dashboard.html"> <a class="home-tile" href="/flow.html">
<span class="home-tile-head"> <span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🖥</span> <span class="home-tile-icon" aria-hidden="true">📡</span>
<span class="home-tile-label">Dashboard</span> <span class="home-tile-label">Flow</span>
</span> </span>
<span class="home-tile-desc">containers · approvals · permissions · schedules · system</span> <span class="home-tile-desc">live all-agents message firehose</span>
</a> </a>
<a class="home-tile" href="/flow.html"> <a class="home-tile" href="/builds.html">
<span class="home-tile-head"> <span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📡</span> <span class="home-tile-icon" aria-hidden="true">🔨</span>
<span class="home-tile-label">Flow</span> <span class="home-tile-label">Builds</span>
</span> </span>
<span class="home-tile-desc">live all-agents message firehose</span> <span class="home-tile-desc"
</a> >rebuild queue · meta inputs · build logs</span
>
</a>
<a class="home-tile" href="/builds.html"> <a class="home-tile" href="/logs.html">
<span class="home-tile-head"> <span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔨</span> <span class="home-tile-icon" aria-hidden="true">📜</span>
<span class="home-tile-label">Builds</span> <span class="home-tile-label">Logs</span>
</span> </span>
<span class="home-tile-desc">rebuild queue · meta inputs · build logs</span> <span class="home-tile-desc">agent · infra · system logs</span>
</a> </a>
<a class="home-tile" href="/logs.html"> <a class="home-tile" href="/stats.html">
<span class="home-tile-head"> <span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📜</span> <span class="home-tile-icon" aria-hidden="true">📊</span>
<span class="home-tile-label">Logs</span> <span class="home-tile-label">Stats</span>
</span> </span>
<span class="home-tile-desc">agent · infra · system logs</span> <span class="home-tile-desc"
</a> >hive-wide turn stats · cost · model mix</span
>
</a>
<a class="home-tile" href="/stats.html"> <a class="home-tile" href="/core.html">
<span class="home-tile-head"> <span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📊</span> <span class="home-tile-icon" aria-hidden="true"></span>
<span class="home-tile-label">Stats</span> <span class="home-tile-label">Core</span>
</span> </span>
<span class="home-tile-desc">hive-wide turn stats · cost · model mix</span> <span class="home-tile-desc">kept state · container load</span>
</a> </a>
<a class="home-tile" href="/core.html"> <a class="home-tile" href="/credentials.html">
<span class="home-tile-head"> <span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true"></span> <span class="home-tile-icon" aria-hidden="true">🔑</span>
<span class="home-tile-label">Core</span> <span class="home-tile-label">Credentials</span>
</span> </span>
<span class="home-tile-desc">kept state · container load</span> <span class="home-tile-desc"
</a> >provision per-agent matrix + github accounts</span
>
</a>
<a class="home-tile" href="/credentials.html"> <!-- API tile: the OpenAPI spec + Swagger UI are always served by
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔑</span>
<span class="home-tile-label">Credentials</span>
</span>
<span class="home-tile-desc">provision per-agent matrix + github accounts</span>
</a>
<!-- API tile: the OpenAPI spec + Swagger UI are always served by
hive-c0re itself (docs/web-ui/dashboard.md::Dashboard hive-c0re itself (docs/web-ui/dashboard.md::Dashboard
endpoints), so this tile is never gated/hidden. --> endpoints), so this tile is never gated/hidden. -->
<a class="home-tile" href="/api/docs"> <a class="home-tile" href="/api/docs">
<span class="home-tile-head"> <span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🧬</span> <span class="home-tile-icon" aria-hidden="true">🧬</span>
<span class="home-tile-label">API</span> <span class="home-tile-label">API</span>
</span> </span>
<span class="home-tile-desc">interactive OpenAPI spec (Swagger UI)</span> <span class="home-tile-desc"
</a> >interactive OpenAPI spec (Swagger UI)</span
>
</a>
</nav>
</main>
</div>
<!-- /.page-content -->
</nav> <script type="module" src="/static/home.js" defer></script>
</main> </body>
</div><!-- /.page-content -->
<script type="module" src="/static/home.js" defer></script>
</body>
</html> </html>

View file

@ -33,7 +33,9 @@ body.logs-shell {
flex-direction: column; flex-direction: column;
} }
.logs-pane[hidden] { display: none; } .logs-pane[hidden] {
display: none;
}
.logs-pane { .logs-pane {
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
@ -75,9 +77,15 @@ body.logs-shell {
padding: 0.25em 0.5em; padding: 0.25em 0.5em;
font-size: 0.85em; font-size: 0.85em;
} }
.journal-refresh { font-size: 0.75em; padding: 0.15em 0.5em; } .journal-refresh {
font-size: 0.75em;
padding: 0.15em 0.5em;
}
/* "fetched N ago" chip next to the refresh button on AGENT + SYSTEM tabs. */ /* "fetched N ago" chip next to the refresh button on AGENT + SYSTEM tabs. */
.logs-fetch-ts { font-size: 0.8em; color: var(--muted); } .logs-fetch-ts {
font-size: 0.8em;
color: var(--muted);
}
.journal-output { .journal-output {
margin: 0; margin: 0;
background: var(--crust); background: var(--crust);
@ -108,7 +116,10 @@ body.logs-shell {
gap: 0.5em; gap: 0.5em;
align-items: center; align-items: center;
} }
.build-logs-refresh { font-size: 0.75em; padding: 0.15em 0.5em; } .build-logs-refresh {
font-size: 0.75em;
padding: 0.15em 0.5em;
}
.build-logs-list { .build-logs-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
@ -124,7 +135,9 @@ body.logs-shell {
font-size: 0.85em; font-size: 0.85em;
padding: 0.3em 0; padding: 0.3em 0;
} }
.build-logs-error { color: var(--red); } .build-logs-error {
color: var(--red);
}
.build-logs-item { .build-logs-item {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
padding: 0.15em 0; padding: 0.15em 0;
@ -145,9 +158,15 @@ body.logs-shell {
cursor: pointer; cursor: pointer;
border-radius: 3px; border-radius: 3px;
} }
.build-logs-row-btn:hover { background: var(--bg-elev); } .build-logs-row-btn:hover {
.build-logs-kind { font-weight: 600; } background: var(--bg-elev);
.build-logs-age { font-size: 0.88em; } }
.build-logs-kind {
font-weight: 600;
}
.build-logs-age {
font-size: 0.88em;
}
.build-logs-cmdline { .build-logs-cmdline {
color: var(--muted); color: var(--muted);
font-size: 0.82em; font-size: 0.82em;
@ -156,7 +175,9 @@ body.logs-shell {
white-space: nowrap; white-space: nowrap;
max-width: 40em; max-width: 40em;
} }
.build-logs-detail { padding: 0 0.4em 0.4em; } .build-logs-detail {
padding: 0 0.4em 0.4em;
}
.build-logs-output { .build-logs-output {
margin: 0.3em 0 0; margin: 0.3em 0 0;
padding: 0.4em 0.6em; padding: 0.4em 0.6em;
@ -181,16 +202,32 @@ body.logs-shell {
margin-top: 0.3em; margin-top: 0.3em;
display: none; display: none;
} }
.build-logs-dl:not([hidden]) { display: inline-block; } .build-logs-dl:not([hidden]) {
.build-logs-dl:hover { color: var(--fg); border-color: var(--purple-dim); } display: inline-block;
}
.build-logs-dl:hover {
color: var(--fg);
border-color: var(--purple-dim);
}
.build-logs-live-badge { margin-bottom: 0.4em; } .build-logs-live-badge {
margin-bottom: 0.4em;
}
@keyframes live-pulse { @keyframes live-pulse {
0%, 100% { opacity: 1; } 0%,
50% { opacity: 0.45; } 100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
.build-logs-live-badge.badge-running {
animation: live-pulse 1.4s ease-in-out infinite;
} }
.build-logs-live-badge.badge-running { animation: live-pulse 1.4s ease-in-out infinite; }
.build-logs-runtime { font-size: 0.85em; color: var(--muted); }
.build-logs-runtime {
font-size: 0.85em;
color: var(--muted);
}

View file

@ -1,82 +1,112 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // LOGS</title> <title>hyperhive // LOGS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css"> <link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css"> <link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/logs.css"> <link rel="stylesheet" href="/static/logs.css" />
</head> </head>
<body class="logs-shell"> <body class="logs-shell">
<!-- Minimal chrome: back link + sub-tab strip.
<!-- Minimal chrome: back link + sub-tab strip.
Same pattern as flow.html — no full dashboard tabbar. Back link Same pattern as flow.html — no full dashboard tabbar. Back link
points to the H0M3 hub (served at /), not the dashboard. points to the H0M3 hub (served at /), not the dashboard.
Three sub-tabs: AGENT, INFRA, SYSTEM. Build log history has Three sub-tabs: AGENT, INFRA, SYSTEM. Build log history has
moved to /builds.html (the build lifecycle hub). --> moved to /builds.html (the build lifecycle hub). -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← home</a>
<hive-tab-strip class="hive-tabbar logs-tabbar" id="logs-tabbar" prefix="logs" <hive-tab-strip
role="tablist"></hive-tab-strip> class="hive-tabbar logs-tabbar"
</header> id="logs-tabbar"
prefix="logs"
role="tablist"
></hive-tab-strip>
</header>
<main class="logs-main"> <main class="logs-main">
<!-- AGENT: journald viewer for a specific agent container.
<!-- AGENT: journald viewer for a specific agent container.
Agent selector + unit filter + line count. Backed by Agent selector + unit filter + line count. Backed by
GET /api/journal/{agent}?unit=<unit>&lines=N. --> GET /api/journal/{agent}?unit=<unit>&lines=N. -->
<section class="logs-pane" id="logs-pane-agent" data-tab-pane="agent" <section
role="tabpanel" aria-labelledby="logs-tab-agent"> class="logs-pane"
<div class="logs-toolbar"> id="logs-pane-agent"
<select id="agent-select" class="journal-unit"></select> data-tab-pane="agent"
<select id="agent-unit-select" class="journal-unit"> role="tabpanel"
<option value="hive-agent.service">hive-agent.service</option> aria-labelledby="logs-tab-agent"
<option value="hive-mcp-http.service">hive-mcp-http.service</option> >
<option value="hive-bash-daemon.service">hive-bash-daemon.service</option> <div class="logs-toolbar">
<option value="hive-matrix-daemon.service">hive-matrix-daemon.service</option> <select id="agent-select" class="journal-unit"></select>
<option value="">(full machine journal)</option> <select id="agent-unit-select" class="journal-unit">
</select> <option value="hive-agent.service">hive-agent.service</option>
<button type="button" class="btn btn-restart" id="agent-refresh">↻ refresh</button> <option value="hive-mcp-http.service">hive-mcp-http.service</option>
<span id="agent-fetch-ts" class="meta logs-fetch-ts" hidden></span> <option value="hive-bash-daemon.service">
</div> hive-bash-daemon.service
<pre id="agent-output" class="journal-output">select an agent above</pre> </option>
</section> <option value="hive-matrix-daemon.service">
hive-matrix-daemon.service
</option>
<option value="">(full machine journal)</option>
</select>
<button type="button" class="btn btn-restart" id="agent-refresh">
↻ refresh
</button>
<span id="agent-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="agent-output" class="journal-output">
select an agent above</pre
>
</section>
<!-- INFRA: journald viewer for infrastructure containers (hive-ci, <!-- INFRA: journald viewer for infrastructure containers (hive-ci,
hive-forge, hive-gateway, hive-matrix). Always fetches the full hive-forge, hive-gateway, hive-matrix). Always fetches the full
machine journal — no unit filter (infra containers don't run the machine journal — no unit filter (infra containers don't run the
per-agent hive daemons). Backed by GET /api/journal/{name}?lines=N. per-agent hive daemons). Backed by GET /api/journal/{name}?lines=N.
Deep-link: ?agent=hive-ci routes here instead of the AGENT tab. --> Deep-link: ?agent=hive-ci routes here instead of the AGENT tab. -->
<section class="logs-pane" id="logs-pane-infra" data-tab-pane="infra" <section
role="tabpanel" aria-labelledby="logs-tab-infra"> class="logs-pane"
<div class="logs-toolbar"> id="logs-pane-infra"
<select id="infra-select" class="journal-unit"></select> data-tab-pane="infra"
<button type="button" class="btn btn-restart" id="infra-refresh">↻ refresh</button> role="tabpanel"
<span id="infra-fetch-ts" class="meta logs-fetch-ts" hidden></span> aria-labelledby="logs-tab-infra"
</div> >
<pre id="infra-output" class="journal-output">select a container above</pre> <div class="logs-toolbar">
</section> <select id="infra-select" class="journal-unit"></select>
<button type="button" class="btn btn-restart" id="infra-refresh">
↻ refresh
</button>
<span id="infra-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="infra-output" class="journal-output">
select a container above</pre
>
</section>
<!-- SYSTEM: host-side service logs. Shows the hive-c0re daemon <!-- SYSTEM: host-side service logs. Shows the hive-c0re daemon
journal via GET /api/journal-host?unit=hive-c0re.service. --> journal via GET /api/journal-host?unit=hive-c0re.service. -->
<section class="logs-pane" id="logs-pane-system" data-tab-pane="system" <section
role="tabpanel" aria-labelledby="logs-tab-system"> class="logs-pane"
<div class="logs-toolbar"> id="logs-pane-system"
<select id="system-unit-select" class="journal-unit"> data-tab-pane="system"
<option value="hive-c0re.service">hive-c0re.service</option> role="tabpanel"
<option value="hive-priv.service">hive-priv.service</option> aria-labelledby="logs-tab-system"
</select> >
<button type="button" class="btn btn-restart" id="system-refresh">↻ refresh</button> <div class="logs-toolbar">
<span id="system-fetch-ts" class="meta logs-fetch-ts" hidden></span> <select id="system-unit-select" class="journal-unit">
</div> <option value="hive-c0re.service">hive-c0re.service</option>
<pre id="system-output" class="journal-output">loading…</pre> <option value="hive-priv.service">hive-priv.service</option>
</section> </select>
<button type="button" class="btn btn-restart" id="system-refresh">
↻ refresh
</button>
<span id="system-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="system-output" class="journal-output">loading…</pre>
</section>
</main>
</main> <script type="module" src="/static/logs.js" defer></script>
</body>
<script type="module" src="/static/logs.js" defer></script>
</body>
</html> </html>

View file

@ -12,11 +12,9 @@
// Last-fetched timestamp is shown next to the refresh button on AGENT, // Last-fetched timestamp is shown next to the refresh button on AGENT,
// INFRA, and SYSTEM tabs so the operator knows how stale the output is. // INFRA, and SYSTEM tabs so the operator knows how stale the output is.
import { import { $, fmtAgeSecs, initServerWarnings } from "./common.js";
$, fmtAgeSecs, initServerWarnings, import { el } from "@hive/shared/dom.js";
} from './common.js'; import "@hive/shared/hive-tab-strip.js";
import { el } from '@hive/shared/dom.js';
import '@hive/shared/hive-tab-strip.js';
(() => { (() => {
initServerWarnings(); initServerWarnings();
@ -34,34 +32,43 @@ import '@hive/shared/hive-tab-strip.js';
// Format a last-fetched timestamp: "fetched just now" / "fetched 3m ago". // Format a last-fetched timestamp: "fetched just now" / "fetched 3m ago".
function fmtFetchTs(fetchedAt) { function fmtFetchTs(fetchedAt) {
const ageSecs = Math.floor((Date.now() - fetchedAt) / 1000); const ageSecs = Math.floor((Date.now() - fetchedAt) / 1000);
return 'fetched ' + (ageSecs < 5 ? 'just now' : fmtAgeSecs(ageSecs) + ' ago'); return (
"fetched " + (ageSecs < 5 ? "just now" : fmtAgeSecs(ageSecs) + " ago")
);
} }
// ─── AGENT tab ──────────────────────────────────────────────────────── // ─── AGENT tab ────────────────────────────────────────────────────────
const agentSelect = $('agent-select'); const agentSelect = $("agent-select");
const agentUnitSelect = $('agent-unit-select'); const agentUnitSelect = $("agent-unit-select");
const agentRefresh = $('agent-refresh'); const agentRefresh = $("agent-refresh");
const agentOutput = $('agent-output'); const agentOutput = $("agent-output");
const agentFetchTs = $('agent-fetch-ts'); const agentFetchTs = $("agent-fetch-ts");
let agentFetching = false; let agentFetching = false;
let agentLastFetch = 0; let agentLastFetch = 0;
async function fetchAgent() { async function fetchAgent() {
if (!agentSelect || !agentOutput) return; if (!agentSelect || !agentOutput) return;
const name = agentSelect.value; const name = agentSelect.value;
if (!name) { agentOutput.textContent = 'select an agent above'; return; } if (!name) {
agentOutput.textContent = "select an agent above";
return;
}
if (agentFetching) return; if (agentFetching) return;
agentFetching = true; agentFetching = true;
agentOutput.textContent = 'fetching…'; agentOutput.textContent = "fetching…";
if (agentFetchTs) agentFetchTs.hidden = true; if (agentFetchTs) agentFetchTs.hidden = true;
const unit = agentUnitSelect ? agentUnitSelect.value : ''; const unit = agentUnitSelect ? agentUnitSelect.value : "";
const params = new URLSearchParams({ lines: '500' }); const params = new URLSearchParams({ lines: "500" });
if (unit) params.set('unit', unit); if (unit) params.set("unit", unit);
try { try {
const resp = await fetch('/api/journal/' + encodeURIComponent(name) + '?' + params); const resp = await fetch(
"/api/journal/" + encodeURIComponent(name) + "?" + params,
);
const text = await resp.text(); const text = await resp.text();
agentOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text; agentOutput.textContent = resp.ok
? text || "(empty)"
: "error " + resp.status + "\n" + text;
agentOutput.scrollTop = agentOutput.scrollHeight; agentOutput.scrollTop = agentOutput.scrollHeight;
if (resp.ok) { if (resp.ok) {
agentLastFetch = Date.now(); agentLastFetch = Date.now();
@ -71,41 +78,48 @@ import '@hive/shared/hive-tab-strip.js';
} }
} }
} catch (err) { } catch (err) {
agentOutput.textContent = 'fetch failed: ' + err; agentOutput.textContent = "fetch failed: " + err;
} finally { } finally {
agentFetching = false; agentFetching = false;
} }
} }
if (agentSelect) agentSelect.addEventListener('change', fetchAgent); if (agentSelect) agentSelect.addEventListener("change", fetchAgent);
if (agentUnitSelect) agentUnitSelect.addEventListener('change', fetchAgent); if (agentUnitSelect) agentUnitSelect.addEventListener("change", fetchAgent);
if (agentRefresh) agentRefresh.addEventListener('click', fetchAgent); if (agentRefresh) agentRefresh.addEventListener("click", fetchAgent);
// ─── INFRA tab ──────────────────────────────────────────────────────── // ─── INFRA tab ────────────────────────────────────────────────────────
// Infra containers (hive-ci, hive-forge, hive-gateway, hive-matrix) don't // Infra containers (hive-ci, hive-forge, hive-gateway, hive-matrix) don't
// run the per-agent hive daemons, so the unit filter is inapplicable. We // run the per-agent hive daemons, so the unit filter is inapplicable. We
// always fetch the full machine journal for them. // always fetch the full machine journal for them.
const infraSelect = $('infra-select'); const infraSelect = $("infra-select");
const infraRefresh = $('infra-refresh'); const infraRefresh = $("infra-refresh");
const infraOutput = $('infra-output'); const infraOutput = $("infra-output");
const infraFetchTs = $('infra-fetch-ts'); const infraFetchTs = $("infra-fetch-ts");
let infraFetching = false; let infraFetching = false;
let infraLastFetch = 0; let infraLastFetch = 0;
async function fetchInfra() { async function fetchInfra() {
if (!infraSelect || !infraOutput) return; if (!infraSelect || !infraOutput) return;
const name = infraSelect.value; const name = infraSelect.value;
if (!name) { infraOutput.textContent = 'select a container above'; return; } if (!name) {
infraOutput.textContent = "select a container above";
return;
}
if (infraFetching) return; if (infraFetching) return;
infraFetching = true; infraFetching = true;
infraOutput.textContent = 'fetching…'; infraOutput.textContent = "fetching…";
if (infraFetchTs) infraFetchTs.hidden = true; if (infraFetchTs) infraFetchTs.hidden = true;
const params = new URLSearchParams({ lines: '500' }); const params = new URLSearchParams({ lines: "500" });
try { try {
const resp = await fetch('/api/journal/' + encodeURIComponent(name) + '?' + params); const resp = await fetch(
"/api/journal/" + encodeURIComponent(name) + "?" + params,
);
const text = await resp.text(); const text = await resp.text();
infraOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text; infraOutput.textContent = resp.ok
? text || "(empty)"
: "error " + resp.status + "\n" + text;
infraOutput.scrollTop = infraOutput.scrollHeight; infraOutput.scrollTop = infraOutput.scrollHeight;
if (resp.ok) { if (resp.ok) {
infraLastFetch = Date.now(); infraLastFetch = Date.now();
@ -115,20 +129,20 @@ import '@hive/shared/hive-tab-strip.js';
} }
} }
} catch (err) { } catch (err) {
infraOutput.textContent = 'fetch failed: ' + err; infraOutput.textContent = "fetch failed: " + err;
} finally { } finally {
infraFetching = false; infraFetching = false;
} }
} }
if (infraSelect) infraSelect.addEventListener('change', fetchInfra); if (infraSelect) infraSelect.addEventListener("change", fetchInfra);
if (infraRefresh) infraRefresh.addEventListener('click', fetchInfra); if (infraRefresh) infraRefresh.addEventListener("click", fetchInfra);
// Fixed allowlist — the four hive infra services never change at // Fixed allowlist — the four hive infra services never change at
// runtime, and there's no dashboard API exposing just the name list // runtime, and there's no dashboard API exposing just the name list
// (the one that used to, `/api/state`'s `infra_containers` field, was // (the one that used to, `/api/state`'s `infra_containers` field, was
// start/stop-panel-only and is gone). Mirrors `hive_priv_sock::InfraContainer::ALL`. // start/stop-panel-only and is gone). Mirrors `hive_priv_sock::InfraContainer::ALL`.
const INFRA_NAMES = ['hive-ci', 'hive-forge', 'hive-gateway', 'hive-matrix']; const INFRA_NAMES = ["hive-ci", "hive-forge", "hive-gateway", "hive-matrix"];
// ─── container list init ────────────────────────────────────────────── // ─── container list init ──────────────────────────────────────────────
// Fetch /api/state once to populate the AGENT selector (agents only); // Fetch /api/state once to populate the AGENT selector (agents only);
@ -138,59 +152,64 @@ import '@hive/shared/hive-tab-strip.js';
async function loadContainerLists() { async function loadContainerLists() {
if (infraSelect) { if (infraSelect) {
infraSelect.replaceChildren(); infraSelect.replaceChildren();
infraSelect.append(el('option', { value: '' }, '— select container —')); infraSelect.append(el("option", { value: "" }, "— select container —"));
for (const name of INFRA_NAMES) { for (const name of INFRA_NAMES) {
infraSelect.append(el('option', { value: name }, name)); infraSelect.append(el("option", { value: name }, name));
} }
} }
try { try {
const resp = await fetch('/api/state'); const resp = await fetch("/api/state");
if (!resp.ok) return; if (!resp.ok) return;
const state = await resp.json(); const state = await resp.json();
// Populate AGENT selector (agents only — no infra optgroup). // Populate AGENT selector (agents only — no infra optgroup).
if (agentSelect) { if (agentSelect) {
agentSelect.replaceChildren(); agentSelect.replaceChildren();
agentSelect.append(el('option', { value: '' }, '— select agent —')); agentSelect.append(el("option", { value: "" }, "— select agent —"));
for (const c of (state.containers || [])) { for (const c of state.containers || []) {
agentSelect.append(el('option', { value: c.name }, c.name)); agentSelect.append(el("option", { value: c.name }, c.name));
} }
} }
// Deep-link: honour ?agent= and ?unit= URL params. // Deep-link: honour ?agent= and ?unit= URL params.
const urlAgent = new URLSearchParams(location.search).get('agent'); const urlAgent = new URLSearchParams(location.search).get("agent");
const urlUnit = new URLSearchParams(location.search).get('unit'); const urlUnit = new URLSearchParams(location.search).get("unit");
if (urlAgent) { if (urlAgent) {
if (INFRA_NAMES.includes(urlAgent)) { if (INFRA_NAMES.includes(urlAgent)) {
// Route to INFRA tab. // Route to INFRA tab.
logTabs.show('infra'); logTabs.show("infra");
if (infraSelect) { if (infraSelect) {
infraSelect.value = urlAgent; infraSelect.value = urlAgent;
fetchInfra(); fetchInfra();
} }
} else if (agentSelect) { } else if (agentSelect) {
// Route to AGENT tab. // Route to AGENT tab.
const found = Array.from(agentSelect.options).some((o) => o.value === urlAgent); const found = Array.from(agentSelect.options).some(
(o) => o.value === urlAgent,
);
if (found) { if (found) {
agentSelect.value = urlAgent; agentSelect.value = urlAgent;
if (urlUnit && agentUnitSelect) { if (urlUnit && agentUnitSelect) {
const unitFound = Array.from(agentUnitSelect.options) const unitFound = Array.from(agentUnitSelect.options).some(
.some((o) => o.value === urlUnit); (o) => o.value === urlUnit,
);
if (unitFound) agentUnitSelect.value = urlUnit; if (unitFound) agentUnitSelect.value = urlUnit;
} }
fetchAgent(); fetchAgent();
} }
} }
} }
} catch { /**/ } } catch {
/**/
}
} }
// ─── SYSTEM tab ─────────────────────────────────────────────────────── // ─── SYSTEM tab ───────────────────────────────────────────────────────
const systemUnitSelect = $('system-unit-select'); const systemUnitSelect = $("system-unit-select");
const systemRefresh = $('system-refresh'); const systemRefresh = $("system-refresh");
const systemOutput = $('system-output'); const systemOutput = $("system-output");
const systemFetchTs = $('system-fetch-ts'); const systemFetchTs = $("system-fetch-ts");
let systemFetching = false; let systemFetching = false;
let systemLastFetch = 0; let systemLastFetch = 0;
@ -198,15 +217,19 @@ import '@hive/shared/hive-tab-strip.js';
if (!systemOutput) return; if (!systemOutput) return;
if (systemFetching) return; if (systemFetching) return;
systemFetching = true; systemFetching = true;
systemOutput.textContent = 'fetching…'; systemOutput.textContent = "fetching…";
if (systemFetchTs) systemFetchTs.hidden = true; if (systemFetchTs) systemFetchTs.hidden = true;
const unit = systemUnitSelect ? systemUnitSelect.value : 'hive-c0re.service'; const unit = systemUnitSelect
const params = new URLSearchParams({ lines: '500' }); ? systemUnitSelect.value
if (unit) params.set('unit', unit); : "hive-c0re.service";
const params = new URLSearchParams({ lines: "500" });
if (unit) params.set("unit", unit);
try { try {
const resp = await fetch('/api/journal-host?' + params); const resp = await fetch("/api/journal-host?" + params);
const text = await resp.text(); const text = await resp.text();
systemOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text; systemOutput.textContent = resp.ok
? text || "(empty)"
: "error " + resp.status + "\n" + text;
systemOutput.scrollTop = systemOutput.scrollHeight; systemOutput.scrollTop = systemOutput.scrollHeight;
if (resp.ok) { if (resp.ok) {
systemLastFetch = Date.now(); systemLastFetch = Date.now();
@ -216,14 +239,15 @@ import '@hive/shared/hive-tab-strip.js';
} }
} }
} catch (err) { } catch (err) {
systemOutput.textContent = 'fetch failed: ' + err; systemOutput.textContent = "fetch failed: " + err;
} finally { } finally {
systemFetching = false; systemFetching = false;
} }
} }
if (systemUnitSelect) systemUnitSelect.addEventListener('change', fetchSystem); if (systemUnitSelect)
if (systemRefresh) systemRefresh.addEventListener('click', fetchSystem); systemUnitSelect.addEventListener("change", fetchSystem);
if (systemRefresh) systemRefresh.addEventListener("click", fetchSystem);
// ─── init ───────────────────────────────────────────────────────────── // ─── init ─────────────────────────────────────────────────────────────
@ -231,15 +255,15 @@ import '@hive/shared/hive-tab-strip.js';
// needs are defined. Its initial show() paints the active pane and, if // needs are defined. Its initial show() paints the active pane and, if
// the deep-linked tab is SYSTEM, kicks off the lazy fetch via onShow. // the deep-linked tab is SYSTEM, kicks off the lazy fetch via onShow.
// Default: AGENT. // Default: AGENT.
logTabs = document.getElementById('logs-tabbar').configure({ logTabs = document.getElementById("logs-tabbar").configure({
tabs: [ tabs: [
{ id: 'agent', label: 'AGENT' }, { id: "agent", label: "AGENT" },
{ id: 'infra', label: 'INFRA' }, { id: "infra", label: "INFRA" },
{ id: 'system', label: 'SYSTEM' }, { id: "system", label: "SYSTEM" },
], ],
defaultId: 'agent', defaultId: "agent",
onShow: (id) => { onShow: (id) => {
if (id === 'system') fetchSystem(); if (id === "system") fetchSystem();
}, },
}); });
loadContainerLists(); loadContainerLists();
@ -257,5 +281,4 @@ import '@hive/shared/hive-tab-strip.js';
systemFetchTs.textContent = fmtFetchTs(systemLastFetch); systemFetchTs.textContent = fmtFetchTs(systemLastFetch);
} }
}, 30_000); }, 30_000);
})(); })();

View file

@ -21,10 +21,10 @@
// coalesces caps+groups per agent into ONE queue entry (one rebuild, no // coalesces caps+groups per agent into ONE queue entry (one rebuild, no
// double-rebuild). Batch is atomic — saved→rebuilding only fires on a clean 200. // double-rebuild). Batch is atomic — saved→rebuilding only fires on a clean 200.
import { $ } from './common.js'; import { $ } from "./common.js";
import { el } from '@hive/shared/dom.js'; import { el } from "@hive/shared/dom.js";
import { containersState } from './state.js'; import { containersState } from "./state.js";
import { asyncBtn } from '@hive/shared/forms.js'; import { asyncBtn } from "@hive/shared/forms.js";
// ── SSE re-render guards ──────────────────────────────────────────── // ── SSE re-render guards ────────────────────────────────────────────
// Skip the live re-render when the operator has unsaved edits in that // Skip the live re-render when the operator has unsaved edits in that
@ -33,14 +33,14 @@ import { asyncBtn } from '@hive/shared/forms.js';
// and the post-save re-fetch are the recovery paths; both clear dirty. // and the post-save re-fetch are the recovery paths; both clear dirty.
export function applyCapabilitiesChanged(ev) { export function applyCapabilitiesChanged(ev) {
const root = $('capabilities-section'); const root = $("capabilities-section");
if (!root) return; if (!root) return;
if (root.contains(document.activeElement)) return; if (root.contains(document.activeElement)) return;
if (sectionHasDirty(root)) return; if (sectionHasDirty(root)) return;
renderCapabilities(root, ev); renderCapabilities(root, ev);
} }
export function applyToolGroupsChanged(ev) { export function applyToolGroupsChanged(ev) {
const root = $('tool-groups-section'); const root = $("tool-groups-section");
if (!root) return; if (!root) return;
if (root.contains(document.activeElement)) return; if (root.contains(document.activeElement)) return;
if (sectionHasDirty(root)) return; if (sectionHasDirty(root)) return;
@ -50,25 +50,25 @@ export function applyToolGroupsChanged(ev) {
// A section is dirty if any checkbox diverges from its render-time // A section is dirty if any checkbox diverges from its render-time
// baseline. Cheap DOM scan; no module-level mirror to drift. // baseline. Cheap DOM scan; no module-level mirror to drift.
function sectionHasDirty(root) { function sectionHasDirty(root) {
for (const cb of root.querySelectorAll('input[type=checkbox]')) { for (const cb of root.querySelectorAll("input[type=checkbox]")) {
if (cb.checked !== (cb.dataset.baseline === '1')) return true; if (cb.checked !== (cb.dataset.baseline === "1")) return true;
} }
return false; return false;
} }
export async function fetchAndRenderCapabilities() { export async function fetchAndRenderCapabilities() {
const root = $('capabilities-section'); const root = $("capabilities-section");
if (!root) return; if (!root) return;
root.replaceChildren(); root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'loading…')); root.append(el("p", { class: "meta" }, "loading…"));
try { try {
const resp = await fetch('/api/capabilities'); const resp = await fetch("/api/capabilities");
if (!resp.ok) throw new Error('http ' + resp.status); if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json(); const data = await resp.json();
renderCapabilities(root, data); renderCapabilities(root, data);
} catch (err) { } catch (err) {
root.replaceChildren(); root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err)); root.append(el("p", { class: "meta" }, "fetch failed: " + err));
} }
} }
@ -76,7 +76,7 @@ function renderCapabilities(root, data) {
root.replaceChildren(); root.replaceChildren();
const { caps, descriptions = {}, assignments, effective = {} } = data; const { caps, descriptions = {}, assignments, effective = {} } = data;
if (!caps || !caps.length) { if (!caps || !caps.length) {
root.append(el('p', { class: 'meta' }, '(no capabilities defined)')); root.append(el("p", { class: "meta" }, "(no capabilities defined)"));
updateSaveBar(); updateSaveBar();
return; return;
} }
@ -84,33 +84,36 @@ function renderCapabilities(root, data) {
// Agent rows: prefer the backend roster (every manageable agent, // Agent rows: prefer the backend roster (every manageable agent,
// default-perms included). Fall back to the live-container explicit // default-perms included). Fall back to the live-container explicit
// union for older payloads that don't carry `agents`. // union for older payloads that don't carry `agents`.
const agentNames = (data.agents && data.agents.length) const agentNames =
? [...data.agents] data.agents && data.agents.length
: [...new Set([ ? [...data.agents]
...Array.from(containersState.keys()), : [
...Object.keys(assignments), ...new Set([
])].sort(); ...Array.from(containersState.keys()),
...Object.keys(assignments),
]),
].sort();
if (!agentNames.length) { if (!agentNames.length) {
root.append(el('p', { class: 'meta' }, '(no agents)')); root.append(el("p", { class: "meta" }, "(no agents)"));
updateSaveBar(); updateSaveBar();
return; return;
} }
const wrap = el('div', { class: 'cap-table-wrap' }); const wrap = el("div", { class: "cap-table-wrap" });
const table = el('table', { class: 'cap-table' }); const table = el("table", { class: "cap-table" });
// Header row. // Header row.
const thead = el('thead'); const thead = el("thead");
const hrow = el('tr'); const hrow = el("tr");
hrow.append(el('th', { class: 'cap-agent-col' }, 'agent')); hrow.append(el("th", { class: "cap-agent-col" }, "agent"));
for (const c of caps) { for (const c of caps) {
hrow.append(el('th', { class: 'cap-col', title: descriptions[c] || c }, c)); hrow.append(el("th", { class: "cap-col", title: descriptions[c] || c }, c));
} }
thead.append(hrow); thead.append(hrow);
table.append(thead); table.append(thead);
const tbody = el('tbody'); const tbody = el("tbody");
for (const name of agentNames) { for (const name of agentNames) {
// Effective caps (explicit-or-default) drive the checkboxes so // Effective caps (explicit-or-default) drive the checkboxes so
// default-perms agents show their real grants, not blank. // default-perms agents show their real grants, not blank.
@ -119,19 +122,26 @@ function renderCapabilities(root, data) {
// includes stopped-but-configured containers — so a temporarily-stopped // includes stopped-but-configured containers — so a temporarily-stopped
// agent is NOT stale. Only destroyed/renamed agents are absent here. // agent is NOT stale. Only destroyed/renamed agents are absent here.
const isStale = !containersState.has(name); const isStale = !containersState.has(name);
const tr = el('tr', { class: 'cap-row' + (isStale ? ' perm-row-stale' : ''), 'data-agent': name }); const tr = el("tr", {
class: "cap-row" + (isStale ? " perm-row-stale" : ""),
"data-agent": name,
});
// Agent name cell. // Agent name cell.
const nameTd = el('td', { class: 'cap-agent-col' }); const nameTd = el("td", { class: "cap-agent-col" });
nameTd.append(el('span', { class: 'cap-agent-name' }, name)); nameTd.append(el("span", { class: "cap-agent-name" }, name));
if (isStale) { if (isStale) {
nameTd.append(el('span', { class: 'perm-stale-label' }, '(not running)')); nameTd.append(el("span", { class: "perm-stale-label" }, "(not running)"));
const removeBtn = el('button', { const removeBtn = el(
type: 'button', "button",
class: 'perm-remove-btn', {
title: 'remove stale permission entries for ' + name, type: "button",
}, '✕ remove'); class: "perm-remove-btn",
removeBtn.addEventListener('click', () => clearStaleAgent(name, root)); title: "remove stale permission entries for " + name,
},
"✕ remove",
);
removeBtn.addEventListener("click", () => clearStaleAgent(name, root));
nameTd.append(removeBtn); nameTd.append(removeBtn);
} }
tr.append(nameTd); tr.append(nameTd);
@ -139,16 +149,16 @@ function renderCapabilities(root, data) {
// One checkbox per capability. // One checkbox per capability.
for (const c of caps) { for (const c of caps) {
const checked = assigned.includes(c); const checked = assigned.includes(c);
const td = el('td', { class: 'cap-col' }); const td = el("td", { class: "cap-col" });
const cb = el('input', { const cb = el("input", {
type: 'checkbox', type: "checkbox",
class: 'cap-cb', class: "cap-cb",
'data-cap': c, "data-cap": c,
'data-baseline': checked ? '1' : '0', "data-baseline": checked ? "1" : "0",
'aria-label': c, "aria-label": c,
}); });
cb.checked = checked; cb.checked = checked;
cb.addEventListener('change', onCellToggle); cb.addEventListener("change", onCellToggle);
td.append(cb); td.append(cb);
tr.append(td); tr.append(td);
} }
@ -162,18 +172,18 @@ function renderCapabilities(root, data) {
} }
export async function fetchAndRenderToolGroups() { export async function fetchAndRenderToolGroups() {
const root = $('tool-groups-section'); const root = $("tool-groups-section");
if (!root) return; if (!root) return;
root.replaceChildren(); root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'loading…')); root.append(el("p", { class: "meta" }, "loading…"));
try { try {
const resp = await fetch('/api/tool-groups'); const resp = await fetch("/api/tool-groups");
if (!resp.ok) throw new Error('http ' + resp.status); if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json(); const data = await resp.json();
renderToolGroups(root, data); renderToolGroups(root, data);
} catch (err) { } catch (err) {
root.replaceChildren(); root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err)); root.append(el("p", { class: "meta" }, "fetch failed: " + err));
} }
} }
@ -181,40 +191,45 @@ function renderToolGroups(root, data) {
root.replaceChildren(); root.replaceChildren();
const { groups, descriptions = {}, assignments, effective = {} } = data; const { groups, descriptions = {}, assignments, effective = {} } = data;
if (!groups || !groups.length) { if (!groups || !groups.length) {
root.append(el('p', { class: 'meta' }, '(no tool groups defined)')); root.append(el("p", { class: "meta" }, "(no tool groups defined)"));
updateSaveBar(); updateSaveBar();
return; return;
} }
// Agent rows: prefer the backend roster (default-perms agents included); // Agent rows: prefer the backend roster (default-perms agents included);
// fall back to the live-container explicit union for older payloads. // fall back to the live-container explicit union for older payloads.
const agentNames = (data.agents && data.agents.length) const agentNames =
? [...data.agents] data.agents && data.agents.length
: [...new Set([ ? [...data.agents]
...Array.from(containersState.keys()), : [
...Object.keys(assignments), ...new Set([
])].sort(); ...Array.from(containersState.keys()),
...Object.keys(assignments),
]),
].sort();
if (!agentNames.length) { if (!agentNames.length) {
root.append(el('p', { class: 'meta' }, '(no agents)')); root.append(el("p", { class: "meta" }, "(no agents)"));
updateSaveBar(); updateSaveBar();
return; return;
} }
const wrap = el('div', { class: 'tg-table-wrap' }); const wrap = el("div", { class: "tg-table-wrap" });
const table = el('table', { class: 'tg-table' }); const table = el("table", { class: "tg-table" });
// Header row. // Header row.
const thead = el('thead'); const thead = el("thead");
const hrow = el('tr'); const hrow = el("tr");
hrow.append(el('th', { class: 'tg-agent-col' }, 'agent')); hrow.append(el("th", { class: "tg-agent-col" }, "agent"));
for (const g of groups) { for (const g of groups) {
hrow.append(el('th', { class: 'tg-group-col', title: descriptions[g] || g }, g)); hrow.append(
el("th", { class: "tg-group-col", title: descriptions[g] || g }, g),
);
} }
thead.append(hrow); thead.append(hrow);
table.append(thead); table.append(thead);
const tbody = el('tbody'); const tbody = el("tbody");
for (const name of agentNames) { for (const name of agentNames) {
// Effective groups (explicit-or-role-default) drive the checkboxes so // Effective groups (explicit-or-role-default) drive the checkboxes so
// a default agent shows its real groups, not blank — and saving keeps // a default agent shows its real groups, not blank — and saving keeps
@ -226,38 +241,47 @@ function renderToolGroups(root, data) {
// includes stopped-but-configured containers — only destroyed/renamed // includes stopped-but-configured containers — only destroyed/renamed
// agents are absent. // agents are absent.
const isStale = !containersState.has(name); const isStale = !containersState.has(name);
const tr = el('tr', { class: 'tg-row' + (isStale ? ' perm-row-stale' : ''), 'data-agent': name }); const tr = el("tr", {
class: "tg-row" + (isStale ? " perm-row-stale" : ""),
"data-agent": name,
});
// Agent name cell. // Agent name cell.
const nameTd = el('td', { class: 'tg-agent-col' }); const nameTd = el("td", { class: "tg-agent-col" });
nameTd.append(el('span', { class: 'tg-agent-name' }, name)); nameTd.append(el("span", { class: "tg-agent-name" }, name));
if (isStale) { if (isStale) {
nameTd.append(el('span', { class: 'perm-stale-label' }, '(not running)')); nameTd.append(el("span", { class: "perm-stale-label" }, "(not running)"));
const removeBtn = el('button', { const removeBtn = el(
type: 'button', "button",
class: 'perm-remove-btn', {
title: 'remove stale permission entries for ' + name, type: "button",
}, '✕ remove'); class: "perm-remove-btn",
removeBtn.addEventListener('click', () => clearStaleAgent(name, root)); title: "remove stale permission entries for " + name,
},
"✕ remove",
);
removeBtn.addEventListener("click", () => clearStaleAgent(name, root));
nameTd.append(removeBtn); nameTd.append(removeBtn);
} else if (!hasExplicit) { } else if (!hasExplicit) {
nameTd.append(el('span', { class: 'meta tg-default-label' }, '(default)')); nameTd.append(
el("span", { class: "meta tg-default-label" }, "(default)"),
);
} }
tr.append(nameTd); tr.append(nameTd);
// One checkbox per group. // One checkbox per group.
for (const g of groups) { for (const g of groups) {
const checked = assigned.includes(g); const checked = assigned.includes(g);
const td = el('td', { class: 'tg-group-col' }); const td = el("td", { class: "tg-group-col" });
const cb = el('input', { const cb = el("input", {
type: 'checkbox', type: "checkbox",
class: 'tg-cb', class: "tg-cb",
'data-group': g, "data-group": g,
'data-baseline': checked ? '1' : '0', "data-baseline": checked ? "1" : "0",
'aria-label': g, "aria-label": g,
}); });
cb.checked = checked; cb.checked = checked;
cb.addEventListener('change', onCellToggle); cb.addEventListener("change", onCellToggle);
td.append(cb); td.append(cb);
tr.append(td); tr.append(td);
} }
@ -285,8 +309,22 @@ function onCellToggle() {
// a full replacement → omitting it leaves that file alone). // a full replacement → omitting it leaves that file alone).
function collectChanges() { function collectChanges() {
const byAgent = new Map(); // agent -> { capabilities?, tool_groups? } const byAgent = new Map(); // agent -> { capabilities?, tool_groups? }
collectSection($('capabilities-section'), '.cap-row', '.cap-cb', 'cap', 'capabilities', byAgent); collectSection(
collectSection($('tool-groups-section'), '.tg-row', '.tg-cb', 'group', 'tool_groups', byAgent); $("capabilities-section"),
".cap-row",
".cap-cb",
"cap",
"capabilities",
byAgent,
);
collectSection(
$("tool-groups-section"),
".tg-row",
".tg-cb",
"group",
"tool_groups",
byAgent,
);
const changes = []; const changes = [];
for (const [agent, obj] of byAgent) changes.push({ agent, ...obj }); for (const [agent, obj] of byAgent) changes.push({ agent, ...obj });
return changes; return changes;
@ -300,7 +338,7 @@ function collectSection(root, rowSel, cbSel, dataKey, field, byAgent) {
let dirty = false; let dirty = false;
const selected = []; const selected = [];
for (const cb of tr.querySelectorAll(cbSel)) { for (const cb of tr.querySelectorAll(cbSel)) {
if (cb.checked !== (cb.dataset.baseline === '1')) dirty = true; if (cb.checked !== (cb.dataset.baseline === "1")) dirty = true;
if (cb.checked) selected.push(cb.dataset[dataKey]); if (cb.checked) selected.push(cb.dataset[dataKey]);
} }
if (dirty) { if (dirty) {
@ -312,13 +350,14 @@ function collectSection(root, rowSel, cbSel, dataKey, field, byAgent) {
} }
function updateSaveBar() { function updateSaveBar() {
const btn = $('perm-save-all'); const btn = $("perm-save-all");
if (!btn) return; if (!btn) return;
// Don't stomp a transient saving/rebuilding label. // Don't stomp a transient saving/rebuilding label.
if (btn.dataset.busy === '1') return; if (btn.dataset.busy === "1") return;
const n = collectChanges().length; const n = collectChanges().length;
btn.disabled = n === 0; btn.disabled = n === 0;
btn.textContent = n === 0 ? 'save all' : `save all (${n} agent${n === 1 ? '' : 's'})`; btn.textContent =
n === 0 ? "save all" : `save all (${n} agent${n === 1 ? "" : "s"})`;
} }
// Remove all explicit permission entries for a stale (non-running) // Remove all explicit permission entries for a stale (non-running)
@ -328,20 +367,27 @@ function updateSaveBar() {
// the delete so the row disappears immediately. // the delete so the row disappears immediately.
async function clearStaleAgent(name, sectionRoot) { async function clearStaleAgent(name, sectionRoot) {
const btn = sectionRoot const btn = sectionRoot
? sectionRoot.querySelector(`[data-agent="${CSS.escape(name)}"] .perm-remove-btn`) ? sectionRoot.querySelector(
`[data-agent="${CSS.escape(name)}"] .perm-remove-btn`,
)
: null; : null;
const doDelete = async () => { const doDelete = async () => {
try { try {
const resp = await fetch('/api/permissions/' + encodeURIComponent(name), { method: 'DELETE' }); const resp = await fetch("/api/permissions/" + encodeURIComponent(name), {
method: "DELETE",
});
if (!resp.ok) { if (!resp.ok) {
const text = await resp.text().catch(() => resp.status); const text = await resp.text().catch(() => resp.status);
setSaveNote('failed to remove ' + name + ': ' + text, true); setSaveNote("failed to remove " + name + ": " + text, true);
return; return;
} }
// Re-fetch both sections so the stale row disappears. // Re-fetch both sections so the stale row disappears.
await Promise.all([fetchAndRenderCapabilities(), fetchAndRenderToolGroups()]); await Promise.all([
fetchAndRenderCapabilities(),
fetchAndRenderToolGroups(),
]);
} catch (err) { } catch (err) {
setSaveNote('failed to remove ' + name + ': ' + err, true); setSaveNote("failed to remove " + name + ": " + err, true);
} }
}; };
// asyncBtn guards double-submit; fall through without guard when there // asyncBtn guards double-submit; fall through without guard when there
@ -352,38 +398,41 @@ async function clearStaleAgent(name, sectionRoot) {
} }
function clearSaveStatus() { function clearSaveStatus() {
const note = $('perm-save-note'); const note = $("perm-save-note");
if (note) { note.textContent = ''; note.classList.remove('perm-save-err'); } if (note) {
note.textContent = "";
note.classList.remove("perm-save-err");
}
} }
function setSaveNote(text, isErr) { function setSaveNote(text, isErr) {
const note = $('perm-save-note'); const note = $("perm-save-note");
if (!note) return; if (!note) return;
note.textContent = text; note.textContent = text;
note.classList.toggle('perm-save-err', !!isErr); note.classList.toggle("perm-save-err", !!isErr);
} }
async function saveAll() { async function saveAll() {
const btn = $('perm-save-all'); const btn = $("perm-save-all");
if (!btn) return; if (!btn) return;
const changes = collectChanges(); const changes = collectChanges();
if (!changes.length) return; if (!changes.length) return;
btn.dataset.busy = '1'; btn.dataset.busy = "1";
btn.disabled = true; btn.disabled = true;
btn.textContent = 'saving…'; btn.textContent = "saving…";
clearSaveStatus(); clearSaveStatus();
try { try {
const r = await fetch('/api/permissions', { const r = await fetch("/api/permissions", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes }), body: JSON.stringify({ changes }),
}); });
if (!r.ok) { if (!r.ok) {
const txt = await r.text(); const txt = await r.text();
btn.dataset.busy = ''; btn.dataset.busy = "";
btn.disabled = false; btn.disabled = false;
btn.textContent = 'save all'; btn.textContent = "save all";
setSaveNote('save failed: ' + (txt || ('http ' + r.status)), true); setSaveNote("save failed: " + (txt || "http " + r.status), true);
updateSaveBar(); updateSaveBar();
return; return;
} }
@ -391,18 +440,18 @@ async function saveAll() {
// re-fetch both tables once the queue worker has committed — that // re-fetch both tables once the queue worker has committed — that
// resets baselines (dirty clears) and the bar disables itself. // resets baselines (dirty clears) and the bar disables itself.
const n = changes.length; const n = changes.length;
btn.textContent = 'queued ✓'; btn.textContent = "queued ✓";
setSaveNote(`rebuilding ${n} agent${n === 1 ? '' : 's'}`, false); setSaveNote(`rebuilding ${n} agent${n === 1 ? "" : "s"}`, false);
setTimeout(() => { setTimeout(() => {
btn.dataset.busy = ''; btn.dataset.busy = "";
fetchAndRenderCapabilities(); fetchAndRenderCapabilities();
fetchAndRenderToolGroups(); fetchAndRenderToolGroups();
}, 900); }, 900);
} catch (err) { } catch (err) {
btn.dataset.busy = ''; btn.dataset.busy = "";
btn.disabled = false; btn.disabled = false;
btn.textContent = 'save all'; btn.textContent = "save all";
setSaveNote('save failed: ' + String(err), true); setSaveNote("save failed: " + String(err), true);
updateSaveBar(); updateSaveBar();
} }
} }
@ -411,9 +460,9 @@ async function saveAll() {
// after the DOM is ready (the button lives in the static permissions // after the DOM is ready (the button lives in the static permissions
// pane markup, so it exists before any fetch). // pane markup, so it exists before any fetch).
export function initPermissions() { export function initPermissions() {
const btn = $('perm-save-all'); const btn = $("perm-save-all");
if (btn && !btn.dataset.bound) { if (btn && !btn.dataset.bound) {
btn.dataset.bound = '1'; btn.dataset.bound = "1";
btn.addEventListener('click', saveAll); btn.addEventListener("click", saveAll);
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1,55 +1,63 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // ST4TS</title> <title>hyperhive // ST4TS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css"> <link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css"> <link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/stats.css"> <link rel="stylesheet" href="/static/stats.css" />
</head> </head>
<body class="stats-shell"> <body class="stats-shell">
<!-- Minimal chrome: back link + title. Same pattern as flow.html /
<!-- Minimal chrome: back link + title. Same pattern as flow.html /
logs.html — no full dashboard tabbar. Back link points to the logs.html — no full dashboard tabbar. Back link points to the
H0M3 hub (served at /), not the dashboard. --> H0M3 hub (served at /), not the dashboard. -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← home</a>
<span class="page-title">ST4TS</span> <span class="page-title">ST4TS</span>
<!-- Window selector lives in the header so it behaves like a tab strip <!-- Window selector lives in the header so it behaves like a tab strip
(matches the pattern on /logs.html). Hash-routed via createTabStrip. --> (matches the pattern on /logs.html). Hash-routed via createTabStrip. -->
<nav class="hive-stats-windows" id="hive-stats-windows" role="tablist"> <nav class="hive-stats-windows" id="hive-stats-windows" role="tablist">
<button type="button" class="btn" data-tab="1h">1h</button> <button type="button" class="btn" data-tab="1h">1h</button>
<button type="button" class="btn" data-tab="4h">4h</button> <button type="button" class="btn" data-tab="4h">4h</button>
<button type="button" class="btn" data-tab="24h">24h</button> <button type="button" class="btn" data-tab="24h">24h</button>
<button type="button" class="btn" data-tab="3d">3d</button> <button type="button" class="btn" data-tab="3d">3d</button>
<button type="button" class="btn" data-tab="7d">7d</button> <button type="button" class="btn" data-tab="7d">7d</button>
<button type="button" class="btn" data-tab="30d">30d</button> <button type="button" class="btn" data-tab="30d">30d</button>
<button type="button" class="btn" data-tab="all">all</button> <button type="button" class="btn" data-tab="all">all</button>
</nav> </nav>
</header> </header>
<main class="stats-main"> <main class="stats-main">
<p class="meta">hive-wide turn statistics, aggregated across every agent over the selected window. <strong>cost is a rough estimate</strong> from approximate per-model list prices — it drifts and is a ballpark, not a bill.</p> <p class="meta">
hive-wide turn statistics, aggregated across every agent over the
selected window. <strong>cost is a rough estimate</strong> from
approximate per-model list prices — it drifts and is a ballpark, not a
bill.
</p>
<div class="hive-stats-chips" id="hive-stats-summary"></div> <div class="hive-stats-chips" id="hive-stats-summary"></div>
<h3>◇ busiest agents</h3> <h3>◇ busiest agents</h3>
<div id="hive-stats-agents"><p class="meta">loading…</p></div> <div id="hive-stats-agents"><p class="meta">loading…</p></div>
<h3>◇ model mix (turns across the swarm)</h3> <h3>◇ model mix (turns across the swarm)</h3>
<div id="hive-stats-models"></div> <div id="hive-stats-models"></div>
<!-- "favorite tools": most-run bash commands across the swarm. <!-- "favorite tools": most-run bash commands across the swarm.
Header + list hidden until the hive-bash-daemon capture has Header + list hidden until the hive-bash-daemon capture has
recorded data, so the section never shows an empty block. --> recorded data, so the section never shows an empty block. -->
<h3 id="hive-stats-bash-h" hidden>◇ favorite tools (bash commands across the swarm)</h3> <h3 id="hive-stats-bash-h" hidden>
<div id="hive-stats-bash" hidden></div> ◇ favorite tools (bash commands across the swarm)
<!-- most-triggered skills across the swarm. Header + list hidden until </h3>
<div id="hive-stats-bash" hidden></div>
<!-- most-triggered skills across the swarm. Header + list hidden until
at least one agent has actually invoked a skill. --> at least one agent has actually invoked a skill. -->
<h3 id="hive-stats-skills-h" hidden>◇ skill mix (invocations across the swarm)</h3> <h3 id="hive-stats-skills-h" hidden>
<div id="hive-stats-skills" hidden></div> ◇ skill mix (invocations across the swarm)
</main> </h3>
<div id="hive-stats-skills" hidden></div>
</main>
<script type="module" src="/static/stats.js" defer></script> <script type="module" src="/static/stats.js" defer></script>
</body> </body>
</html> </html>

Some files were not shown because too many files have changed in this diff Show more