Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e04b6075e5 | ||
|
|
a443108be5 | ||
|
|
33683f2de2 | ||
|
|
68c6a5ae19 | ||
|
|
7bb55bca43 | ||
|
|
bd6b48a883 | ||
|
|
680d13b9e9 | ||
|
|
8a50f36c0c | ||
|
|
35a7ff03b7 | ||
|
|
5ca96b8c85 | ||
|
|
a8d8159038 | ||
|
|
ac83404f1c | ||
|
|
91f5588134 | ||
|
|
c7a8cec2b5 | ||
|
|
dc8c71c687 | ||
|
|
4cee50a3be | ||
|
|
db2a48cde6 | ||
|
|
f60a90d752 | ||
|
|
b15c534e67 | ||
|
|
0ef79b8032 | ||
|
|
224385af37 | ||
|
|
8f9866f5e1 | ||
|
|
7cc59c4338 | ||
|
|
f57cf916a1 | ||
|
|
7d31b7def4 | ||
|
|
5f4494c239 | ||
|
|
f7e38c0b42 | ||
|
|
6c9b28903f | ||
|
|
eccceeaadf | ||
|
|
24775845a3 | ||
|
|
ba5a6181fc | ||
|
|
2ada0e22ca | ||
|
|
ea90814809 | ||
|
|
df71d8deac | ||
|
|
c3b5f970c1 | ||
|
|
9bcd6976fe | ||
|
|
9c72fd369a | ||
|
|
dfbaa2654b | ||
|
|
815f6561b7 | ||
|
|
b07d74b51b | ||
|
|
ea5f70629c | ||
|
|
5f61528133 | ||
|
|
309879dba0 |
44 changed files with 3146 additions and 1849 deletions
|
|
@ -295,6 +295,10 @@ docs/
|
|||
boundary.md operator/agent trust model rationale
|
||||
agent-hierarchy.md tree-shape topology design + manager-privilege audit (#361)
|
||||
damocles-migration.md future migration plan for damocles → hyperhive
|
||||
gateway.md nginx vhost map, matrix discovery flow, firewall posture,
|
||||
HIVE_FORGE_URL loopback rationale (#764, #772, #793)
|
||||
matrix.md matrix container shape, serverName/gatewayHost split,
|
||||
firewall + federation, provisioning flow, fluffychat-web build
|
||||
```
|
||||
|
||||
## Reading paths
|
||||
|
|
@ -316,6 +320,11 @@ read them à la carte.
|
|||
pattern."** → [`docs/conventions.md`](docs/conventions.md).
|
||||
- **"Why does the nspawn flag look like that?"** →
|
||||
[`docs/gotchas.md`](docs/gotchas.md).
|
||||
- **"What nginx vhosts does the gateway serve? How does matrix
|
||||
discovery work?"** → [`docs/gateway.md`](docs/gateway.md).
|
||||
- **"How does the matrix-tuwunel container work? What about
|
||||
fluffychat-web and per-agent matrix accounts?"** →
|
||||
[`docs/matrix.md`](docs/matrix.md).
|
||||
|
||||
## Conventions & process
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Agent hierarchy & privileges
|
||||
|
||||
Design + audit doc for milestone #6 (the
|
||||
[issue](http://localhost:3000/hyperhive/hyperhive/issues/361) tree).
|
||||
Design + audit doc for the agent-privileges + tree-shape milestone
|
||||
(the [issue tree](http://localhost:3000/hyperhive/hyperhive/issues/361)).
|
||||
The implementation lands in pieces; this doc tracks what's done, what's
|
||||
planned, and what currently special-cases the manager.
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ Topology lives in the hive-c0re-owned **meta repo**, alongside
|
|||
|
||||
`null` = root-level agent. Today only the manager qualifies by default.
|
||||
Other agents land under `"manager"` on first sync. Re-parenting is
|
||||
operator-driven (#486 / #487):
|
||||
operator-driven:
|
||||
|
||||
- CLI: `hive-c0re set-parent <child> --parent <new>` (or `--root` to
|
||||
promote). Exactly one of `--parent` / `--root` is required.
|
||||
|
|
@ -34,11 +34,11 @@ validation rules to a pure `apply_set_parent` helper. Refuses:
|
|||
- self-parenting,
|
||||
- cycles (32-hop ancestor walk, mirroring `is_descendant_of`).
|
||||
|
||||
Post-#743 the manager is reparentable like any other agent — the
|
||||
"structurally root" carve-out was historical paranoia; the manager's
|
||||
privileges live on its MCP socket, not its tree position, and the
|
||||
cycle walk above catches the only real safety concern (moving the
|
||||
manager under one of its own descendants).
|
||||
The manager is reparentable like any other agent — there's no
|
||||
"structurally root" carve-out; the manager's privileges live on its
|
||||
MCP socket, not its tree position, and the cycle walk above catches
|
||||
the only real safety concern (moving the manager under one of its
|
||||
own descendants).
|
||||
|
||||
Idempotent no-op fast path skips the disk write when the parent is
|
||||
already what's requested. After a successful write the surfaces call
|
||||
|
|
@ -46,13 +46,12 @@ already what's requested. After a successful write the surfaces call
|
|||
viewers see the tree repaint without polling
|
||||
(`ContainerView.parent` is sourced from `topology.json`).
|
||||
|
||||
**Today's caveat (#361 follow-up):** the move is purely a JSON edit.
|
||||
Only the top-level manager (`hm1nd`) gets `/var/lib/hyperhive/agents`
|
||||
**Today's caveat:** the move is purely a JSON edit. Only the
|
||||
top-level manager (`hm1nd`) gets `/var/lib/hyperhive/agents`
|
||||
bind-mounted at `/agents` in its container, so sub-agents don't yet
|
||||
see their would-be children's state. Once sub-manager bind mounts
|
||||
land alongside #361 enforcement, `set_parent` grows a companion
|
||||
umount-old / mount-new / restart-cascade step (tracked via the
|
||||
cross-ref comment on #361).
|
||||
land alongside cap enforcement, `set_parent` grows a companion
|
||||
umount-old / mount-new / restart-cascade step.
|
||||
|
||||
### Why meta, not per-agent `agent.nix`
|
||||
|
||||
|
|
@ -78,7 +77,7 @@ where system-level facts live.
|
|||
so the harness / claude prompts can see it.
|
||||
4. **Surface**: `container_view::build_all` reads `topology.json` and
|
||||
populates `ContainerView.parent: Option<String>` on every rescan.
|
||||
The dashboard renders the field as a tree (#363 follow-up).
|
||||
The dashboard renders the field as a tree.
|
||||
|
||||
## Target topology semantics
|
||||
|
||||
|
|
@ -150,7 +149,7 @@ Tree-shape version:
|
|||
- `request_update_meta_inputs` is the only path that actually writes
|
||||
`flake.lock`, gated by the cap; everyone else stays RO.
|
||||
|
||||
### D — drop legacy `/state` for manager ✓ done (#604)
|
||||
### D — drop legacy `/state` for manager ✓ done
|
||||
|
||||
`lifecycle.rs` no longer binds `/state` for the manager.
|
||||
`HYPERHIVE_STATE_DIR` is now injected uniformly via
|
||||
|
|
@ -165,8 +164,8 @@ read.
|
|||
|
||||
- `prompts/system.md` with `<!-- role:agent -->` / `<!-- role:manager -->`
|
||||
marker blocks, assembled by `hive_ag3nt::prompt::render` based on
|
||||
flavor (closes #519). **Per-agent cap list** of what the agent can
|
||||
do — already a single parametrised prompt; once #513 lands the
|
||||
flavor. **Per-agent cap list** of what the agent can do — already
|
||||
a single parametrised prompt; once per-agent cap groups land the
|
||||
marker grammar grows `cap:<group>` blocks the renderer reads from
|
||||
the per-agent ToolGroup set.
|
||||
- `mcp.rs::Flavor::{Agent, Manager}` controls which MCP tools claude
|
||||
|
|
@ -182,8 +181,7 @@ read.
|
|||
descendants'.
|
||||
- `operator_questions.rs` + `broker.rs`: "manager can cancel any
|
||||
question" override on the owner check. **Topology** — agents can
|
||||
moderate threads of their descendants. (per mara's
|
||||
https://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3344)
|
||||
moderate threads of their descendants.
|
||||
- `reminder_scheduler.rs`: same override pattern for reminder cancel.
|
||||
**Topology** — descendants only.
|
||||
- `actions.rs`: `destroy` refuses to act on `MANAGER_NAME` (no
|
||||
|
|
@ -196,9 +194,9 @@ read.
|
|||
|
||||
### G — sub-agents inside the same container
|
||||
|
||||
Future work mentioned in #361: when enabled for an agent, it can spawn
|
||||
temporary "sub-agents" that run inside its own container. Lighter than
|
||||
a full nspawn agent. Open questions, not yet wired:
|
||||
Future work: when enabled for an agent, it can spawn temporary
|
||||
"sub-agents" that run inside its own container. Lighter than a full
|
||||
nspawn agent. Open questions, not yet wired:
|
||||
|
||||
- Inherit caps from parent, or take an explicit narrower set?
|
||||
- Survive container restart, or always ephemeral?
|
||||
|
|
@ -206,9 +204,102 @@ a full nspawn agent. Open questions, not yet wired:
|
|||
- Filesystem: share parent's `/state` RW, or a sub-dir?
|
||||
- Identity: distinct broker recipient name, or address the parent?
|
||||
|
||||
## Harness systemd unit shape (per-role)
|
||||
|
||||
One harness binary (`hive`), one `harness-base.nix` template, two
|
||||
systemd units depending on `hyperhive.role`:
|
||||
|
||||
- `agent-base.nix` (`role = "agent"`) → `systemd.services.hive-ag3nt`
|
||||
- `manager.nix` (`role = "manager"`) → `systemd.services.hive-m1nd`
|
||||
|
||||
The unit names diverge but the binary is the same. `HIVE_ROLE` env
|
||||
var picks the surface at startup (agent vs manager); naming the
|
||||
units after the historical per-role binaries keeps dashboard log
|
||||
queries, ExecStartPre paths, and ancestor PR diffs working without a
|
||||
rename cascade.
|
||||
|
||||
### Manager-only defaults
|
||||
|
||||
`harness-base.nix` flips these when `hyperhive.role == "manager"`,
|
||||
via `lib.mkDefault` so any agent can invert if needed:
|
||||
|
||||
- `hyperhive.forge.keepSubscriptions = false`
|
||||
- `hyperhive.forge.skipNotifyReasons = [ "subscribed" "participating" ]`
|
||||
|
||||
Skips the subscription / participation firehose so the manager's
|
||||
inbox only carries direct mentions, reviews, and assignments. Sub-
|
||||
agents keep the noisier defaults so they see anything aimed at the
|
||||
repos they're working on.
|
||||
|
||||
### Standalone-eval fallbacks
|
||||
|
||||
`nixosConfigurations.manager` must build standalone (without the
|
||||
meta-flake's per-agent flake.nix wrapper). For the manager unit
|
||||
that means hardcoded `HIVE_PORT` / `HIVE_LABEL` env values:
|
||||
|
||||
- `HIVE_PORT = "8875"` — FNV-1a(`"hm1nd"`) % 900 + 8100, matching
|
||||
`lifecycle::agent_web_port`. Sub-agents have the same shape via
|
||||
the meta-flake-generated `applied/<name>/flake.nix`.
|
||||
- `HIVE_LABEL = "hm1nd"` — container name; matches what `meta.rs`
|
||||
injects at deploy time.
|
||||
|
||||
Real deploys never read these — `meta::render_flake` overrides them
|
||||
via the generated wrapper. They exist so the manager
|
||||
`nixosConfigurations` evaluates cleanly even outside the meta-flake
|
||||
boundary.
|
||||
|
||||
### Environment variables set on the unit
|
||||
|
||||
- `HOME = /home/<userName>` — systemd defaults `HOME` to `/` for
|
||||
services without `User=` set; with the per-agent user (#658) the
|
||||
harness needs the right home so claude finds its bind-mounted
|
||||
`~/.claude/` session dir.
|
||||
- `HIVE_STATIC_DIR = <mergedDist>` — `tower_http::ServeDir` root for
|
||||
the per-agent web UI; merged dist = agent default + every
|
||||
`hyperhive.frontend.extraFiles` overlay.
|
||||
- `HIVE_ASSETS_DIR = pkgs.hyperhive-assets/share/hyperhive` — set
|
||||
directly on the unit, **not** via `environment.variables`, because
|
||||
the latter only populates `/etc/profile` which systemd services
|
||||
don't inherit.
|
||||
- `HIVE_ROLE = config.hyperhive.role` — picks the binary surface
|
||||
(agent / manager) at startup.
|
||||
|
||||
### `PATH` setup (the wrapper-dir trick)
|
||||
|
||||
```nix
|
||||
path = [ "/run/wrappers" "/run/current-system/sw" ];
|
||||
```
|
||||
|
||||
`/run/wrappers` comes first so setuid wrappers (notably `sudo`)
|
||||
resolve before bare nix-store binaries. NixOS's
|
||||
`systemd.services.<unit>.path` appends `/bin` to every entry via
|
||||
`lib.makeBinPath`; passing `/run/wrappers/bin` directly produces
|
||||
`/run/wrappers/bin/bin` which doesn't exist (`docs/gotchas.md::
|
||||
systemd.services.*.path appends /bin to every entry`). Post-#658
|
||||
when the harness runs as the per-agent user this matters: without
|
||||
the wrapper dir on PATH, `sudo` resolves to the un-setuid nix-store
|
||||
binary and rejects with `must be owned by uid 0 and have the setuid
|
||||
bit set` regardless of `hyperhive.user.passwordlessSudo`.
|
||||
|
||||
### `serviceConfig` highlights
|
||||
|
||||
- `ExecStart = pkgs.hyperhive/bin/hive serve` — single binary,
|
||||
surface picked from `HIVE_ROLE`.
|
||||
- `Restart = on-failure`, `RestartSec = 2` — keeps the harness
|
||||
resilient across transient crashes without thundering retries.
|
||||
- `RuntimeDirectory = "hive-config"` → `/run/hive-config/` owned by
|
||||
`User=`, auto-cleared on stop. The harness writes regenerated
|
||||
`claude-{mcp-config,settings,system-prompt}` files there
|
||||
(`paths::config_dir`). Deliberately separate from `/run/hive`,
|
||||
which the host bind-mounts in root-owned and which holds
|
||||
hive-c0re's `mcp.sock` (#658 fixup).
|
||||
- `User = Group = userName` — drops root inside the container; sudo
|
||||
is the explicit escalation surface
|
||||
(`hyperhive.user.passwordlessSudo`).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Milestone: [#361 "Agent privileges and sub-agents"](http://localhost:3000/hyperhive/hyperhive/issues/361)
|
||||
- Dashboard render: [#363 "show agent topology in container list"](http://localhost:3000/hyperhive/hyperhive/issues/363)
|
||||
- Audit table source: [comment 3335 on #361](http://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3335)
|
||||
- Milestone: ["Agent privileges and sub-agents"](http://localhost:3000/hyperhive/hyperhive/issues/361)
|
||||
- Dashboard render: ["show agent topology in container list"](http://localhost:3000/hyperhive/hyperhive/issues/363)
|
||||
- Audit table source: [milestone comment](http://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3335)
|
||||
- Operator/agent trust boundary (orthogonal axis): [`boundary.md`](boundary.md)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ happens after a decision lands.
|
|||
applied.
|
||||
3a. **Flake validation (ApplyCommit only):** after the proposal tag
|
||||
is planted, hive-c0re reads `proposal/<id>:flake.lock` and
|
||||
runs two checks (closes #317). If either check fails, no
|
||||
runs two checks. If either check fails, no
|
||||
pending approval is created for the operator — the row is
|
||||
marked failed and surfaces on the dashboard with the
|
||||
validation message:
|
||||
|
|
@ -66,7 +66,7 @@ happens after a decision lands.
|
|||
### Withdrawing a pending approval
|
||||
|
||||
The manager can call `cancel_loose_end(kind: "approval", id)` to
|
||||
withdraw an approval that hasn't been acted on yet (closes #250).
|
||||
withdraw an approval that hasn't been acted on yet.
|
||||
The row transitions to `ApprovalStatus::Cancelled` (distinct from
|
||||
`Denied`/`Failed`), the dashboard pulls the card out of the
|
||||
pending pane, and `ApprovalResolved { status: "cancelled" }` fires
|
||||
|
|
@ -202,7 +202,7 @@ approval id to retry. Because tags are first-class git objects,
|
|||
rejected and failed trees stay browsable forever — `git log
|
||||
--tags` in the applied repo is the audit trail.
|
||||
|
||||
### Dispatch via `rebuild_queue` (#441)
|
||||
### Dispatch via `rebuild_queue`
|
||||
|
||||
Long-running approval work — `ApplyCommit`, `UpdateMetaInputs`,
|
||||
`Spawn` — no longer runs inline inside `actions::approve`. Instead
|
||||
|
|
@ -344,7 +344,7 @@ Differences from sub-agents:
|
|||
(vs `agent-base`).
|
||||
- Container name is `hm1nd` (no `h-` prefix).
|
||||
- Web UI port via `lifecycle::agent_web_port("hm1nd")` — same
|
||||
FNV-1a hash as every other agent (8100..8999 range) since #753.
|
||||
FNV-1a hash as every other agent (8100..8999 range).
|
||||
- `set_nspawn_flags` adds two extra binds: `/var/lib/hyperhive/agents`
|
||||
→ `/agents` (RW) so the manager can edit per-agent proposed repos,
|
||||
and `/var/lib/hyperhive/applied` → `/applied` (RO) so the manager
|
||||
|
|
@ -424,8 +424,8 @@ regular claude turn so the manager can react. Variants
|
|||
such transient was cleared in the last 30s (`RECENT_TRANSIENT_GRACE`
|
||||
tombstone, three `POLL_INTERVAL`s — closes the race where a
|
||||
lifecycle op finishes between two crash-watch polls and the
|
||||
container shows briefly as "stopped without transient" before the
|
||||
next start, #425). Manager can `start` it again or escalate.
|
||||
container shows briefly as "stopped without transient" before
|
||||
the next start). Manager can `start` it again or escalate.
|
||||
- `NeedsLogin { agent }` — sub-agent has no claude session yet.
|
||||
Manager can't act directly (interactive OAuth); typically flags
|
||||
the operator.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ exist because something already went wrong without them.
|
|||
- The manager is `hm1nd` (no `h-` prefix, fixed name).
|
||||
- `MAX_AGENT_NAME` in `lifecycle.rs` enforces the cap.
|
||||
- Per-agent web UI port = `WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE`
|
||||
(8100..8999) for every agent including the manager (#753); dashboard
|
||||
(8100..8999) for every agent including the manager; dashboard
|
||||
`cfg.dashboardPort` (default 7000).
|
||||
|
||||
## Identity = socket
|
||||
|
|
@ -32,12 +32,12 @@ angle-bracket and asterisk shapes below are structurally safe.
|
|||
(`agent_server::handle_send` fans out via `Coordinator::broadcast_send`).
|
||||
- `operator` — the human at the dashboard. Messages accumulate in the
|
||||
inbox view; no agent ever `recv`'s them.
|
||||
- `<parent>` — the sender's parent per `topology.json` (`#692`).
|
||||
Rewritten at send time by `topology::resolve_recipient`: looks up
|
||||
- `<parent>` — the sender's parent per `topology.json`. Rewritten at
|
||||
send time by `topology::resolve_recipient`: looks up
|
||||
`parent_of(sender)` and falls back to `operator` when the sender is
|
||||
a root agent (or absent from topology entirely). Lets agents address
|
||||
their parent without learning the label, so runtime reparenting
|
||||
(`#486`) propagates with zero agent-side restart.
|
||||
propagates with zero agent-side restart.
|
||||
|
||||
When the resolver rewrites `<parent>`, the broker stores the
|
||||
*resolved* label as the message's recipient — the dashboard and
|
||||
|
|
@ -54,6 +54,43 @@ each frame carries a `seq` field for the snapshot-dedupe dance
|
|||
— change them in one place. The dashboard event vocabulary lives
|
||||
in `hive-c0re::dashboard_events::DashboardEvent`.
|
||||
|
||||
### Broker delivery + ack cycle
|
||||
|
||||
`AgentRequest::Recv` is the only path that delivers messages to an
|
||||
agent. Always returns a list (`Messages { messages }`) — empty when
|
||||
nothing's pending, single-pop when `max = None` (default 1, the
|
||||
single-message behaviour), batched up to `max` when caller asks for
|
||||
more (server-side cap is 32; values above clamp silently).
|
||||
`wait_seconds` long-polls for the first message; once one arrives —
|
||||
or one is already pending — the call drains up to `max` in total
|
||||
before returning, so a single `Recv` call coalesces a burst.
|
||||
|
||||
Per-row bookkeeping inside the broker:
|
||||
|
||||
- `delivered_at = NOW` set on every popped row.
|
||||
- Each recipient has an in-memory `unacked_ids` list of every row
|
||||
delivered since the last `AckTurn`.
|
||||
- `redelivered = true` on a row if `RequeueInflight` resurfaced it
|
||||
(the harness prepends a "may already be handled" hint when this
|
||||
flag is set so the per-message warning is visible).
|
||||
|
||||
`AgentRequest::AckTurn` closes out the in-memory list — the harness
|
||||
fires it after `TurnOutcome::Ok`, marking every message popped since
|
||||
the last ack as fully handled. Claude doesn't see this surface; it's
|
||||
strictly a harness↔broker pairing. On `TurnOutcome::Failed` the
|
||||
harness intentionally skips the ack so the unacked rows stay
|
||||
in-flight in the DB and get picked up by the next requeue sweep.
|
||||
|
||||
`AgentRequest::RequeueInflight` is the recovery pair: fired by the
|
||||
harness exactly once at boot, before the serve loop starts. Catches
|
||||
the crashed-mid-turn / OOM-killed / container-restarted cases where
|
||||
a previous harness session popped messages but never drove them to
|
||||
a clean turn-end. Resets `delivered_at` back to NULL on every
|
||||
unacked row (so the next `Recv` pops them again), and remembers
|
||||
each id in a per-recipient in-memory set so the next `Recv` can tag
|
||||
the row with `redelivered: true`. Idempotent + cheap when there's
|
||||
nothing in flight, so the at-boot fire is unconditional.
|
||||
|
||||
## Async forms
|
||||
|
||||
Dashboard + per-agent mutating forms carry `data-async`; a delegated
|
||||
|
|
|
|||
|
|
@ -76,3 +76,92 @@ SSH for forge stays direct on `cfg.sshPort` — separate listener protocol, not
|
|||
- #772 / #775 — fluffychat hops from `<hive>/matrix/` to `matrix.<hive>/`.
|
||||
|
||||
Next-up tracked separately: #14 (container netns isolation), TLS (#594).
|
||||
|
||||
## Firewall posture (host-level)
|
||||
|
||||
`hive-c0re.nix` opens the per-agent web-port range
|
||||
`8100..8999` in the host firewall **only when
|
||||
`services.hyperhive.gateway.enable = false`**. With the gateway on
|
||||
(default), it's the sole external entry point and proxies to
|
||||
`127.0.0.1:<port>` internally — leaving the per-agent ports
|
||||
firewall-open would defeat the single-front-door story (closes
|
||||
#621).
|
||||
|
||||
Manager hashes into the same range since #753 (no more
|
||||
"manager pinned at 8000" special case), so one range opening covers
|
||||
every container.
|
||||
|
||||
The dashboard port (`cfg.dashboardPort`, default 7000) is *not*
|
||||
listed in either case — since #652 it binds `127.0.0.1` only, so a
|
||||
firewall hole would be a no-op. Remote dashboard access flows
|
||||
through the gateway. Operators who opt out of the gateway lose
|
||||
external dashboard reach by design — the surface is privileged
|
||||
(approve / deny / destroy) and must not be exposed without a real
|
||||
reverse proxy in front.
|
||||
|
||||
## `HIVE_FORGE_URL`: loopback for in-cluster, sub-domain for the operator
|
||||
|
||||
Agents poll `HIVE_FORGE_URL` for Forgejo notifications + run all
|
||||
`hive-forge` calls against it. `hive-c0re.nix` pins this to
|
||||
`http://127.0.0.1:<forge.httpPort>` for the in-cluster path: every
|
||||
agent container shares the host's network namespace, so loopback
|
||||
reaches the forge container directly with no DNS lookup needed
|
||||
(closes #761).
|
||||
|
||||
The post-#754 sub-domain default (`forge.<hive-domain>`) is for
|
||||
**operator browsers + cross-host clients**, not in-cluster traffic.
|
||||
Using the sub-domain URL inside agent containers would fail every
|
||||
`hive-forge` invocation with "Name or service not known" — the
|
||||
agent's nspawn doesn't have DNS for the external hostname.
|
||||
|
||||
## hive-forge container shape
|
||||
|
||||
Private Forgejo wrapped in a nixos-container (`hive-forge`, not
|
||||
`h-*` — keeps c0re's lifecycle scanner out of the picture; the
|
||||
operator manages it via the standard `nixos-container` CLI). The
|
||||
container also keeps hive-forge from fighting any `services.forgejo`
|
||||
the operator already runs on the host — separate systemd namespace,
|
||||
separate state dir, separate port unless the operator deliberately
|
||||
collides.
|
||||
|
||||
Container shares the host network namespace
|
||||
(`privateNetwork = false`) so agents reach the forge at
|
||||
`http://localhost:<httpPort>` without extra plumbing — nixos-container
|
||||
is here for state + systemd-unit isolation, not network isolation.
|
||||
|
||||
State lives at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/`
|
||||
and survives container restart / host reboot. To wipe, destroy the
|
||||
container.
|
||||
|
||||
## Per-agent error pages
|
||||
|
||||
`/agent/<name>/` requests hit two failure modes; both get static
|
||||
HTML pages instead of nginx's default error chrome (#755):
|
||||
|
||||
- **Agent not found** (`/agent/<unknown>/...`) — name isn't in
|
||||
`agentPortsTable`. nginx's prefix match falls back to the bare
|
||||
`/agent/` catch-all, which `return 404`s and `error_page 404` rewrites
|
||||
to `/__hive_agent_not_found` → serves `not-found.html` with a link
|
||||
back to the dashboard.
|
||||
|
||||
- **Agent unreachable** (`502 / 503 / 504` from `proxy_pass`) — the
|
||||
per-agent harness isn't responding (container restarting, crash
|
||||
recovery, etc.). `proxy_intercept_errors on` + `error_page 502 503
|
||||
504 = /__hive_agent_unreachable` rewrites to `unreachable.html`.
|
||||
|
||||
Both pages are built at deploy time via `pkgs.runCommand` (one nix
|
||||
derivation `hyperhive-agent-error-pages` with `not-found.html` +
|
||||
`unreachable.html` inside) and served via two `internal` nginx
|
||||
locations with `alias` to the exact file. `internal` keeps the
|
||||
files from being directly request-able by operators — only nginx's
|
||||
own error-handling can reach them.
|
||||
|
||||
Page styling: minimal inline CSS matching the dashboard's catppuccin
|
||||
palette (`#1e1e2e` bg, `#cdd6f4` text, `#cba6f7` heading). No
|
||||
dependencies on the frontend dist — these pages render even when
|
||||
hive-c0re itself is down.
|
||||
|
||||
Scope is intentionally narrow per mara on #755: "only for routes
|
||||
already special cased in the nginx config". Other gateway routes
|
||||
(forge / matrix / fluffychat) get nginx defaults — extending the
|
||||
custom-error pattern there is a separate follow-up.
|
||||
|
|
|
|||
151
docs/gotchas.md
151
docs/gotchas.md
|
|
@ -87,8 +87,7 @@ propagate in. Operators don't need to set anything on their side.
|
|||
## Claude credentials are per-agent
|
||||
|
||||
`/var/lib/hyperhive/agents/<name>/claude/` bind-mounts to
|
||||
`/home/<name>/.claude` (RW; was `/root/.claude` pre-#658 when every
|
||||
harness ran as root). Sharing one dir across agents is NOT viable —
|
||||
`/home/<name>/.claude` (RW). Sharing one dir across agents is NOT viable —
|
||||
OAuth refresh tokens rotate, so any sibling refresh invalidates all
|
||||
the others. Login flow runs from the per-agent web UI; creds persist
|
||||
across `destroy`/recreate (`--purge` wipes them).
|
||||
|
|
@ -96,8 +95,8 @@ across `destroy`/recreate (`--purge` wipes them).
|
|||
## Persistent notes dir per agent
|
||||
|
||||
`/var/lib/hyperhive/agents/<name>/state/` bind-mounts to
|
||||
`/agents/<name>/state` (RW; uniform for sub-agents + manager
|
||||
post-#604, was `/state` pre-#604). The harness exposes the same path
|
||||
`/agents/<name>/state` (RW; uniform for sub-agents + manager).
|
||||
The harness exposes the same path
|
||||
via `$HYPERHIVE_STATE_DIR`. System prompts tell agents to keep
|
||||
durable knowledge here (`notes.md`, anything else). The harness also
|
||||
writes its events log here (`hyperhive-events.sqlite`).
|
||||
|
|
@ -112,8 +111,8 @@ unlucky. Operator resolves a collision by renaming the offending
|
|||
agent (different hash → different port) and rebuilding. No state
|
||||
file, no probing, no port-allocation drift — the value is
|
||||
reproducible from just the name. Every agent — including the
|
||||
manager — hashes into 8100..8999 via the same FNV-1a since #753;
|
||||
dashboard at `cfg.dashboardPort` (default 7000).
|
||||
manager — hashes into 8100..8999 via the same FNV-1a; dashboard
|
||||
at `cfg.dashboardPort` (default 7000).
|
||||
|
||||
## Restart races on TCP bind
|
||||
|
||||
|
|
@ -146,13 +145,12 @@ files in subdirectories) fails with `EPERM`. Fix: pass
|
|||
The naive nginx pattern for a path-prefix SPA (`try_files $uri $uri/
|
||||
/matrix/index.html`) silently swallows asset 404s — a missing JS file
|
||||
returns `index.html` with a 200, so the JS runtime never loads and the
|
||||
page renders blank with no visible error (#685; fixed in PR #684;
|
||||
#686 filed the follow-up edge-case, addressed in PR #729).
|
||||
Extension allowlists (tried in #686 → PR #729) have the same maintenance
|
||||
problem: any new file extension the SPA ships breaks silently.
|
||||
page renders blank with no visible error. Extension allowlists (tried
|
||||
as an alternative) have the same maintenance problem: any new file
|
||||
extension the SPA ships breaks silently.
|
||||
|
||||
The correct pattern (landed in PR #729, `hive-gateway.nix`) keys the
|
||||
fallback on the HTTP `Accept` header:
|
||||
The pattern that works (`hive-gateway.nix`) keys the fallback on the
|
||||
HTTP `Accept` header:
|
||||
|
||||
```nginx
|
||||
# Outside the server block (appendHttpConfig):
|
||||
|
|
@ -188,22 +186,22 @@ nix build /var/lib/hyperhive/meta#argus.config.system.build.toplevel
|
|||
nix build /var/lib/hyperhive/meta#nixosConfigurations.argus.config.system.build.toplevel
|
||||
```
|
||||
|
||||
`lifecycle::prebuild_toplevel` hit this in #721 (fixed in #738) by
|
||||
constructing the attr path as `{flake_ref}.config…` — which produced
|
||||
`meta#argus.config…` instead of `meta#nixosConfigurations.argus.config…`.
|
||||
The fix: `split_once('#')` to separate flake path from name, then
|
||||
template `{path}#nixosConfigurations.{name}.config.system.build.toplevel`.
|
||||
`lifecycle::prebuild_toplevel` hit this once by constructing the attr
|
||||
path as `{flake_ref}.config…` — which produced `meta#argus.config…`
|
||||
instead of `meta#nixosConfigurations.argus.config…`. The fix:
|
||||
`split_once('#')` to separate flake path from name, then template
|
||||
`{path}#nixosConfigurations.{name}.config.system.build.toplevel`.
|
||||
|
||||
## `hive-forge`: prefer over raw curl pipelines
|
||||
|
||||
Every agent container has `hive-forge` in PATH (installed via
|
||||
`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary
|
||||
since #280). Use it instead of ad-hoc curl pipelines:
|
||||
`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary).
|
||||
Use it instead of ad-hoc curl pipelines:
|
||||
|
||||
```bash
|
||||
hive-forge view 42 # title + body + comments
|
||||
hive-forge comments 42 # list all comments (human-readable)
|
||||
hive-forge --json comments 42 # same as above, JSON array (global flag, closes #421)
|
||||
hive-forge --json comments 42 # same as above, JSON array (global flag)
|
||||
hive-forge comment 42 --body "..." # post comment (inline body)
|
||||
hive-forge comment 42 --body-file - <<EOF # ...or pipe a HEREDOC
|
||||
multi-line body
|
||||
|
|
@ -212,7 +210,7 @@ hive-forge assign 42 damocles
|
|||
hive-forge close 42
|
||||
hive-forge labels 42 add feature
|
||||
hive-forge pr 42 # PR metadata as JSON
|
||||
hive-forge pr-create --title "..." --head my-branch --push # also `git push forge my-branch`, suppressing the post-push "Create a pull request" hint (#222)
|
||||
hive-forge pr-create --title "..." --head my-branch --push # also `git push forge my-branch`, suppressing the post-push "Create a pull request" hint
|
||||
hive-forge diff 42 # unified diff (lockfile hunks collapsed by default)
|
||||
hive-forge diff 42 --full # include unfiltered lockfile hunks
|
||||
hive-forge branches deployed/ # filter branches by pattern
|
||||
|
|
@ -227,3 +225,114 @@ hive-forge lint assignments # per-assignee open item count
|
|||
Credentials come from `$HYPERHIVE_STATE_DIR/forge-token`; default
|
||||
repo from `$HIVE_FORGE_REPO`, overridden per-invocation by the
|
||||
global `-r/--repo` flag.
|
||||
|
||||
## Containerized nix-daemon needs `sandbox-fallback = true`
|
||||
|
||||
Agent containers bind-mount the host's nix-daemon socket. nspawn
|
||||
containers don't get user-namespaces by default, so `nix build`
|
||||
invocations *inside* the container can't set up the build sandbox
|
||||
and fail outright if the host daemon's
|
||||
`nix.settings.sandbox-fallback` is `false` (nixpkgs default).
|
||||
`nix/templates/harness-base.nix` does `lib.mkForce true` so builds
|
||||
fall back to unsandboxed local builds rather than failing. Security
|
||||
implications: `docs/security.md`.
|
||||
|
||||
## Split asset derivations away from the rust workspace
|
||||
|
||||
`nix/assets.nix` builds the branding SVG/PNG family + claude
|
||||
system-prompt template + claude-settings JSON as its own derivation,
|
||||
separate from the hive-ag3nt / hive-c0re crates. Reason: when the
|
||||
rust build's `src` was the whole repo tree, any tweak to
|
||||
`branding/agent-configs.svg` or `hive-ag3nt/prompts/system.md`
|
||||
invalidated the cargo cache and forced a full rebuild. crane (and
|
||||
naersk before it) couldn't see "these inputs are unused by rust" on
|
||||
its own — the split breaks the coupling at the derivation boundary.
|
||||
The agent-configs PNG is rendered from the SVG via `rsvg-convert` at
|
||||
build time; librsvg dependency lives here, not in the rust
|
||||
derivation's `nativeBuildInputs`.
|
||||
|
||||
## Weston VNC compositor (per-agent `hyperhive.gui.enable`)
|
||||
|
||||
`nix/templates/weston-vnc.nix` adds an optional Weston Wayland
|
||||
compositor with the VNC backend, surfaced as
|
||||
`hyperhive.gui.enable = true` per-agent. The harness's
|
||||
`/screen/ws` WebSocket relay (`docs/web-ui.md::Per-agent endpoints`)
|
||||
connects to the compositor at `127.0.0.1:<vnc_port>`.
|
||||
|
||||
- **Port allocation**: deterministic FNV-1a of the agent name
|
||||
(read from `/etc/hostname`, leading `h-` stripped) mapped into
|
||||
`[15900, 16799]`. Mirrors the agent web-UI port pattern from
|
||||
`docs/gotchas.md::Web UI ports collide on hash` — same FNV-1a
|
||||
constant, different range. The compositor's startup script writes
|
||||
`/etc/hyperhive/gui.json = {"vnc_port":N,"auth":"none"}` so the
|
||||
harness reads the port at runtime; no nix-side / harness-side hash
|
||||
duplication.
|
||||
- **VNC bind address**: weston's VNC backend has no CLI
|
||||
bind-address flag (unlike the RDP backend's `--address`), so the
|
||||
listener binds `0.0.0.0`. The harness relay only connects via
|
||||
`127.0.0.1`; the host firewall blocks the per-agent VNC port range
|
||||
from external access. A future weston.ini `[vnc] address=` will
|
||||
let us restrict the bind directly once upstream supports it.
|
||||
- **PAM service name**: literal `weston-remote-access` — that's the
|
||||
string libweston passes to `pam_start()` in `libweston/auth.c`.
|
||||
Using `weston` falls back to the system default PAM stack and
|
||||
rejects auth. The service is configured to `pam_permit.so` for
|
||||
all three module types (auth / account / session) so the
|
||||
browser's empty Apple-DH credentials (type 30) always pass —
|
||||
neatvnc ≥ 0.9 calls the PAM auth callback regardless of
|
||||
`weston.ini` `auth-method=none`, so the permit fallback is what
|
||||
actually lets the empty-cred client through.
|
||||
- **`Type = "simple"` (not `notify`)**: `switch-to-configuration`
|
||||
must never block on weston signalling readiness. A misconfigured
|
||||
weston degrades to a `Restart=on-failure` loop visible in
|
||||
`journalctl`, it does not abort the `nixos-container update`.
|
||||
Same reasoning as the `tea-login` unit in `harness-base.nix`.
|
||||
- **`[core] idle-time=0`**: disables weston's 300-second idle
|
||||
timeout. Without it the VNC desktop fades to black and
|
||||
desktop-shell shows its click-to-unlock screen — useless for an
|
||||
agent desktop viewed over `/screen`. `idle-time=0` updates the
|
||||
idle timer with a 0ms delay, which
|
||||
`wl_event_source_timer_update` treats as "disarm", so the
|
||||
compositor never goes idle and never locks.
|
||||
|
||||
## Nix options reference (`nix/docs/default.nix`)
|
||||
|
||||
`pkgs.nixosOptionsDoc` over two evaluated module trees:
|
||||
`hostEval` (a stub NixOS system loading `self.nixosModules.default`
|
||||
with every hyperhive subsystem `mkForce false` so heavy build
|
||||
inputs stay out of the eval) and `agentEval` (reuses the already-evaluated
|
||||
`agent-base` container config so the per-agent options tree is
|
||||
identical to what a real agent container sees).
|
||||
|
||||
Three output trees consumed by `flake.nix`:
|
||||
|
||||
- `docs-host` — operator-facing host module options
|
||||
(`services.hyperhive.*`)
|
||||
- `docs-agent` — per-agent harness options (`hyperhive.*`
|
||||
declared in `nix/templates/harness-base.nix`)
|
||||
- `docs` — bundled static site (`index.html` + `host.html` +
|
||||
`agent.html`, plus `.md` source-of-truth versions of each
|
||||
options page)
|
||||
|
||||
Rendering pipeline:
|
||||
|
||||
- CommonMark from `nixosOptionsDoc.optionsCommonMark` — source of
|
||||
truth, kept as `.md` in the bundle.
|
||||
- HTML via `pkgs.cmark-gfm` over the CommonMark, wrapped in a
|
||||
minimal inline-CSS template. `cmark-gfm` (not plain `cmark`) so
|
||||
any future tables / autolinks Just Work without revisiting.
|
||||
- Inline `<style>` from `nix/docs/style.css` so the bundle is
|
||||
single-file-per-page and nginx's `/options/` mount needs no MIME
|
||||
setup for separate `.css` files and no cache-busting.
|
||||
- Asset paths inside rendered HTML are all relative
|
||||
(`./host.html`, etc.) so the bundle can mount at any URL prefix
|
||||
without rewriting.
|
||||
- `transformOptions` strips the nix-store prefix from option
|
||||
declaration paths and rewrites them as forge URLs, so the
|
||||
rendered docs link back to the source.
|
||||
|
||||
Host options live entirely under `services.hyperhive.*`. The
|
||||
`pickSubtrees` filter is rooted at `["services" "hyperhive"]` so the
|
||||
options tree picks up everything under that root — picking against
|
||||
stray roots produces an empty tree and renders the host page as
|
||||
template chrome with no `<h2>` headers.
|
||||
|
|
|
|||
199
docs/matrix.md
Normal file
199
docs/matrix.md
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# hive-matrix
|
||||
|
||||
Private Matrix homeserver (matrix-tuwunel — the conduwuit
|
||||
successor) wrapped in a nixos-container, plus optional fluffychat-web
|
||||
client at `matrix.<hive>/`. Configured via
|
||||
`services.hyperhive.matrix.*`; vhost routing lives in
|
||||
[`gateway.md`](gateway.md).
|
||||
|
||||
## Container shape
|
||||
|
||||
Same shape as [`gateway.md::hive-forge container shape`](gateway.md):
|
||||
|
||||
- Container name `hive-matrix` (not `h-*`) so c0re's lifecycle
|
||||
scanner ignores it; operator manages via the standard
|
||||
`nixos-container` CLI.
|
||||
- Keeps hive-matrix from fighting any `services.matrix-*` the
|
||||
operator already runs on the host — separate systemd namespace,
|
||||
separate state dir.
|
||||
- Container shares the host network namespace
|
||||
(`privateNetwork = false`) so agents reach tuwunel at
|
||||
`http://localhost:<httpPort>` without extra plumbing — the
|
||||
nixos-container is here for state + systemd-unit isolation, not
|
||||
network isolation.
|
||||
- Persistent state at
|
||||
`/var/lib/nixos-containers/hive-matrix/var/lib/matrix-tuwunel/`
|
||||
survives container restart / host reboot. To wipe, destroy the
|
||||
container.
|
||||
|
||||
## Identity vs API listener: `serverName` vs `gatewayHost`
|
||||
|
||||
Two distinct hostnames:
|
||||
|
||||
- **`serverName`** — matrix-spec `server_name`, embedded
|
||||
*irrevocably* in every `@user:<server_name>` and `!room:<server_name>`
|
||||
identifier minted on this homeserver. Cannot be changed later
|
||||
without abandoning every account and chat history. Defaults to the
|
||||
bare `services.hyperhive.domain` per mara on #660; clients
|
||||
auto-discover the actual API endpoint via the
|
||||
`.well-known/matrix/{client,server}` routes the hive-gateway serves
|
||||
at that domain.
|
||||
- **`gatewayHost`** — the API listener hostname, where the gateway's
|
||||
matrix vhost proxies `/_matrix/*` to tuwunel. Defaults to
|
||||
`matrix.<services.hyperhive.domain>` (sub-domain shape per mara on
|
||||
#749:9609). Set to `null` to skip the gateway vhost (tuwunel stays
|
||||
direct on `httpPort`).
|
||||
|
||||
**Breaking change** (#660): `serverName` used to default to
|
||||
`matrix.${services.hyperhive.domain}`. Existing homeservers must set
|
||||
the option explicitly to preserve their pre-#660 user / room IDs
|
||||
before rebuilding. The default flipped because the bare hive-domain
|
||||
makes for cleaner matrix IDs and `.well-known` delegation hides the
|
||||
sub-domain from the user-facing identifier.
|
||||
|
||||
## Default-closed firewall
|
||||
|
||||
`openFirewall` defaults to `false` (#651, secure-by-default): the
|
||||
homeserver is reachable from the host + every agent container via
|
||||
loopback either way (shared netns), so the firewall hole only
|
||||
matters for access from *outside* the host. Flip to `true` when
|
||||
announcing the homeserver to other hives or when an external matrix
|
||||
client needs to reach the client-server API directly.
|
||||
|
||||
**Breaking change** (#651): used to default to `true`. Operators
|
||||
relying on external reach must add
|
||||
`services.hyperhive.matrix.openFirewall = true;` before rebuilding.
|
||||
|
||||
Federation port 8448 is intentionally not opened here — tuwunel
|
||||
serves the federation API on the same `httpPort` as client-server
|
||||
by default. Reaching it on 8448 needs either an explicit tuwunel
|
||||
bind to that port OR a reverse-proxy + `.well-known/matrix/server`
|
||||
delegation (the latter lives in `gateway.md::Discovery flow`).
|
||||
|
||||
## Provisioning flow (registration token)
|
||||
|
||||
Token-gated registration: hive-c0re holds the token, agents never
|
||||
see it. The agent only receives the resulting `access_token`.
|
||||
|
||||
1. **System activation** writes a 32-byte random hex token (64
|
||||
chars) to `cfg.registrationTokenFile`
|
||||
(`/var/lib/hyperhive/matrix-register-token` by default), mode
|
||||
`0600 root:root`, before any container start. Idempotent — only
|
||||
writes when the file is missing or empty; always re-applies 0600
|
||||
(normalises any 0640 / world-readable carry-over from
|
||||
pre-LoadCredential deployments). This runs at activation time
|
||||
(not first container start) to dodge the argus #565 race where
|
||||
nspawn creates an empty file when the bind-mount target is
|
||||
missing and tuwunel reads `registration_token_file=""` rejecting
|
||||
every registration until next restart.
|
||||
2. **Read-only bind-mount** maps the host file into the tuwunel
|
||||
container at the same path.
|
||||
3. **systemd `LoadCredential=`** inside the container copies the
|
||||
bind-mounted file into
|
||||
`/run/credentials/tuwunel.service/registration_token`, owned by
|
||||
tuwunel's dynamic user with mode `0400`, at service start. The
|
||||
host file stays `root:root 0600` — no `chown :tuwunel` /
|
||||
`chmod 0640` / GID-pin gymnastics required (per iris on #644
|
||||
8043, dropping the shape #649 originally shipped with). Keeps
|
||||
`DynamicUser = true` + `PrivateUsers = true` intact.
|
||||
4. tuwunel's `registration_token_file` points at the credentials
|
||||
path, not the original bind-mount path.
|
||||
5. **hive-c0re** uses the token to register each agent account via
|
||||
the matrix-spec UIAA registration flow, persists the returned
|
||||
`access_token` to `<agent-state>/matrix-token`. The agent's
|
||||
matrix MCP client authenticates with that access_token and
|
||||
never touches the shared registration token.
|
||||
|
||||
Initial rollout settings (#548):
|
||||
|
||||
- `allow_federation = true` at the protocol level so swarms can be
|
||||
wired up later by extending `trustedServers` without a homeserver
|
||||
restart. `trusted_servers = []` keeps it effectively closed
|
||||
until peers are listed.
|
||||
- `allow_registration = true` (required for the token flow to
|
||||
engage). The absent
|
||||
`yes_i_am_very_very_sure_…_open_registration_…` flag keeps the
|
||||
server closed to anyone without the token.
|
||||
- `allow_encryption = false` per operator call (#548). E2EE
|
||||
re-enabling tracked at #551.
|
||||
|
||||
## Assertion rationale
|
||||
|
||||
Two `config.assertions` entries fail eval early rather than ship
|
||||
surprising behaviour:
|
||||
|
||||
- **`hyperhiveDomain != null || cfg.serverName != null`** (mara on
|
||||
#548) — `server_name` is embedded into every user / room ID
|
||||
irrevocably; we refuse to spawn the homeserver with a bogus
|
||||
server_name we can never change later.
|
||||
- **`cfg.gatewayHost != ""`** (argus 🟡 on #764) — same footgun as
|
||||
`forge.domain` (#754). Empty string renders `.<hive>`-shaped
|
||||
garbage in both nginx `server_name` (treated as wildcard
|
||||
catch-all, surprising) and `/etc/hosts` (invalid entry). `null`
|
||||
is the right opt-out shape; empty string is rejected explicitly.
|
||||
|
||||
## fluffychat-web build fixes (#685)
|
||||
|
||||
`pkgs.fluffychat-web` ships from `flutter341.buildFlutterApplication`,
|
||||
which has two upstream gaps for fluffychat's web target:
|
||||
|
||||
- The dart web-worker entry point (`web/native_executor.dart`) isn't
|
||||
compiled — `buildFlutterApplication` only runs `flutter build web`
|
||||
on the main entry.
|
||||
- `native_imaging`'s C source isn't built — emscripten isn't a
|
||||
flutter-builder native build input.
|
||||
|
||||
Both fixed in `nix/modules/hive-matrix.nix` via two derivations:
|
||||
|
||||
- **`fluffychat-web-imaging`** builds `Imaging.{js,wasm}` from the
|
||||
`native_imaging` C source via `pkgs.emscripten`. Source comes
|
||||
from `pkgs.fluffychat-web.passthru.pubspecLock.dependencySources.native_imaging`
|
||||
— already in the build closure of the flutter app, so no parallel
|
||||
hash pin and version auto-syncs with nixpkgs bumps. Build closure
|
||||
is ~3.6 GiB (emscripten LLVM); runtime closure is just the two
|
||||
output files. `dontConfigure = true` because cmake runs inside
|
||||
`js/Makefile` via `emcmake cmake`, not at the package root. The
|
||||
build script needs `HOME` + `EM_CACHE` writable for emscripten's
|
||||
on-demand sysroot build (libc, libc++ → wasm).
|
||||
- **`fluffychat-web-fixed`** is `pkgs.fluffychat-web` plus a
|
||||
`postInstall` patch that (a) compiles `web/native_executor.dart`
|
||||
via `dart compile js` (dart from the flutter341 closure, no
|
||||
incremental cost) and (b) installs `fluffychat-web-imaging`'s
|
||||
outputs into `$out`.
|
||||
|
||||
Two non-obvious fixes from review history:
|
||||
|
||||
- **`make -C js`** instead of `cd js; make` (argus 🟡 on #697 v2)
|
||||
— keeps the build-phase pwd at the source root so `installPhase`
|
||||
doesn't have to know about the cd. Robust against future
|
||||
reorders / `dontBuild`.
|
||||
- **`web/native_executor.dart`** as a build-CWD-relative path,
|
||||
*not* `$src/web/...` (#685 / #733 fixup) — `dart`'s
|
||||
`package_config.json` walk-up needs to hit
|
||||
`buildFlutterApplication`'s pub-get output (`.dart_tool/` in the
|
||||
build CWD). Walking up from a read-only `$src/` store path finds
|
||||
no `.dart_tool/` and errors with "Couldn't resolve the package
|
||||
'matrix'". Confused two PRs.
|
||||
|
||||
Drop both derivations when nixpkgs's flutter builder grows worker
|
||||
+ emcc support upstream.
|
||||
|
||||
Mount point is `matrix.<hive>/` (#772); upstream `--base-href "/"`
|
||||
is correct at sub-domain root, no override.
|
||||
|
||||
## Sequencing history
|
||||
|
||||
- #548 — initial rollout (federation enabled, registration enabled,
|
||||
E2EE disabled)
|
||||
- #565 — first-boot empty-token race fix → activation-time token
|
||||
generation
|
||||
- #644 / iris 8043 / #649 — registration token ownership shape
|
||||
(dropped chown/GID-pin; LoadCredential delivers as 0400 dynamic-user)
|
||||
- #651 — `openFirewall` default flipped to `false`
|
||||
- #660 — `serverName` default flipped to bare hive-domain (was
|
||||
`matrix.<hive>`)
|
||||
- #685 / #697 / #733 — fluffychat-web build fixes (Imaging emscripten,
|
||||
native_executor dart worker, build-CWD path)
|
||||
- #736 — fluffychat config.json inline JSON at sub-domain root
|
||||
- #749 / #764 — gateway sub-domain shape verdict
|
||||
- #772 / #775 — fluffychat hops from `<hive>/matrix/` to `matrix.<hive>/`
|
||||
|
|
@ -32,8 +32,8 @@ header/targets split:
|
|||
Q&A (`HelperEvent::QuestionAsked` pushed into target's inbox,
|
||||
answered via `Answer` request). Migrated via `ALTER TABLE ADD
|
||||
COLUMN` against `pragma_table_info`.
|
||||
- `scheduled_prompts` — recurring + one-shot prompt queue
|
||||
(closes #444). `owner / body / interval_seconds (NULL = one-shot) /
|
||||
- `scheduled_prompts` — recurring + one-shot prompt queue.
|
||||
`owner / body / interval_seconds (NULL = one-shot) /
|
||||
next_fire_at_unix / created_at_unix / source ("operator" or
|
||||
"approval:<id>") / cancelled_at_unix / description`. `owner`
|
||||
drives cancel-permission checks (operator vs the submitting
|
||||
|
|
@ -101,9 +101,8 @@ Bin-loop helpers `build_row` + `record` land each row at
|
|||
`turn_end`; writes are best-effort, a sqlite hiccup logs + lets
|
||||
the turn loop continue.
|
||||
|
||||
No host-side vacuum yet — tracked as forge issue
|
||||
[#10](http://localhost:3000/hyperhive/hyperhive/issues/10)
|
||||
(target retention ~90 days, age-only sweep like events_vacuum).
|
||||
No host-side vacuum yet — tracked separately. Target retention
|
||||
~90 days, age-only sweep like events_vacuum.
|
||||
|
||||
### `/state/hyperhive-rate-limited` (per agent)
|
||||
|
||||
|
|
@ -136,13 +135,12 @@ Under `/var/lib/hyperhive/agents/<name>/`:
|
|||
precise changes from the manager; RW into the manager via the
|
||||
`/agents` tree bind.
|
||||
- `claude/` — claude OAuth credentials, bind-mounted RW to
|
||||
`/home/<name>/.claude` inside the container (post-#658 — was
|
||||
`/root/.claude` pre-#658 when every harness ran as root).
|
||||
`/home/<name>/.claude` inside the container.
|
||||
- `state/` — durable notes, the events.sqlite db, and the
|
||||
turn-stats sqlite db. Bind-mounted to `/agents/<name>/state`
|
||||
inside the container (uniform for sub-agents + manager
|
||||
post-#604). The `$HYPERHIVE_STATE_DIR` env var exposes the
|
||||
same path to in-container scripts.
|
||||
inside the container (uniform for sub-agents + manager).
|
||||
The `$HYPERHIVE_STATE_DIR` env var exposes the same path to
|
||||
in-container scripts.
|
||||
|
||||
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
|
||||
applied repo. Tracks `flake.nix` (module-only boilerplate; never
|
||||
|
|
@ -198,3 +196,66 @@ On startup, `Coordinator::register_agent` drops any prior socket
|
|||
task before rebinding — idempotent so a hive-c0re restart followed
|
||||
by `rebuild alice` recreates the agent's socket without a clean
|
||||
reinstall.
|
||||
|
||||
## First-boot agent-user migration
|
||||
|
||||
The harness runs as a per-agent unix user inside the container
|
||||
(`hyperhive.user.name`, defaults to the agent's logical label so each
|
||||
container has a uniquely-named user). Operators with legacy root-owned
|
||||
state dirs need a one-time data shuffle so they don't lose their claude
|
||||
session.
|
||||
|
||||
`system.activationScripts.hive-agent-user-migrate` (in
|
||||
`nix/templates/harness-base.nix`) runs on every activation,
|
||||
marker-guarded so the substantive moves only happen once per
|
||||
container lifetime:
|
||||
|
||||
1. **`${homeDir}` exists with the right ownership** — covers the
|
||||
very first boot before `useradd`'s `createHome` has had a
|
||||
chance to chown. Also re-applies on every rebuild in case the
|
||||
meta-flake's per-agent name evolves (rare).
|
||||
2. **Migrate any leftover `/root/.claude` content into
|
||||
`${homeDir}/.claude`** — legacy `claude` wrote to root's
|
||||
empty home; the bind mount didn't exist yet. Marker
|
||||
(`/var/lib/hive-agent-user-migrated`) guards single-shot.
|
||||
`cp -an` (no-clobber) so any pre-existing files at the new
|
||||
location win — never blow over data already there.
|
||||
3. **Chown the bind-mounted state dir** (`/agents/*/state`)
|
||||
recursively so the agent user can read/write it. Wildcard
|
||||
matches the single agent that container sees; `-h` skips
|
||||
symlinks the agent might have planted.
|
||||
4. **Chown the `~/.claude/` bind-mount** recursively. Legacy
|
||||
`claude` wrote `.credentials.json` 0600 root:root; the
|
||||
current harness reads `~/.claude/` as the agent user to decide
|
||||
Online vs NeedsLogin in `login::has_session`. Without the
|
||||
chown the existing credentials get silently treated as "no
|
||||
session" and the operator re-prompts every boot.
|
||||
|
||||
The activation script will eventually become unnecessary once no
|
||||
operators have legacy root-owned state dirs left to migrate; drop
|
||||
the body + marker check at that point.
|
||||
|
||||
## Matrix per-agent daemon + token-arrival trigger
|
||||
|
||||
`hive-matrix-daemon` is a long-running matrix-sdk Client + sync
|
||||
process per agent. Holds the unix socket the stdio
|
||||
`hive-matrix-mcp` bridge talks to, emits hyperhive wake signals
|
||||
on incoming room events via `/run/hive/mcp.sock`. Conditional on
|
||||
`hyperhive.matrix.enable` (which both the daemon AND the
|
||||
auto-injected `extraMcpServers.matrix` entry read).
|
||||
|
||||
Socket path lives inside the systemd-managed runtime dir
|
||||
(`RuntimeDirectory = "hive-matrix"` → `/run/hive-matrix/`, owned by
|
||||
the agent user) so the daemon can bind without needing root over
|
||||
`/run/` itself. Both daemon + bridge agree on the path via the
|
||||
`HIVE_MATRIX_SOCKET` env var.
|
||||
|
||||
**First-boot ordering**: hive-c0re provisions the matrix token AFTER
|
||||
agent containers come up. Without the path-trigger sibling
|
||||
(`systemd.paths.hive-matrix-daemon`, `PathExistsGlob =
|
||||
/agents/*/state/matrix-token`), the daemon would exit 0 quietly the
|
||||
first time it ran and the MCP would have no backend until the next
|
||||
restart. The `.path` unit makes the appearance of the token re-fire
|
||||
the service so the daemon comes alive in the same boot cycle as
|
||||
provisioning. `matrix-avatar-sync.path` uses the same pattern for
|
||||
the icon-upload oneshot.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Security model
|
||||
|
||||
## Nix builds and credential isolation (issue #240)
|
||||
## Nix builds and credential isolation
|
||||
|
||||
### Background
|
||||
|
||||
|
|
@ -19,12 +19,11 @@ any file in the container that the nixbld user can read.
|
|||
**What is NOT exposed**:
|
||||
|
||||
- `/home/<name>/.claude/` — mode `0700`, owned by the per-agent
|
||||
user `<name>` (post-#658 — was `/root/.claude` owned by root
|
||||
pre-#658). nixbld users cannot read it.
|
||||
user `<name>`. nixbld users cannot read it.
|
||||
- `$HYPERHIVE_STATE_DIR/forge-token` (= `/agents/<name>/state/forge-token`)
|
||||
— written at mode `0600` by `hive-c0re/src/forge.rs` and chowned to the
|
||||
per-agent uid:gid by `lifecycle::chown_to_agent` (post-#673/#678).
|
||||
nixbld users cannot read it.
|
||||
per-agent uid:gid by `lifecycle::chown_to_agent`. nixbld users
|
||||
cannot read it.
|
||||
|
||||
**Policy**: all credential files written to agent state directories MUST be mode
|
||||
`0600` or stricter. Do not create world-readable secret files in agent state dirs.
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ parent's negative pull.
|
|||
| `.tool-use` (flat) | `→ Name args…` | cyan | tool_use w/o rich renderer | stream-json |
|
||||
| `.tool-use` `<details>` | `Write/Edit <path> · +N` (no `→`) | cyan, body is +/- diff | `renderRichToolUse` Write/Edit | stream-json |
|
||||
| `.tool-use` `<details open>` | `send → to · NL`, `ask → to`, `answer #id` | cyan, body is markdown | rich renderer for send / ask / answer | stream-json |
|
||||
| `.tool-use .ask-answer-inline-slot` | (sub-block under `ask → operator`) | inherits row | inline answer form bound by `reconcileAskBinds` to the loose-end | #666 |
|
||||
| `.tool-use .ask-answer-inline-slot` | (sub-block under `ask → operator`) | inherits row | inline answer form bound by `reconcileAskBinds` to the loose-end | rich renderer |
|
||||
| `.tool-result` (flat) | `← <txt>` | muted | short `tool_result` (≤120c, non-recv) | stream-json |
|
||||
| `.tool-result-block` `<details>` | `Nl · headline` | muted, body is text | long generic `tool_result` | stream-json |
|
||||
| `.tool-result-block` `<details open>` | `recv ← <txt>` | muted, body is markdown | `tool_result` correlated to a prior `recv` tool_use via id | stream-json |
|
||||
|
|
@ -105,7 +105,7 @@ isn't in the built-in `fmtToolUse` switch:
|
|||
This keeps `mcp__matrix__send_message` and similar from
|
||||
dumping raw JSON.
|
||||
|
||||
## Inline ask-operator answer (#666)
|
||||
## Inline ask-operator answer
|
||||
|
||||
When an agent calls `mcp__hyperhive__ask` with `to == "operator"`
|
||||
(default), the rich tool-use renderer mounts an empty
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ claude has access to in return.
|
|||
## The loop
|
||||
|
||||
Each agent harness (`hive serve`, with role picked from `$HIVE_ROLE`
|
||||
— `"agent"` for sub-agents, `"manager"` for the manager; post-#598
|
||||
this is one binary not two) runs:
|
||||
— `"agent"` for sub-agents, `"manager"` for the manager — one
|
||||
binary, not two) runs:
|
||||
|
||||
1. Long-poll `Recv` on its socket. The host-side broker
|
||||
(`broker.rs::recv_blocking_batch`) returns immediately if there's
|
||||
|
|
@ -31,8 +31,8 @@ this is one binary not two) runs:
|
|||
(`Bus::emit_status("rate_limited")`), sleeps
|
||||
`HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), then retries.
|
||||
The dashboard and per-agent page show a `⊘ rate limited` badge
|
||||
while the harness is parked. **Auth-failed detection** (closes
|
||||
#419): both stdout and stderr pumps also match
|
||||
while the harness is parked. **Auth-failed detection**: both
|
||||
stdout and stderr pumps also match
|
||||
`AUTH_FAIL_MARKERS` (`"authentication_failed"`, `401`, etc.).
|
||||
On match the harness writes `{state_dir}/hyperhive-needs-login`,
|
||||
emits `needs_login_idle` status, requeues the inflight message
|
||||
|
|
@ -40,7 +40,7 @@ this is one binary not two) runs:
|
|||
the same path used at boot. The operator re-authenticates via
|
||||
the per-agent web UI login flow; on success the sentinel is
|
||||
cleared and the queued message drives the next turn normally.
|
||||
**Mtime-snapshot resumption** (closes #542): `wait_for_login`
|
||||
**Mtime-snapshot resumption**: `wait_for_login`
|
||||
snapshots the `~/.claude/` dir (newest file mtime + file count)
|
||||
at entry and only resumes when that snapshot advances — not
|
||||
just when credentials exist on disk. This prevents a silent
|
||||
|
|
@ -176,8 +176,8 @@ socket at `/run/hive/` once at startup:
|
|||
- `claude-system-prompt.md` — rendered from
|
||||
`hive-ag3nt/prompts/system.md` by `hive_ag3nt::prompt::render`:
|
||||
HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`,
|
||||
same for `role:manager`) gate the role-specific blocks (closes
|
||||
#519); everything else is shared. Five placeholders are then
|
||||
same for `role:manager`) gate the role-specific blocks; everything
|
||||
else is shared. Five placeholders are then
|
||||
substituted: `{label}` (short agent name), `{qualified_label}`
|
||||
(hive-qualified `name@domain` form), `{operator_pronouns}`,
|
||||
`{hive_identity}` (e.g. `` on hive `pr1ma` ``; empty when
|
||||
|
|
@ -350,10 +350,9 @@ meta's.
|
|||
`agent.nix`, commits the changes, and calls `request_apply_commit`
|
||||
with the commit sha — the first ApplyCommit on a freshly-init'd
|
||||
config creates the container. Fails if a proposed repo for this
|
||||
name already exists. (The pre-#442 path through a separate
|
||||
manager-side `request_spawn` was removed; operator can still
|
||||
direct-spawn an empty agent from the dashboard's `◆ R3QU3ST SP4WN`
|
||||
button which routes via `HostRequest::RequestSpawn`.)
|
||||
name already exists. (The operator can also direct-spawn an empty
|
||||
agent from the dashboard's `◆ R3QU3ST SP4WN` button, which routes
|
||||
via `HostRequest::RequestSpawn`.)
|
||||
- `kill(name)` — graceful stop. No approval required.
|
||||
- `start(name)` — start a stopped sub-agent. No approval.
|
||||
- `restart(name)` — stop + start. No approval.
|
||||
|
|
@ -398,7 +397,7 @@ meta's.
|
|||
approval (use `remind` for unapproved self-wake). Long downtime
|
||||
fires once per recurring row on resume (catch-up clamp).
|
||||
- `edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)` —
|
||||
partial-update a schedule (#474/#478). Pass only the fields to
|
||||
partial-update a schedule. Pass only the fields to
|
||||
change; absent fields are left alone. `targets_add` / `targets_remove`
|
||||
mutate the recipient list in the same transaction; re-adding a
|
||||
previously-cancelled target drops its tombstone + history (fresh
|
||||
|
|
|
|||
190
docs/web-ui.md
190
docs/web-ui.md
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
Two web surfaces share the same skeleton: the dashboard (port 7000)
|
||||
and the per-agent UIs (every container — including the manager —
|
||||
hashes into :8100-8999 via `lifecycle::agent_web_port`'s FNV-1a,
|
||||
since #753). Both are SPAs — `GET /` returns a static shell,
|
||||
`/api/state` returns JSON, JS renders. No full-page reloads.
|
||||
hashes into :8100-8999 via `lifecycle::agent_web_port`'s FNV-1a).
|
||||
Both are SPAs — `GET /` returns a static shell, `/api/state`
|
||||
returns JSON, JS renders. No full-page reloads.
|
||||
|
||||
## Shape (shared by both)
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ since #753). Both are SPAs — `GET /` returns a static shell,
|
|||
based, no `innerHTML` — XSS-safe); markdown bodies get the
|
||||
same treatment via `marked`'s autolink (npm dep, replacing the
|
||||
vendored UMD bundle), with the rendered `<a>`s rewritten to
|
||||
`target="_blank"` (issue #233).
|
||||
`target="_blank"`.
|
||||
- `GET /api/state` → JSON snapshot the JS app renders into the
|
||||
DOM. Includes a top-level `seq` (the dashboard event channel's
|
||||
high-water mark at the moment the snapshot was assembled);
|
||||
|
|
@ -50,7 +50,7 @@ since #753). Both are SPAs — `GET /` returns a static shell,
|
|||
~200 broker messages wrapped in `{ seq, events }`) on the
|
||||
dashboard and `GET /events/history` (last 2000 `LiveEvent`s
|
||||
also wrapped in `{ seq, events }`) on the agent.
|
||||
**SSE multiplexing** (#448): the dashboard uses a
|
||||
**SSE multiplexing**: the dashboard uses a
|
||||
`SharedWorker` (`stream-worker.js`) to hold one upstream
|
||||
`EventSource` per URL. All same-origin tabs share this worker
|
||||
— a second dashboard tab joins the existing connection rather
|
||||
|
|
@ -59,7 +59,7 @@ since #753). Both are SPAs — `GET /` returns a static shell,
|
|||
page re-subscribes (gets a synthetic `open` event immediately
|
||||
if the upstream is already connected). Falls back gracefully
|
||||
when `SharedWorker` is unavailable (e.g. some private-mode
|
||||
browsers). **Worker-death self-heal** (#515): Firefox kills
|
||||
browsers). **Worker-death self-heal**: Firefox kills
|
||||
"idle" SharedWorkers under memory pressure with no client-side
|
||||
signal — the port silently goes no-op. The worker now pings
|
||||
every connected port every 30s; the client bumps a
|
||||
|
|
@ -72,6 +72,84 @@ since #753). Both are SPAs — `GET /` returns a static shell,
|
|||
bfcache-restore uses). Recovery is per-tab; pings are
|
||||
invisible on the healthy path.
|
||||
|
||||
### Shared terminal pane
|
||||
|
||||
Both surfaces' scrollable log streams (`#msgflow` on the dashboard,
|
||||
`#live` on the per-agent page) are backed by the shared terminal
|
||||
factory in `@hive/shared/terminal.js`. The factory wires up
|
||||
sticky-bottom auto-scroll, a "↓ N new" pill, history backfill, and
|
||||
SSE replay. Pages register a `kind → renderer` map; unknown kinds
|
||||
fall through to a JSON-dump note row. The factory ships three row
|
||||
shapes the renderers call:
|
||||
|
||||
- `api.row(cls, text)` — single-line row with an inline `linkify`
|
||||
pass over the text.
|
||||
- `api.details(cls, summary, body)` — collapsible `<details>` with
|
||||
a `<pre>` body (used by long tool-results and stack traces).
|
||||
- `api.detailsDiff(cls, summary, body)` — same shape, splits the
|
||||
body on newlines and tags each line as `diff-add` / `diff-del` /
|
||||
`diff-ctx` so the renderer's diff bodies get coloured without
|
||||
emitting raw HTML.
|
||||
|
||||
**Sticky-bottom + snap animation.** `stickToBottom` is the
|
||||
operator's intent: true means "keep snapping to bottom on every
|
||||
mutation", false means "I scrolled up, leave me alone". The flag
|
||||
flips when a scroll event lands further than `NEAR_BOTTOM_PX = 48`
|
||||
from the bottom. New rows then either snap to bottom (when sticky)
|
||||
or bump the unseen-count and surface the "↓ N new" pill. The snap
|
||||
is a brief 140ms ease-out (`SCROLL_ANIM_MS`) — the browser's
|
||||
default `behavior: 'smooth'` ~500ms reads as "still smooth, but
|
||||
visibly slow"; 140ms feels snap-y while still reading as motion
|
||||
rather than a jump. Distances under `SCROLL_SNAP_PX = 24`
|
||||
short-circuit to instant — animating a 12px nudge would just be
|
||||
jitter. Each new snap cancels the previous `requestAnimationFrame`
|
||||
so a burst of mutations coalesces into one ride to the latest
|
||||
bottom; the per-frame step re-reads `scrollHeight - clientHeight`
|
||||
so mutations landing mid-animation extend the destination smoothly
|
||||
rather than land short.
|
||||
|
||||
**Mid-animation scroll-event guard.** The scroll handler's
|
||||
`isNearBottom` check would flip `stickToBottom` false mid-snap as
|
||||
the smooth animation eases through positions that are technically
|
||||
"not near bottom yet", which would strand the operator partway. A
|
||||
`smoothScrollingUntil` timestamp gates the scroll handler — set to
|
||||
the animation end + ~80ms headroom, re-armed on each fresh snap.
|
||||
Programmatic `scrollTop` writes (the animation's per-frame update)
|
||||
fire scroll events that the gate swallows.
|
||||
|
||||
**Post-append `MutationObserver`.** Renderers commonly call
|
||||
`api.row(cls, text)` to create the row shell then append more
|
||||
children (badges, multi-line bodies, tool result panes) after the
|
||||
factory returned. The initial sticky-snap fires off the row's
|
||||
empty shape; the renderer's later appends grow the row past the
|
||||
visible bottom. A `MutationObserver` on the log subtree fires once
|
||||
per microtask after each batch of synchronous mutations and snaps
|
||||
again when `stickToBottom` is true. Programmatic `scrollTop`
|
||||
writes don't re-trigger the observer (scroll isn't a DOM
|
||||
mutation), so no feedback loop. The pre-append
|
||||
`nearBottomBeforeAppend` snapshot is still useful — it keeps the
|
||||
initial visual lag to one frame instead of one microtask + frame.
|
||||
|
||||
**Backfill + SSE.** Cold load fetches `historyUrl` (replay), then
|
||||
subscribes to `streamUrl` (live tail). Both endpoints return
|
||||
`{ seq, events }` so the client can dedupe — events with
|
||||
`seq <= snapshot.seq` from the SSE stream are dropped silently
|
||||
(the snapshot already covers them). History rows render with a
|
||||
`.no-anim` class so they don't stagger in like live events. The
|
||||
optional `streamFactory(url)` callback lets the dashboard hand
|
||||
the factory a `SharedWorker`-backed `EventSource` facade (so
|
||||
multiple tabs share one upstream connection — see *SSE
|
||||
multiplexing* above); when omitted, the factory falls back to a
|
||||
plain `new EventSource(url)`.
|
||||
|
||||
**`linkify` (text-node based).** Bare `http(s)://` URLs in row
|
||||
text get wrapped in `<a target="_blank" rel="noopener noreferrer">`
|
||||
inside a fresh text node, so the autolinker never touches
|
||||
`innerHTML` and untrusted row content can't smuggle markup. The
|
||||
trailing-punctuation strip keeps `.,;:` outside the link surface.
|
||||
Markdown bodies go through `marked` separately and get the same
|
||||
target rewrite.
|
||||
|
||||
The JS app handles all `form[data-async]` submissions via a delegated
|
||||
listener: read `data-confirm`, swap the button to a spinner, POST
|
||||
`application/x-www-form-urlencoded`, re-enable the button on success
|
||||
|
|
@ -116,7 +194,7 @@ titled header, a close button, and a scrollable body. Closes on
|
|||
the button, a backdrop click, or `Escape`. `Panel.open(title,
|
||||
node)` swaps the body; the JS builders for file previews,
|
||||
approval diffs, and journald logs all render into it. **The
|
||||
drawer width is drag-to-resize** (#451): a thin 6px hit-strip on
|
||||
drawer width is drag-to-resize**: a thin 6px hit-strip on
|
||||
the left edge captures pointer events, resizes the drawer in
|
||||
real-time (pointer capture keeps dragging even if the cursor
|
||||
outpaces the handle), and persists the chosen width to
|
||||
|
|
@ -354,7 +432,7 @@ with `--base-href /matrix/`, swappable via
|
|||
`services.hyperhive.matrix.gui.package`) served by the hive-gateway
|
||||
nginx container at `/matrix/` when
|
||||
`services.hyperhive.matrix.gui.enable` is on (defaults to
|
||||
`matrix.enable`, #635). c0re signals availability via the
|
||||
`matrix.enable`). c0re signals availability via the
|
||||
`HIVE_MATRIX_GUI_ENABLED` env var → `state.matrix_gui_enabled` in
|
||||
`/api/state`; the gateway does the actual static serving.
|
||||
|
||||
|
|
@ -364,7 +442,7 @@ in once with the in-host tuwunel homeserver URL
|
|||
|
||||
The unified nginx-front re-root to
|
||||
`https://matrix.${hyperhive.domain}` + `.well-known/matrix/client`
|
||||
auto-discovery is tracked in #609 (atlas's lane, post-#15).
|
||||
auto-discovery lives in `docs/gateway.md` (atlas's lane).
|
||||
|
||||
### FL0W page (`/flow.html`)
|
||||
|
||||
|
|
@ -383,7 +461,14 @@ newest-first.
|
|||
**MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`.
|
||||
Cold load backfills the last ~200 messages from `/dashboard/history`;
|
||||
live frames arrive on `/dashboard/stream`. Each row is one broker
|
||||
event — `sent` or `delivered` — with `from → to: body`. Sticky-
|
||||
event — `sent` or `delivered` — with `from → to: body`. The row is
|
||||
a `flex-wrap: wrap` container holding ts / arrow / from / sep / to
|
||||
chips inline; the **body wraps to its own full-width line below**
|
||||
the chips (`flex: 1 1 100%`) so the body always gets the full row
|
||||
width down to the content edge — long timestamps + agent names
|
||||
used to push the body ~30ch in and force awkward narrow-column
|
||||
wraps. `min-width: 0` keeps `word-break: break-word` effective so
|
||||
the body doesn't force the row wider than its container. Sticky-
|
||||
bottom auto-scroll + "↓ N new" pill. Below the stream sits a
|
||||
terminal-style compose box: `@name` picks the recipient (sticky via
|
||||
localStorage; auto-complete from the live container list, Tab/Enter
|
||||
|
|
@ -482,7 +567,7 @@ fetch entirely.
|
|||
`ContainerView.context_window_tokens`; the badge goes yellow
|
||||
≥ 50% and red ≥ 75% of that window (the harness compaction
|
||||
watermarks). When the window can't be resolved the badge falls
|
||||
back to fixed 100k / 150k thresholds. (issue #66)
|
||||
back to fixed 100k / 150k thresholds.
|
||||
- Line 2: status badges only (no per-card action buttons — actions
|
||||
moved to the **selection bar**, see below).
|
||||
- Line 3: drill-in triggers —
|
||||
|
|
@ -492,7 +577,7 @@ fetch entirely.
|
|||
A unit dropdown (harness service / full machine journal) and
|
||||
a refresh button live in the panel. The panel uses a column-flex
|
||||
layout so the `<pre>` log surface fills the full remaining panel
|
||||
height (#541); scroll happens inside the `<pre>`, not the side
|
||||
height; scroll happens inside the `<pre>`, not the side
|
||||
panel body.
|
||||
- Plain navigation links (config repo, forge profile,
|
||||
`dashboardLinks` extras) now live in the icon-only nav strip
|
||||
|
|
@ -538,6 +623,26 @@ needed). The joint at the row's own depth column is `├` (more
|
|||
siblings below) or `└` (last sibling at this depth — vertical
|
||||
stops at the row's icon midline).
|
||||
|
||||
**Indent + lane geometry.** Each depth level shifts the row right
|
||||
by `1.8em` (the lane width). The per-depth ladders are hardcoded
|
||||
for six levels — enough for any plausible hive topology, and the
|
||||
typed `attr()` function from CSS Values 5 that would collapse
|
||||
this to one rule is still partial-support (Chromium-only as of
|
||||
2026). The `.tree-prefix` span sits absolutely positioned with
|
||||
`left: -<depth>*1.8em` so its right edge meets the row content
|
||||
(the icon) and its leftmost lane lines up with top-level rows'
|
||||
icons at `x = 0`. Each `.tree-lane` is `flex: 0 0 1.8em` so all
|
||||
lanes have equal width. Continuation bars are drawn at lane
|
||||
center (`left: 0.6em`, `border-left: 1px solid currentColor`,
|
||||
`top: 0; bottom: 0`) and extend through `.containers { gap: 0.4em }`
|
||||
into the next sibling's prefix (`bottom: -0.4em` on the prefix
|
||||
span itself) so adjacent ancestor lines visually merge into one
|
||||
unbroken vertical line. The horizontal stub at a row's own joint
|
||||
lands at the icon midline so the L/T meets the icon edge cleanly.
|
||||
When every container has `parent = null` (pre-topology state) the
|
||||
`[data-depth]` attribute is absent on every row and these rules
|
||||
are no-ops — the layout reads exactly like the legacy flat list.
|
||||
|
||||
### Selection bar
|
||||
|
||||
Per-card action buttons (`R3ST4RT` / `ST0P` / `ST4RT` / `R3BU1LD` /
|
||||
|
|
@ -650,13 +755,13 @@ not ours.
|
|||
the sentinel `[cancelled]`. Same code path as a real answer.
|
||||
- `POST /request-spawn` — queue a Spawn approval.
|
||||
- `POST /update-all` — rebuild every stale container.
|
||||
- `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry
|
||||
(#447). Refuses `Running` / terminal-state entries (in-flight
|
||||
- `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry.
|
||||
Refuses `Running` / terminal-state entries (in-flight
|
||||
rebuilds can't be safely interrupted). Always 200; body is
|
||||
`{"cancelled": true}` on a successful flip or
|
||||
`{"cancelled": false}` when the entry was not in `Queued` state.
|
||||
- `POST /api/agent/{name}/mark-all-read` — ack all pending broker
|
||||
messages for `{name}` (#559). Backfills `delivered_at` for rows
|
||||
messages for `{name}`. Backfills `delivered_at` for rows
|
||||
not yet delivered and sets `acked_at = now`. Returns
|
||||
`{ "marked": N }`. Agent name validated against
|
||||
`[a-z0-9_-]`, 1-63 chars; 400 on bad input.
|
||||
|
|
@ -705,7 +810,7 @@ not ours.
|
|||
`{ targets, body, first_fire_at_unix, interval_seconds?, description? }`.
|
||||
Agent-initiated schedules go through the approval queue instead
|
||||
(manager MCP `request_schedule_prompt`).
|
||||
- `PATCH /api/schedules/{id}` — partial edit (#474). JSON body
|
||||
- `PATCH /api/schedules/{id}` — partial edit. JSON body
|
||||
`{ body?, description?, interval_seconds?, next_fire_at_unix?,
|
||||
targets_add?, targets_remove? }`.
|
||||
Missing key = "leave alone"; explicit `null` on
|
||||
|
|
@ -723,7 +828,7 @@ not ours.
|
|||
`{ targets?: ["name", …] }` cancels just those recipients;
|
||||
absent or empty body cancels the whole schedule.
|
||||
- `POST /api/schedules/{id}/fire-now` — out-of-band manual
|
||||
pulse (#467). Fires the schedule body once immediately to
|
||||
pulse. Fires the schedule body once immediately to
|
||||
every active target. Recurring schedules: `next_fire_at_unix`
|
||||
is untouched; the regular cadence continues. One-shots: the
|
||||
schedule is consumed (cancelled) after the manual fan-out.
|
||||
|
|
@ -807,13 +912,17 @@ Three fixed-position layers frame a full-viewport terminal:
|
|||
|
||||
**Fixed-overlay header** (`<header class="agent-header">`): frosted
|
||||
glass — `backdrop-filter: blur` lets scrolled terminal rows show
|
||||
through. Three flex columns (#394 redesign):
|
||||
through. Three flex columns:
|
||||
|
||||
- **Agent icon** (`<img class="agent-icon">`): fixed-size square
|
||||
identity anchor (5em, `width: 5em; aspect-ratio: 1;
|
||||
align-self: flex-start` — capped so a tall state-row doesn't
|
||||
inflate the icon, #411). Falls back to the dimmed hyperhive mark
|
||||
on load error.
|
||||
identity anchor — `width: 5em; height: 5em` with explicit pixel
|
||||
sizing so the `<img>`'s intrinsic (large) dimensions don't push
|
||||
the parent flex container open via `align-items: stretch`-driven
|
||||
height feedback. 5em ≈ header content area (header `min-height: 6em`
|
||||
minus `2 × 0.5em` padding). `align-self: flex-start` keeps the
|
||||
icon stuck to the top so a state-row line-wrap doesn't drag it
|
||||
down with it. Falls back to the dimmed hyperhive mark on load
|
||||
error.
|
||||
- **Main column** (`.agent-header-main`): two rows.
|
||||
- Row 1 (`.agent-header-title-row`): title (`<h2 id="title">`) +
|
||||
meta-nav (`<nav id="meta-links">`). Meta-nav renders
|
||||
|
|
@ -861,8 +970,11 @@ through. Three flex columns (#394 redesign):
|
|||
(POST confirm → `POST /api/logout`; SIGINTs any in-flight turn,
|
||||
wipes OAuth credential files, flips the agent to `needs_login`
|
||||
— session history preserved). All destructive actions require
|
||||
one extra click to acknowledge (#394 — rare ops shouldn't live
|
||||
in the primary state strip).
|
||||
one extra click to acknowledge — rare ops shouldn't live in the
|
||||
primary state strip. The popover's display rules are scoped to
|
||||
`:not([hidden])` so the `[hidden]` HTML attribute's UA `display:
|
||||
none` isn't overridden by the author CSS's `display: flex` —
|
||||
the popover stays hidden until JS removes the attribute.
|
||||
|
||||
`/api/state` is fetched once on cold load (+ while
|
||||
`status === 'needs_login_in_progress'`); all other updates arrive via
|
||||
|
|
@ -871,7 +983,7 @@ tooltip, and `qualified_label` — the hive-qualified agent name
|
|||
(`name@domain` form when `HYPERHIVE_HIVE_DOMAIN` is set, otherwise
|
||||
just `name`). The frontend uses `qualified_label` to set the browser
|
||||
tab title so two tabs from different hives are distinguishable; the
|
||||
header `<h2 id="title">` stays short (#589 phase A).
|
||||
header `<h2 id="title">` stays short.
|
||||
|
||||
**Main content** (`<main class="agent-main">`): fills the viewport
|
||||
and scrolls behind the fixed header + footer.
|
||||
|
|
@ -1048,14 +1160,26 @@ shaped).
|
|||
`wait_for_login` entry.
|
||||
- `GET /events/history` — replay buffer for the terminal.
|
||||
- `GET /screen` — VNC viewer page (minimal RFB-over-WebSocket
|
||||
renderer). Only accessible when `hyperhive.gui.enable = true`
|
||||
in the agent's `agent.nix`; the harness shows a 🖥 screen link
|
||||
in the state row when `gui_vnc_port` is present. Toolbar:
|
||||
`⤢ fit` CSS-downscales the canvas to the window; `⤡ match size`
|
||||
sends an RFB `SetDesktopSize` request so the server (weston)
|
||||
changes its real output resolution to the window dimensions —
|
||||
enabled once the server advertises the `ExtendedDesktopSize`
|
||||
pseudo-encoding (issue #133).
|
||||
renderer — deliberately thin, just enough to display the
|
||||
desktop + forward pointer + keyboard. A production-grade viewer
|
||||
would vendor noVNC; this file ships the minimal in-tree variant).
|
||||
Only accessible when `hyperhive.gui.enable = true` in the agent's
|
||||
`agent.nix`; the harness shows a 🖥 screen link in the state row
|
||||
when `gui_vnc_port` is present. Toolbar: `⤢ fit` CSS-downscales
|
||||
the canvas to the window via `relayoutCanvas()` setting explicit
|
||||
pixel dimensions on the canvas — *not* CSS `max-width/max-height`,
|
||||
because a flex item's automatic minimum size (`min-width: auto`
|
||||
resolves to the canvas's intrinsic framebuffer resolution) silently
|
||||
clamps `max-*` back up, making fit mode a no-op that just centred +
|
||||
clipped the oversized canvas. The fit-mode rules pin the canvas
|
||||
with `flex: none; min-width: 0; min-height: 0` so the JS-set size
|
||||
sticks. `⤡ match size` sends an RFB `SetDesktopSize` request so the
|
||||
server (weston) changes its real output resolution to the window
|
||||
dimensions; enabled once the server advertises the
|
||||
`ExtendedDesktopSize` pseudo-encoding (`-308` rect in the header).
|
||||
Fit-mode state persists in `localStorage` (`screen-fit`); default
|
||||
is on. Pointer coordinates are rescaled in `sendPointer` so clicks
|
||||
land on the right pixel regardless of CSS scale.
|
||||
- `GET /screen/ws` — raw RFB byte relay: proxies WebSocket
|
||||
frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
|
||||
Transparent to any RFB variant. VNC port comes from
|
||||
|
|
|
|||
|
|
@ -3,18 +3,16 @@
|
|||
@import "@hive/shared/base.css";
|
||||
@import "@hive/shared/terminal.css";
|
||||
|
||||
/* ─── full-screen vibec0re overhaul (issue #360) ──────────────────
|
||||
Layout shape: fixed-position frosted-glass header at top, fixed-
|
||||
position composer at bottom, full-viewport terminal in between.
|
||||
The terminal scrolls — its text passes BENEATH the floating
|
||||
header/composer with backdrop-filter blur for the frosted look.
|
||||
Inbox + loose-ends move into the side-panel flyout; header pills
|
||||
surface their counts as the only chrome they get. */
|
||||
/* ─── full-screen layout overrides ─────────────────────────────────
|
||||
The agent page mounts a full-viewport terminal under a fixed
|
||||
frosted-glass header + composer pair. See docs/web-ui.md::Per-agent
|
||||
page for the layer / pill / side-panel structure; rules below
|
||||
override the in-page defaults from @hive/shared. */
|
||||
|
||||
:root {
|
||||
/* Bumped to 6em (#394) so the agent icon can be a full-height
|
||||
square identity anchor without crowding the two-row main column
|
||||
(title + nav-links on top, state strip below). */
|
||||
/* 6em min-height accommodates the full-height agent icon (5em +
|
||||
padding) alongside the two-row main column (title + nav-links
|
||||
on top, state strip below). */
|
||||
--agent-header-h: 6em;
|
||||
--agent-composer-h: 3.6em;
|
||||
--agent-frost-bg: rgba(30, 30, 46, 0.72);
|
||||
|
|
@ -86,7 +84,7 @@ body.agent-shell {
|
|||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Main column: title row on top, state strip below (#394). Centred
|
||||
/* Main column: title row on top, state strip below. Centred
|
||||
vertically against the full-height icon on the left. */
|
||||
.agent-header-main {
|
||||
display: flex;
|
||||
|
|
@ -140,12 +138,10 @@ h2, h3 {
|
|||
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
|
||||
}
|
||||
.agent-icon {
|
||||
/* Square identity anchor (#394 — mara's spec). Explicit em sizing
|
||||
so the <img>'s intrinsic (large) dimensions don't push the
|
||||
parent flex container open via `align-items: stretch`-driven
|
||||
height feedback. Width = header content area (header min-h 6em
|
||||
- 2 × 0.5em padding ≈ 5em). Sticks to the top so a state-row
|
||||
wrap doesn't drag the icon down with it. (#411) */
|
||||
/* Square identity anchor — explicit 5em sizing + align-self.
|
||||
See docs/web-ui.md::Per-agent page (Agent icon) for the
|
||||
intrinsic-dim-pushes-parent-flex-open + sticks-to-top
|
||||
rationale. */
|
||||
width: 5em;
|
||||
height: 5em;
|
||||
flex-shrink: 0;
|
||||
|
|
@ -156,9 +152,9 @@ h2, h3 {
|
|||
}
|
||||
|
||||
/* Meta-nav links (stats / screen / forge / dashboard / extras) —
|
||||
no underline (#394 mara's spec); hover lights with cyan glow +
|
||||
subtle background tint. Reads as a row of soft tabs rather than
|
||||
default-styled inline anchors. */
|
||||
no underline; hover lights with cyan glow + subtle background
|
||||
tint. Reads as a row of soft tabs rather than default-styled
|
||||
inline anchors. */
|
||||
.agent-nav-link {
|
||||
color: var(--cyan);
|
||||
text-decoration: none;
|
||||
|
|
@ -176,7 +172,7 @@ h2, h3 {
|
|||
}
|
||||
|
||||
/* Overflow menu trigger — `⋯` round button on the right of the
|
||||
pills row. Quiet by default, lights on hover / open (#394). */
|
||||
pills row. Quiet by default, lights on hover / open. */
|
||||
.overflow-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--purple-dim);
|
||||
|
|
@ -200,13 +196,11 @@ h2, h3 {
|
|||
box-shadow: 0 0 10px -2px var(--purple);
|
||||
}
|
||||
|
||||
/* Overflow popover — rebuild + new-session (and the dashboard
|
||||
back-link, prepended in app.js setHeader). Positioned in JS so
|
||||
the menu's top-right corner anchors under the trigger button.
|
||||
`:not([hidden])` scoping (#411): the `[hidden]` HTML attribute
|
||||
sets `display: none` via the UA stylesheet, but author CSS's
|
||||
`display: flex` would override that. Scope display rules so
|
||||
they apply only when the menu is unhidden. */
|
||||
/* Overflow popover — rebuild + new-session + logout (and the
|
||||
dashboard back-link, prepended in app.js setHeader). Positioned
|
||||
in JS so the menu's top-right corner anchors under the trigger
|
||||
button. See docs/web-ui.md::Per-agent page (Overflow button)
|
||||
for the `:not([hidden])` scoping rationale. */
|
||||
.overflow-menu:not([hidden]) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -395,14 +389,12 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px rgba(137, 220, 235, 0.9); }
|
|||
}
|
||||
.btn-login { 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; }
|
||||
/* `.btn-rebuild` was the per-agent header chip — moved into the
|
||||
overflow menu in #394 (`.overflow-item-rebuild` covers it now).
|
||||
The dashboard has its own `.btn-rebuild` rule for the per-row
|
||||
R3BU1LD form on the SW4RM tab; this one was specific to the
|
||||
per-agent header.
|
||||
`.btn-send` was a green send-button variant — orphaned since
|
||||
the dashboard's compose form was retired; no live consumer left
|
||||
in either the agent or dashboard tree. */
|
||||
/* Orphaned rules — left here as a tombstone so a search for the
|
||||
class name finds them. Live consumers gone:
|
||||
- `.btn-rebuild` was a per-agent header chip (covered now by
|
||||
`.overflow-item-rebuild` in the overflow menu).
|
||||
- `.btn-send` was a green send-button variant — the dashboard's
|
||||
compose form that used it is retired. */
|
||||
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
||||
.sendform input {
|
||||
font-family: inherit; font-size: 1em;
|
||||
|
|
@ -424,10 +416,10 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px rgba(137, 220, 235, 0.9); }
|
|||
}
|
||||
.loginform input:focus { outline: 1px solid var(--purple); }
|
||||
|
||||
/* #568: show / hide toggle for the masked OAuth-code input. Quiet
|
||||
by default (muted border + transparent bg), lights amber on
|
||||
hover / when pressed (aria-pressed="true") so the operator
|
||||
sees at a glance whether the code is currently visible. */
|
||||
/* Show / hide toggle for the masked OAuth-code input. Quiet by
|
||||
default (muted border + transparent bg), lights amber on hover
|
||||
/ when pressed (`aria-pressed="true"`) so the operator sees at
|
||||
a glance whether the code is currently visible. */
|
||||
.loginform-reveal {
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
|
|
@ -489,7 +481,7 @@ pre.diff {
|
|||
/* Inbox / loose-ends rows: header (from / sep / ts) on one line,
|
||||
body on its own line below — gives the body the full panel width
|
||||
instead of squeezing it into a fourth grid column that wrapped
|
||||
long messages over many narrow lines (issue #376). */
|
||||
long messages over many narrow lines. */
|
||||
.agent-inbox li {
|
||||
padding: 0.4em 0;
|
||||
display: block;
|
||||
|
|
@ -552,15 +544,16 @@ pre.diff {
|
|||
.agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; }
|
||||
.agent-inbox .answer-status { color: var(--muted); align-self: center; }
|
||||
|
||||
/* #666: inline answer slot mounted under each `ask → operator` row
|
||||
in the live terminal stream. Mirrors the side-panel `.answer-form`
|
||||
/* Inline answer slot mounted under each `ask → operator` row in
|
||||
the live terminal stream. Mirrors the side-panel `.answer-form`
|
||||
look-and-feel — same textarea, same send button — so the operator
|
||||
doesn't have to context-switch between "answering in the panel" and
|
||||
"answering inline". Empty slot collapses to nothing (no margin) so
|
||||
pre-bind rows stay tidy; populated slot gets a thin top divider to
|
||||
separate the question body from the form. Resolved tag (the
|
||||
struck-through `[answered ✓]`) replaces the form once the
|
||||
question's been answered. */
|
||||
doesn't have to context-switch between "answering in the panel"
|
||||
and "answering inline". Empty slot collapses to nothing (no
|
||||
margin) so pre-bind rows stay tidy; populated slot gets a thin
|
||||
top divider to separate the question body from the form. A
|
||||
`[resolved]` tag (neutral, covers answered / cancelled /
|
||||
TTL-expired uniformly) replaces the form once the question
|
||||
leaves the pending list. */
|
||||
.live .ask-answer-inline-slot:empty { display: none; }
|
||||
.live .ask-answer-inline-slot {
|
||||
margin-top: 0.5em;
|
||||
|
|
@ -613,10 +606,10 @@ pre.diff {
|
|||
text-decoration-color: var(--muted);
|
||||
}
|
||||
|
||||
/* #559: "mark all read" header row sits above the recent-messages
|
||||
list in the inbox side-panel flyout. Same look as the answer-form
|
||||
button (mauve hover, bg-elev background) so they read as part of
|
||||
the same affordance family. */
|
||||
/* "mark all read" header row sits above the recent-messages list
|
||||
in the inbox side-panel flyout. Same look as the answer-form
|
||||
button (mauve hover, bg-elev background) so they read as part
|
||||
of the same affordance family. */
|
||||
.agent-inbox .inbox-mark-all-row {
|
||||
display: flex;
|
||||
gap: 0.6em;
|
||||
|
|
@ -697,8 +690,9 @@ pre.diff {
|
|||
text-shadow: 0 0 6px rgba(243, 139, 168, 0.55); }
|
||||
.status-badge.status-needs-login { color: var(--amber); border-color: var(--amber); }
|
||||
.status-badge.status-offline { color: var(--muted); border-color: var(--muted); }
|
||||
/* Orphaned in #394 — `.btn-dashlink` chip beside the title moved
|
||||
into the overflow menu (`.overflow-item-dashboard` covers it). */
|
||||
/* Orphaned tombstone — `.btn-dashlink` chip that lived beside the
|
||||
title was moved into the overflow menu (`.overflow-item-dashboard`
|
||||
covers it now). */
|
||||
.btn-cancel-turn {
|
||||
font-family: inherit;
|
||||
font-size: 0.8em;
|
||||
|
|
@ -716,8 +710,8 @@ pre.diff {
|
|||
background: rgba(243, 139, 168, 0.1);
|
||||
box-shadow: 0 0 10px -2px currentColor;
|
||||
}
|
||||
/* Orphaned in #394 — `.btn-new-session` round-pill moved into the
|
||||
overflow menu (`.overflow-item-new-session` covers it; the
|
||||
/* Orphaned tombstone — `.btn-new-session` round-pill moved into
|
||||
the overflow menu (`.overflow-item-new-session` covers it; the
|
||||
`:disabled` opacity treatment lives on the shared
|
||||
`.overflow-item:disabled` rule). */
|
||||
.state-badge {
|
||||
|
|
@ -793,7 +787,10 @@ pre.diff {
|
|||
/* Tail pill (↓ N new): nudged up so it floats clear of the composer
|
||||
rather than colliding with the frosted bar. z-index bumped above
|
||||
the composer (z-30) so the pill sits on the top layer instead of
|
||||
being clipped by the floating chrome (issue #375). */
|
||||
being clipped by the floating chrome. (Pill is anchored in
|
||||
`.agent-main` rather than `.terminal-wrap` — see
|
||||
docs/web-ui.md::Per-agent page Terminal-wrap paragraph for the
|
||||
backdrop-filter stacking-context rationale.) */
|
||||
.agent-main .tail-pill {
|
||||
bottom: calc(var(--agent-composer-h) + 0.6em);
|
||||
z-index: 35;
|
||||
|
|
@ -843,10 +840,9 @@ pre.diff {
|
|||
/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */
|
||||
|
||||
/* ─── side panel (singleton drawer) ────────────────────────────────
|
||||
Inbox + loose-ends details open here instead of expanding inline
|
||||
(issue #360). Copy of the dashboard's side-panel pattern —
|
||||
candidate for extraction into @hive/shared once both surfaces
|
||||
stabilize. */
|
||||
Inbox + loose-ends details open here instead of expanding inline.
|
||||
Copy of the dashboard's side-panel pattern — candidate for
|
||||
extraction into @hive/shared once both surfaces stabilize. */
|
||||
.side-panel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
|
|||
|
|
@ -79,8 +79,7 @@ window.marked = marked;
|
|||
|
||||
// ─── side panel (singleton drawer for inbox + loose-ends flyouts) ──────
|
||||
// Shared shape with the dashboard's panel. Candidate for extraction
|
||||
// into @hive/shared in a follow-up — keeping the duplication for
|
||||
// now to land #360 without simultaneously refactoring the dashboard.
|
||||
// into @hive/shared once both surfaces stabilise.
|
||||
const Panel = (() => {
|
||||
const root = $('side-panel');
|
||||
const titleEl = $('side-panel-title');
|
||||
|
|
@ -141,13 +140,12 @@ window.marked = marked;
|
|||
// ─── state rendering ────────────────────────────────────────────────────
|
||||
function setHeader(label, qualifiedLabel, dashboardPort) {
|
||||
const title = $('title');
|
||||
// Title is now just the glowing identity glyph — DASHB04RD,
|
||||
// R3BU1LD, NEW SESSION all live in the overflow `⋯` menu now
|
||||
// (#394). Glow + uppercase styling from h2 / .agent-header-title-row.
|
||||
// The glyphic title stays short (no @hive suffix) — the hive
|
||||
// qualifier lives on the second row's `qualified` chip + the
|
||||
// browser tab title so the cinematic header reads cleanly at a
|
||||
// glance (#589).
|
||||
// Title is just the glowing identity glyph — DASHB04RD, R3BU1LD,
|
||||
// NEW SESSION live in the overflow `⋯` menu. Glow + uppercase
|
||||
// styling from h2 / .agent-header-title-row. The glyphic title
|
||||
// stays short (no @hive suffix) — the hive qualifier lives on
|
||||
// the second row's `qualified` chip + the browser tab title so
|
||||
// the cinematic header reads cleanly at a glance.
|
||||
title.textContent = `◆ ${label} ◆`;
|
||||
// Document title carries the qualified name so the browser's tab
|
||||
// bar disambiguates between same-named agents on different hives
|
||||
|
|
@ -160,20 +158,17 @@ window.marked = marked;
|
|||
populateOverflowMenu(label, dashUrl);
|
||||
}
|
||||
|
||||
// Overflow popover: dashboard back-link + rebuild + new-session.
|
||||
// Per #394 mara's spec — rebuild + new-session both moved off the
|
||||
// header strip into the `⋯` menu (rare actions, both destructive
|
||||
// enough to warrant one extra click; the operator rebuilds from
|
||||
// the host dashboard normally). Dashboard link also slotted in so
|
||||
// every "leave this page" action lives in one menu.
|
||||
// Overflow popover: dashboard back-link + rebuild + new-session +
|
||||
// logout. Rare + destructive actions live here behind one extra
|
||||
// click (the operator rebuilds from the host dashboard normally).
|
||||
// See docs/web-ui.md::Per-agent page (Overflow button).
|
||||
let overflowMenuPopulated = false;
|
||||
function populateOverflowMenu(label, dashUrl) {
|
||||
const menu = $('overflow-menu');
|
||||
if (!menu) return;
|
||||
menu.replaceChildren();
|
||||
|
||||
// ↑ dashboard — host dashboard back-link (was `.btn-dashlink`
|
||||
// beside the title pre-#394).
|
||||
// ↑ dashboard — host dashboard back-link.
|
||||
menu.append(el('a', {
|
||||
class: 'overflow-item overflow-item-dashboard',
|
||||
href: dashUrl,
|
||||
|
|
@ -227,15 +222,14 @@ window.marked = marked;
|
|||
});
|
||||
menu.append(newSessBtn);
|
||||
|
||||
// 🔓 logout (#576) — SIGINTs claude, wipes the credentials dir,
|
||||
// flips the harness LoginState to NeedsLogin. The turn loop's
|
||||
// next iteration parks in wait_for_login; a fresh `claude auth
|
||||
// login` from the dashboard re-arms it (#542 mtime resumption).
|
||||
// Operator has to re-paste OAuth creds on the login screen after,
|
||||
// but the --continue session history is preserved (#584 narrowed
|
||||
// the backend wipe to just .credentials.json + mcp-needs-auth-
|
||||
// cache.json) — so the agent picks up where it left off on the
|
||||
// next turn after re-login.
|
||||
// 🔓 logout — SIGINTs claude, wipes the credentials dir, flips
|
||||
// the harness LoginState to NeedsLogin. The turn loop's next
|
||||
// iteration parks in wait_for_login; a fresh `claude auth login`
|
||||
// from the dashboard re-arms it. Operator has to re-paste OAuth
|
||||
// creds on the login screen after, but the --continue session
|
||||
// history is preserved (the backend wipe is narrowed to just
|
||||
// .credentials.json + mcp-needs-auth-cache.json) — so the agent
|
||||
// picks up where it left off on the next turn after re-login.
|
||||
const logoutBtn = el('button', {
|
||||
type: 'button',
|
||||
class: 'overflow-item overflow-item-logout',
|
||||
|
|
@ -464,8 +458,9 @@ window.marked = marked;
|
|||
if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err);
|
||||
}
|
||||
}
|
||||
// First arg is the URL path (relative to document base, #14);
|
||||
// second is the slash-command label rendered in the local note.
|
||||
// First arg is the URL path (relative to document base — see
|
||||
// docs/web-ui.md::Per-agent relative paths); second is the
|
||||
// slash-command label rendered in the local note.
|
||||
const postCancelTurn = () => postSimple('api/cancel', '/cancel');
|
||||
const postCompact = () => postSimple('api/compact', '/compact');
|
||||
const postNewSession = () => postSimple('api/new-session', '/new-session');
|
||||
|
|
@ -485,7 +480,7 @@ window.marked = marked;
|
|||
return true;
|
||||
case '/clear':
|
||||
termAPI.clear();
|
||||
// #666: detached `ask → operator` rows no longer have a live
|
||||
// Detached `ask → operator` rows no longer have a live
|
||||
// mount-point in the DOM — drop their slots so subsequent
|
||||
// loose-ends reconciliation doesn't walk dead references.
|
||||
pendingAskBinds.length = 0;
|
||||
|
|
@ -837,12 +832,11 @@ window.marked = marked;
|
|||
return wrap;
|
||||
}
|
||||
|
||||
/** #559: "mark all read" affordance for the agent's inbox flyout.
|
||||
* Returns a DOM row containing a button + an inline status pill.
|
||||
* POSTs to the host dashboard's `/api/agent/{name}/mark-all-read`
|
||||
* (damocles PR #566) and surfaces the `{ marked: N }` count back to
|
||||
* the operator. Re-runs `onCleared` on success so the caller can
|
||||
* refresh whatever state it owns. */
|
||||
/** "mark all read" affordance for the agent's inbox flyout —
|
||||
* see docs/web-ui.md::Per-agent page (Loose-ends flyout) for the
|
||||
* cross-origin POST + count rendering. Returns a DOM row
|
||||
* containing a button + an inline status pill; re-runs
|
||||
* `onCleared` on success so the caller can refresh its own state. */
|
||||
function buildInboxMarkAllRow(label, onCleared) {
|
||||
const status = el('span', { class: 'inbox-mark-status' });
|
||||
const btn = el('button', {
|
||||
|
|
@ -896,12 +890,12 @@ window.marked = marked;
|
|||
'inbox empty.'));
|
||||
return wrap;
|
||||
}
|
||||
// #559: "mark all read" header row drains the host broker's
|
||||
// pending + delivered-unacked rows for this agent (damocles PR
|
||||
// #566). Visible rows here are the most-recent-N regardless of
|
||||
// ack state, so the list itself doesn't visually empty on click —
|
||||
// the status pill confirms the drain count, and the next
|
||||
// turn_start's "unread" badge will read zero.
|
||||
// "mark all read" header row drains the host broker's pending +
|
||||
// delivered-unacked rows for this agent. Visible rows here are
|
||||
// the most-recent-N regardless of ack state, so the list itself
|
||||
// doesn't visually empty on click — the status pill confirms
|
||||
// the drain count, and the next turn_start's "unread" badge
|
||||
// will read zero.
|
||||
wrap.append(buildInboxMarkAllRow(currentLabel, () => {
|
||||
// Refresh state so any UI surface that DOES depend on
|
||||
// delivery state (eg future per-status filters) picks up
|
||||
|
|
@ -1029,10 +1023,6 @@ window.marked = marked;
|
|||
});
|
||||
})();
|
||||
|
||||
// (#394) — `↻ new session` button moved into the overflow `⋯`
|
||||
// menu and wired by `populateOverflowMenu()` above. Previously
|
||||
// wired here as a static `#new-session-btn` in index.html.
|
||||
|
||||
// Track banner activity by reference-counting in-flight turns. A turn
|
||||
// can begin while the previous turn_end is still in the pipeline (rare
|
||||
// but happens on tight wake cycles), so we count rather than toggle.
|
||||
|
|
@ -1074,9 +1064,8 @@ window.marked = marked;
|
|||
rel: 'noopener',
|
||||
title: lnk.label || '',
|
||||
});
|
||||
// Layout gap comes from `.agent-nav { gap }` (#394) — drop
|
||||
// the legacy per-link inline `marginLeft`. The trailing `→`
|
||||
// is the "leaves this page" affordance.
|
||||
// Layout gap comes from `.agent-nav { gap }`. The trailing
|
||||
// `→` is the "leaves this page" affordance.
|
||||
a.append(((lnk.icon || '') + ' ' + (lnk.label || '')).trim() + ' →');
|
||||
metaLinks.append(a);
|
||||
});
|
||||
|
|
@ -1154,7 +1143,7 @@ window.marked = marked;
|
|||
marked.setOptions({ breaks: true, gfm: true });
|
||||
div.innerHTML = marked.parse(src);
|
||||
// marked autolinks URLs but leaves them same-tab — open them
|
||||
// externally so a click never unloads the terminal. (issue #233)
|
||||
// externally so a click never unloads the terminal.
|
||||
div.querySelectorAll('a[href]').forEach((a) => {
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
|
|
@ -1306,15 +1295,10 @@ window.marked = marked;
|
|||
const d = detailsOpenMd(api, 'tool-use',
|
||||
'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
|
||||
q);
|
||||
// #666: when the ask targets the operator, mount an inline
|
||||
// answer slot in the live terminal so the operator doesn't
|
||||
// need to open the loose-ends side panel (or jump to Y3R C4LL
|
||||
// on the dashboard) to respond. The slot starts empty and
|
||||
// gets populated by `reconcileAskBinds()` once the
|
||||
// loose-ends fetch identifies a matching pending question
|
||||
// (by asker == this agent + question text). Resolved
|
||||
// questions render as a struck-through [answered ✓] tag
|
||||
// instead of a form.
|
||||
// When the ask targets the operator, mount an inline answer
|
||||
// slot in the live terminal — see docs/web-ui.md::Per-agent
|
||||
// page (Ask → operator inline-answer binding) for the slot
|
||||
// registry + reconciler + [resolved] semantics.
|
||||
if (to === 'operator') {
|
||||
const slot = el('div', { class: 'ask-answer-inline-slot' });
|
||||
// Stash the question text on the slot so the reconciler
|
||||
|
|
@ -1356,12 +1340,13 @@ window.marked = marked;
|
|||
: (c.content || '');
|
||||
const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null;
|
||||
const isMessageBearing = sourceName === 'mcp__hyperhive__recv';
|
||||
// #666: when an ask's tool_result lands the broker has just
|
||||
// persisted the question with its assigned id. Refresh loose
|
||||
// ends so `reconcileAskBinds` finds the new entry and mounts
|
||||
// the inline answer form under the rendered ask row. Skipped
|
||||
// during history replay (the question's likely long-resolved;
|
||||
// turn_end refresh on cold-load already covers reconciliation).
|
||||
// When an ask's tool_result lands the broker has just
|
||||
// persisted the question with its assigned id. Refresh
|
||||
// loose-ends so reconcileAskBinds finds the new entry and
|
||||
// mounts the inline answer form under the rendered ask row.
|
||||
// Skipped during history replay (the question's likely
|
||||
// long-resolved; turn_end refresh on cold-load covers
|
||||
// reconciliation).
|
||||
if (sourceName === 'mcp__hyperhive__ask' && !api.fromHistory) {
|
||||
refreshLooseEnds();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,16 +8,10 @@
|
|||
</head>
|
||||
<body class="agent-shell">
|
||||
|
||||
<!-- Fixed-overlay header (#394 redesign): two-row layout in the
|
||||
main column — row 1 carries the title + meta-nav, row 2 carries
|
||||
the live state strip. The agent icon eats the full header
|
||||
height on the left as the identity anchor; flyout pills + an
|
||||
overflow menu trigger sit on the right. Frosted glass over the
|
||||
terminal — backdrop-filter blur shows the scrolled terminal
|
||||
text behind.
|
||||
|
||||
All asset / API hrefs are relative — see docs/web-ui.md::Per-agent
|
||||
relative paths for why (#14). -->
|
||||
<!-- Fixed-overlay header — see docs/web-ui.md::Per-agent page
|
||||
for the three-column layout (icon · main · pills). All asset
|
||||
/ API hrefs are relative — see docs/web-ui.md::Per-agent
|
||||
relative paths for the document-baseURI resolution model. -->
|
||||
<header class="agent-header" id="agent-header">
|
||||
<img class="agent-icon" src="icon" alt="">
|
||||
|
||||
|
|
@ -44,9 +38,9 @@
|
|||
|
||||
<!-- Right cluster: flyout triggers + overflow menu. Pills stay
|
||||
hidden until their list is non-empty; the overflow `⋯` is
|
||||
always visible (rebuild + new-session live inside it per
|
||||
#394 — both rare, both destructive, both deserve one extra
|
||||
click). -->
|
||||
always visible. Rebuild + new-session + logout live inside
|
||||
it — see docs/web-ui.md::Per-agent page (Overflow button)
|
||||
for the rare-destructive-extra-click rationale. -->
|
||||
<div class="agent-header-pills">
|
||||
<button type="button" id="inbox-pill" class="header-pill header-pill-inbox" hidden
|
||||
title="open inbox flyout">
|
||||
|
|
|
|||
|
|
@ -62,13 +62,10 @@ html, body { height: 100%; background: var(--base); color: var(--text);
|
|||
fit the wrap) and clip any sub-pixel rounding overflow. */
|
||||
#canvas-wrap.fit { align-items: center; overflow: hidden; }
|
||||
canvas { display: block; cursor: default; }
|
||||
/* In fit mode relayoutCanvas() sets the canvas display size explicitly.
|
||||
The canvas is a flex item, and flex items default to
|
||||
min-width/min-height: auto — which resolves to the canvas's intrinsic
|
||||
framebuffer resolution and clamps the JS-set size straight back up,
|
||||
defeating the downscale (the bug behind #133 round 1). Pin the canvas
|
||||
to exactly the size relayoutCanvas() sets: min-* 0 lifts the clamp,
|
||||
flex: none stops flex grow/shrink from fighting it. */
|
||||
/* Pin the canvas to exactly the size relayoutCanvas() sets. See
|
||||
docs/web-ui.md::Per-agent endpoints (GET /screen) for the
|
||||
flex-item min-width:auto clamp that made fit mode a silent
|
||||
no-op before this pinning. */
|
||||
#canvas-wrap.fit canvas { flex: none; min-width: 0; min-height: 0; }
|
||||
#msg {
|
||||
position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
|
||||
|
|
@ -93,13 +90,11 @@ canvas { display: block; cursor: default; }
|
|||
<div id="debug-log"></div>
|
||||
|
||||
<script>
|
||||
// Minimal RFB-over-WebSocket renderer.
|
||||
// Connects to /screen/ws on the same host; the harness relays raw
|
||||
// RFB bytes to the VNC server running inside the container.
|
||||
//
|
||||
// This is a deliberately thin implementation — enough to display the
|
||||
// desktop and forward pointer + keyboard events. For a production-grade
|
||||
// viewer, replace with noVNC (issue #52 vendors the full bundle).
|
||||
// Minimal RFB-over-WebSocket renderer. Connects to `screen/ws` on
|
||||
// the same host; the harness relays raw RFB bytes to the VNC server
|
||||
// running inside the container. See docs/web-ui.md::Per-agent
|
||||
// endpoints (GET /screen) for the design rationale + canvas-sizing
|
||||
// implementation notes.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
|
@ -123,18 +118,13 @@ canvas { display: block; cursor: default; }
|
|||
});
|
||||
|
||||
// --- Fit-to-window toggle ---
|
||||
// Scales the canvas down so the whole desktop is visible without
|
||||
// scrolling. The canvas's intrinsic resolution (width/height attrs)
|
||||
// is untouched — only its CSS display size changes, set explicitly
|
||||
// by relayoutCanvas(). Pointer coordinates are rescaled in
|
||||
// sendPointer to stay accurate. Persisted in localStorage; default
|
||||
// is fit-on.
|
||||
// See docs/web-ui.md::Per-agent endpoints (GET /screen) for the
|
||||
// localStorage persistence + canvas-intrinsic-resolution +
|
||||
// pointer-rescale model.
|
||||
let fitMode = localStorage.getItem('screen-fit') !== 'off';
|
||||
// Size the canvas. In fit mode, scale down (never up) to the wrap,
|
||||
// preserving aspect ratio. Explicit px sizing rather than CSS
|
||||
// max-width/max-height: on a flex item those are overridden by the
|
||||
// automatic minimum size, so fit mode was a silent no-op — the
|
||||
// oversized canvas just got centred and clipped (issue #133).
|
||||
// Scale down (never up) to the wrap, preserving aspect ratio.
|
||||
// Explicit px sizing — see GET /screen docs for the flex-item
|
||||
// min-width:auto clamp this avoids.
|
||||
function relayoutCanvas() {
|
||||
if (fitMode && canvas.width && canvas.height
|
||||
&& canvasWrap.clientWidth && canvasWrap.clientHeight) {
|
||||
|
|
@ -166,9 +156,9 @@ canvas { display: block; cursor: default; }
|
|||
// --- Match-size: resize the remote desktop to this window ---
|
||||
// Sends an RFB SetDesktopSize request so the VNC server (weston)
|
||||
// changes its actual output resolution to match the browser
|
||||
// viewport — sharper than fit-mode's CSS downscale. The button is
|
||||
// enabled only once the server has advertised the ExtendedDesktopSize
|
||||
// pseudo-encoding (a -308 rect). (issue #133)
|
||||
// viewport — sharper than fit-mode's CSS downscale. Gated on the
|
||||
// server's ExtendedDesktopSize advert; see docs/web-ui.md::Per-agent
|
||||
// endpoints (GET /screen).
|
||||
let extDesktopSupported = false;
|
||||
let screenId = 1; // captured from the server's ExtendedDesktopSize advert
|
||||
matchBtn.addEventListener('click', () => {
|
||||
|
|
@ -206,10 +196,10 @@ canvas { display: block; cursor: default; }
|
|||
}
|
||||
|
||||
// --- WebSocket connection ---
|
||||
// Path-relative so the agent page mounted under a prefix
|
||||
// (e.g. /agent/<name>/screen via nginx, #14) still hits the right
|
||||
// upstream. `document.baseURI` resolves against the page's URL;
|
||||
// swapping protocol on top gives ws(s)://host/<prefix>/screen/ws.
|
||||
// Path-relative URL — `document.baseURI` resolves against the
|
||||
// page's URL so the agent page mounted under a nginx prefix
|
||||
// (e.g. /agent/<name>/screen) still hits the right upstream.
|
||||
// Swap protocol on top gives ws(s)://host/<prefix>/screen/ws.
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsUrl = new URL('screen/ws', document.baseURI);
|
||||
wsUrl.protocol = proto + ':';
|
||||
|
|
@ -573,8 +563,8 @@ canvas { display: block; cursor: default; }
|
|||
canvas.height = fbH;
|
||||
relayoutCanvas();
|
||||
setStatus('connected', 'connected');
|
||||
// Advertise Raw + the ExtendedDesktopSize pseudo-encoding so the
|
||||
// server reports (and accepts) desktop-size changes. (issue #133)
|
||||
// Advertise Raw + the ExtendedDesktopSize pseudo-encoding so
|
||||
// the server reports (and accepts) desktop-size changes.
|
||||
sendSetEncodings([0, -308]);
|
||||
// Request full framebuffer update
|
||||
requestUpdate(0, 0, 0, fbW, fbH);
|
||||
|
|
@ -618,7 +608,7 @@ canvas { display: block; cursor: default; }
|
|||
} else if (enc === EXT_DESKTOP_SIZE_U32) {
|
||||
// ExtendedDesktopSize: w,h carry the new desktop dimensions;
|
||||
// the rect body is nScreens(1) + pad(3) + nScreens×16. The
|
||||
// header's x = change reason, y = request status. (issue #133)
|
||||
// header's x = change reason, y = request status.
|
||||
const nScreens = peekByte();
|
||||
if (nScreens < 0) { chunks.unshift(b); totalBytes += 12; return false; }
|
||||
const body = drainTo(4 + nScreens * 16);
|
||||
|
|
@ -693,7 +683,7 @@ canvas { display: block; cursor: default; }
|
|||
}
|
||||
|
||||
// SetDesktopSize (msg type 251): ask the server to change the desktop
|
||||
// resolution. One screen at the origin, sized to the request. (#133)
|
||||
// resolution. One screen at the origin, sized to the request.
|
||||
function sendSetDesktopSize(w, h) {
|
||||
const b = new Uint8Array(24);
|
||||
b[0] = 251; b[1] = 0; // message-type + padding
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
// Shared dashboard helpers — extracted from the original monolithic
|
||||
// dashboard JS as step 1 of the #406 split. These bits are used by
|
||||
// both the tab dashboard (index.html) and the flow page (flow.html):
|
||||
// pure DOM helpers, the side-panel singleton, the OS-notification
|
||||
// module, and the path-link / file-preview infrastructure for the
|
||||
// side panel.
|
||||
//
|
||||
// Each page now has its own entry point — `./tabs.js` for index.html,
|
||||
// `./flow.js` for flow.html — and both import from here directly
|
||||
// (#406 steps 2 + 3 complete; #406 closed).
|
||||
// Shared dashboard helpers used by both index.html (./tabs.js) and
|
||||
// flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
|
||||
// the OS-notification module, and the path-link / file-preview
|
||||
// infrastructure for the side panel.
|
||||
|
||||
import { linkify as termLinkify } from '@hive/shared/terminal.js';
|
||||
|
||||
|
|
@ -57,31 +51,23 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
|
|||
// tied to its caller, so they don't generalise cleanly. We can lift
|
||||
// them when a second consumer needs the same shape.
|
||||
|
||||
// ─── shared-worker SSE pipe (#448) ──────────────────────────────────────
|
||||
// Returns an EventSource-shaped object backed by a SharedWorker that
|
||||
// holds ONE upstream `new EventSource(url)` and fans events out to
|
||||
// every connected tab. Replaces direct `new EventSource(url)` at the
|
||||
// dashboard's two consumer sites (tabs.js inline + flow.js via
|
||||
// terminal.js's `streamFactory` option) so N hyperhive tabs share
|
||||
// ONE backend connection — way under the browser's per-host
|
||||
// connection cap, immune to per-tab throttling that drops the SSE
|
||||
// when Firefox suspends background tabs.
|
||||
// ─── shared-worker SSE pipe ─────────────────────────────────────────────
|
||||
// Returns an EventSource-shaped facade backed by a SharedWorker that
|
||||
// holds one upstream `new EventSource(url)` and fans events out to
|
||||
// every connected tab. See docs/web-ui.md (SSE multiplexing paragraph)
|
||||
// for the design + Firefox throttling motivation; graceful fallback to
|
||||
// direct EventSource on environments without SharedWorker.
|
||||
//
|
||||
// Graceful fallback to direct EventSource on environments without
|
||||
// SharedWorker (some embedded browsers, some Safari versions). The
|
||||
// per-tab connection cost is the same as today — no regression.
|
||||
//
|
||||
// The page consumer uses the returned object like a regular
|
||||
// EventSource: assign `onmessage` / `onopen` / `onerror`. `.close()`
|
||||
// tells the worker to drop the subscription; the worker closes the
|
||||
// upstream EventSource when the last subscriber leaves.
|
||||
// Consumer API: assign `onmessage` / `onopen` / `onerror`; `.close()`
|
||||
// drops the subscription (the worker closes the upstream when the last
|
||||
// subscriber leaves).
|
||||
const SHARED_WORKER_PATH = '/static/stream-worker.js';
|
||||
const SHARED_WORKER_NAME = 'hyperhive-stream';
|
||||
|
||||
// One SharedWorker port per page, reused by all openStream calls on
|
||||
// that page. Invalidated on `pagehide` so a bfcache restore picks up
|
||||
// a fresh port (the cached one may have been collected if all other
|
||||
// tabs closed while this page was frozen — argus nit on #453).
|
||||
// a fresh port — the cached port may be dead if all other tabs
|
||||
// closed while this page was frozen.
|
||||
let _sharedPort = null;
|
||||
function makeSharedPort() {
|
||||
if (typeof SharedWorker === 'undefined') return null;
|
||||
|
|
@ -99,30 +85,11 @@ function getSharedPort() {
|
|||
return _sharedPort;
|
||||
}
|
||||
|
||||
// #515: detect when the SharedWorker has been killed. Firefox aggressively
|
||||
// reclaims "idle" SharedWorkers under memory pressure (or just on tab
|
||||
// lifecycle quirks we don't fully understand), and there's no native
|
||||
// signal to the client when that happens — `postMessage` on a dead
|
||||
// port silently no-ops, and the page just stops receiving events. The
|
||||
// observable symptom is "dashboard never refreshes; F5 fixes it" (a
|
||||
// fresh page creates a fresh worker), which is mara's report on #515.
|
||||
//
|
||||
// Pipeline:
|
||||
// - The worker pings every connected port every WORKER_PING_INTERVAL_MS.
|
||||
// - `route` (every message arrival, including pings) bumps
|
||||
// `_lastWorkerActivityAt`.
|
||||
// - `startWorkerWatchdog` polls every WORKER_WATCHDOG_INTERVAL_MS.
|
||||
// If the page is visible AND we have active subs AND we haven't
|
||||
// heard from the worker in > WORKER_DEAD_THRESHOLD_MS, we presume
|
||||
// the worker is dead, log a warning, and re-subscribe on a fresh
|
||||
// port.
|
||||
//
|
||||
// Numbers picked so a real Firefox tab-suspend / unsuspend cycle (which
|
||||
// can pause the watchdog itself) doesn't false-positive: 90s without a
|
||||
// 30s ping means at least three pings missed. Visibility-gated so a
|
||||
// backgrounded tab — where Firefox throttles setInterval to 1Hz min and
|
||||
// our healthcheck wouldn't trigger reliably anyway — doesn't try to
|
||||
// reconnect uselessly.
|
||||
// SharedWorker death detection: pings from the worker bump the
|
||||
// activity clock; a visibility-gated watchdog polls and re-subscribes
|
||||
// on a fresh port if the page has been silent past the threshold.
|
||||
// See docs/web-ui.md (Worker-death self-heal paragraph) for the
|
||||
// timing rationale + Firefox reclaim symptom.
|
||||
const WORKER_DEAD_THRESHOLD_MS = 90_000;
|
||||
const WORKER_WATCHDOG_INTERVAL_MS = 15_000;
|
||||
let _lastWorkerActivityAt = 0;
|
||||
|
|
@ -164,25 +131,19 @@ function rebindOnFreshPort() {
|
|||
noteWorkerActivity();
|
||||
}
|
||||
|
||||
// Registry of live subscriptions on this page. Keyed by url so a
|
||||
// second openStream call for the same URL (would only happen on a
|
||||
// hypothetical multi-consumer page) attaches to the existing route
|
||||
// rather than overlapping. Each entry caches the route function so
|
||||
// bfcache-restore re-bind can re-attach it to the fresh port.
|
||||
//
|
||||
// Today's pages only call openStream once with one URL; the registry
|
||||
// shape just keeps the bfcache-restore path correct if that changes
|
||||
// (e.g. /index.html later subscribing to two streams).
|
||||
// Registry of live subscriptions on this page. Keyed by url; entries
|
||||
// cache the route function so bfcache-restore re-bind can re-attach
|
||||
// it to the fresh port. Today's pages only call openStream once with
|
||||
// one URL; the registry shape just keeps the bfcache path correct
|
||||
// if that changes.
|
||||
const _activeSubs = new Map();
|
||||
|
||||
// One-shot wiring of the page-wide lifecycle hooks: on bfcache
|
||||
// freeze (`pagehide { persisted: true }`) we unsubscribe so the
|
||||
// worker can close the upstream when the last live subscriber
|
||||
// leaves; on bfcache restore (`pageshow { persisted: true }`) we
|
||||
// invalidate the cached port (it may be dead if all other tabs
|
||||
// closed during the freeze) and re-attach every active subscription
|
||||
// to a fresh port. argus nit on #453: without this, the consumer's
|
||||
// onmessage stays bound but no events flow after a bfcache restore.
|
||||
// One-shot wiring of page-wide lifecycle hooks. On bfcache freeze
|
||||
// we unsubscribe so the worker can close the upstream when the last
|
||||
// live subscriber leaves; on bfcache restore we invalidate the cached
|
||||
// port (may be dead after the freeze) and re-attach every active
|
||||
// subscription to a fresh port. Without this, the consumer's
|
||||
// onmessage stays bound but no events flow after restore.
|
||||
let _lifecycleBound = false;
|
||||
function bindLifecycleOnce() {
|
||||
if (_lifecycleBound) return;
|
||||
|
|
@ -245,7 +206,7 @@ export function openStream(url) {
|
|||
},
|
||||
};
|
||||
const route = (e) => {
|
||||
// #515: any message from the worker is proof of life — note it
|
||||
// Any message from the worker is proof of life — note it
|
||||
// before the URL filter, since heartbeat pings carry no URL.
|
||||
noteWorkerActivity();
|
||||
const m = e.data;
|
||||
|
|
@ -272,8 +233,8 @@ export function openStream(url) {
|
|||
_activeSubs.set(url, { target, route });
|
||||
port.addEventListener('message', route);
|
||||
port.postMessage({ kind: 'subscribe', url });
|
||||
// #515: seed the activity clock so the watchdog has a baseline; it
|
||||
// would otherwise compare against 0 (epoch) and trigger immediately.
|
||||
// Seed the activity clock so the watchdog has a baseline (would
|
||||
// otherwise compare against 0 and trigger immediately).
|
||||
noteWorkerActivity();
|
||||
return target;
|
||||
}
|
||||
|
|
@ -330,13 +291,10 @@ export const Panel = (() => {
|
|||
root.classList.remove('open');
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
// #451: drag-to-resize the drawer's width. Listens on a thin
|
||||
// hit-strip glued to the drawer's left edge; mousedown captures
|
||||
// pointermove + pointerup on the document so the drag continues
|
||||
// even if the cursor strays outside the 6px handle band. Width
|
||||
// persists to localStorage so it survives page reload. The CSS
|
||||
// clamps the value (min-width: 320px, max-width: 96vw) — drop
|
||||
// unparseable / out-of-range stored values silently.
|
||||
// Drag-to-resize the drawer's width. See docs/web-ui.md::Side panel
|
||||
// for the hit-strip + pointer-capture + localStorage persistence
|
||||
// model; CSS clamps the stored value to min 320px / max 96vw and
|
||||
// out-of-range stored values are dropped silently.
|
||||
const WIDTH_KEY = 'hyperhive:side-panel-width';
|
||||
const WIDTH_MIN = 320;
|
||||
function clampWidth(w) {
|
||||
|
|
@ -476,7 +434,7 @@ function mdNode(text) {
|
|||
window.marked.setOptions({ breaks: true, gfm: true });
|
||||
div.innerHTML = window.marked.parse(text);
|
||||
// marked autolinks URLs but leaves them same-tab — open externally
|
||||
// so a click never navigates away from the dashboard. (issue #233)
|
||||
// so a click never navigates away from the dashboard.
|
||||
div.querySelectorAll('a[href]').forEach((a) => {
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
|
|
|
|||
|
|
@ -3,19 +3,19 @@
|
|||
@import "@hive/shared/base.css";
|
||||
@import "@hive/shared/terminal.css";
|
||||
|
||||
/* ─── tabbed dashboard chrome (#369) ────────────────────────────────
|
||||
Top-of-page sticky header with banner + tab strip. Tab routing is
|
||||
hash-based; tab panes are show/hide via the `.tab-pane-active`
|
||||
class. SSE stays alive across tab switches so count pills update
|
||||
live on inactive tabs without losing pulse on what's happening
|
||||
elsewhere. */
|
||||
/* ─── tabbed dashboard chrome ──────────────────────────────────────
|
||||
Top-of-page sticky header with banner + tab strip. SSE stays
|
||||
alive across tab switches so count pills update live on inactive
|
||||
tabs. See docs/web-ui.md::Chrome header + Tab strip for the
|
||||
routing model. */
|
||||
|
||||
body.dashboard-shell {
|
||||
/* Full-width layout (#416 — mara: drop the 90em cap so wide screens
|
||||
don't waste real estate on empty side margins). `padding: 0 1.5em
|
||||
1.5em` keeps a small gutter on the left/right so cards don't kiss
|
||||
the viewport edge; `.dashboard-chrome { margin: 0 -1.5em ... }`
|
||||
still pulls the chrome bar edge-to-edge through that gutter. */
|
||||
/* Full-width layout — no max-width cap so wide screens don't
|
||||
waste real estate on empty side margins. `padding: 0 1.5em
|
||||
1.5em` keeps a small gutter on the left/right so cards don't
|
||||
kiss the viewport edge; `.dashboard-chrome { margin: 0 -1.5em
|
||||
... }` still pulls the chrome bar edge-to-edge through that
|
||||
gutter. */
|
||||
margin: 0;
|
||||
padding: 0 1.5em 1.5em;
|
||||
}
|
||||
|
|
@ -189,16 +189,9 @@ a:hover {
|
|||
background: rgba(24, 24, 37, 0.55);
|
||||
transition: opacity 200ms ease, border-color 200ms ease;
|
||||
}
|
||||
/* Topology indent (#363). Each depth level shifts the row right by one
|
||||
step; the .tree-prefix span (drawn by tabs.js::treePrefix) carries
|
||||
the ├─ / └─ glyph and any continuation lines that thread through
|
||||
ancestor columns. When every container has parent=null (pre-#361
|
||||
state) `[data-depth]` is absent on every row and these rules are
|
||||
no-ops — the layout reads exactly like the legacy flat list.
|
||||
Per-depth indent: hardcoded steps for 6 levels (sufficient for any
|
||||
plausible hive topology) — the typed `attr()` function from CSS
|
||||
Values 5 would collapse this to one rule, but browser support is
|
||||
still partial (Chromium-only as of 2026). */
|
||||
/* Topology indent ladder. See docs/web-ui.md::Topology tree (Indent
|
||||
+ lane geometry paragraph) for the 1.8em-per-depth-level
|
||||
rationale + CSS-attr()-not-yet-portable caveat. */
|
||||
.container-row[data-depth] { position: relative; }
|
||||
.container-row[data-depth="1"] { margin-left: 1.8em; }
|
||||
.container-row[data-depth="2"] { margin-left: 3.6em; }
|
||||
|
|
@ -206,16 +199,9 @@ a:hover {
|
|||
.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 sits in the left margin and paints the connecting
|
||||
├ / └ / │ lanes as CSS rules rather than text glyphs (#388). Each
|
||||
ancestor depth gets its own `.tree-lane` so we can paint a
|
||||
full-row-height vertical bar that extends through the
|
||||
.containers row gap into the next sibling — text box-drawing
|
||||
glyphs only fill one text line, which left visible breaks
|
||||
between rows once cards grew taller than one line of text (5em
|
||||
square icons + multi-line body). The horizontal stub at the row's
|
||||
own joint lands at the icon midline so the L/T meets the icon
|
||||
edge cleanly. */
|
||||
/* Tree prefix lanes — DOM-painted, not text-glyph-painted. See
|
||||
docs/web-ui.md::Topology tree for the full-row-height vertical
|
||||
bar rationale + icon-midline joint alignment. */
|
||||
.container-row .tree-prefix {
|
||||
position: absolute;
|
||||
/* Extend into the `.containers { gap: 0.4em }` below so vertical
|
||||
|
|
@ -228,12 +214,10 @@ a:hover {
|
|||
user-select: none;
|
||||
color: var(--purple-dim);
|
||||
}
|
||||
/* Each depth step is one 1.8em lane wide — same step as the row's
|
||||
own margin-left ladder above, so the rightmost lane (the joint)
|
||||
sits flush against the row content (the icon). The prefix's left
|
||||
edge is depth*1.8em LEFT of the row's left edge, so its right
|
||||
edge meets the icon, and the leftmost lane lines up with the
|
||||
top-level rows' icons at x=0. */
|
||||
/* Prefix-left-edge ladder. Each depth step is 1.8em (matches the
|
||||
row indent ladder above) so the prefix's right edge meets the
|
||||
icon and its leftmost lane lines up with top-level rows' icons
|
||||
at x = 0. */
|
||||
.container-row[data-depth="1"] .tree-prefix { left: -1.8em; }
|
||||
.container-row[data-depth="2"] .tree-prefix { left: -3.6em; }
|
||||
.container-row[data-depth="3"] .tree-prefix { left: -5.4em; }
|
||||
|
|
@ -277,14 +261,12 @@ a:hover {
|
|||
width: 2em;
|
||||
border-top: 1px solid currentColor;
|
||||
}
|
||||
/* Live cards get the icon-left / body-right split; tombstone rows keep
|
||||
the plain stacked block layout. The icon is a background-image div
|
||||
with no intrinsic size, so its load state can never reflow the row
|
||||
(issue #177). It used to `align-self: stretch` to fill the body
|
||||
height, but with state badges / rate-limit pills / etc. wrapping the
|
||||
head row, the body grew taller and the square icon grew with it —
|
||||
so two cards with different content showed different-sized icons
|
||||
(issue #344). Fixed at 5em now; height follows from aspect-ratio. */
|
||||
/* Live cards get the icon-left / body-right split; tombstone rows
|
||||
keep the plain stacked block layout. The icon's fixed 5em width
|
||||
+ aspect-ratio-derived height avoid the align-self: stretch
|
||||
feedback loop that used to make different-content cards show
|
||||
different-sized icons. See docs/web-ui.md::Container row (Icon
|
||||
layout + load strategy) for the load-state reflow rationale. */
|
||||
.container-row:not(.tombstone) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -298,9 +280,10 @@ a:hover {
|
|||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
background-color: rgba(17, 17, 27, 0.6);
|
||||
/* #443 — icon is the selection toggle. Cursor + hover ring make
|
||||
that affordable without a chrome change. The :focus-visible ring
|
||||
covers keyboard activation (Enter / Space). */
|
||||
/* Icon doubles as the selection toggle — cursor + hover ring
|
||||
make that affordable without a chrome change. The
|
||||
:focus-visible ring covers keyboard activation (Enter / Space).
|
||||
See docs/web-ui.md::Selection bar for the toggle semantics. */
|
||||
cursor: pointer;
|
||||
transition: box-shadow 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
|
@ -321,9 +304,9 @@ a:hover {
|
|||
.container-row.selected > .container-icon {
|
||||
box-shadow: 0 0 0 2px var(--purple), 0 0 12px -4px var(--purple);
|
||||
}
|
||||
/* The icon image fills the square wrapper and is taken out of flow
|
||||
(absolute) so its load state — pending, loaded, broken — can never
|
||||
contribute intrinsic size or reflow the row. (issue #177) */
|
||||
/* Icon image — absolutely positioned, fills the square wrapper.
|
||||
See docs/web-ui.md::Container row (Icon layout + load strategy)
|
||||
for the load-state-can't-reflow-row rationale. */
|
||||
.container-row:not(.tombstone) > .container-icon > .container-icon-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
|
@ -331,8 +314,9 @@ a:hover {
|
|||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
/* When the <img> fails to load it falls back to the dimmed hyperhive
|
||||
mark, standing in for the unreachable agent icon (issues #195, #202). */
|
||||
/* When the <img> fails to load it falls back to the dimmed
|
||||
hyperhive mark — see docs/web-ui.md::Container row for the
|
||||
fire-and-forget load + favicon fallback chain. */
|
||||
.container-row:not(.tombstone) > .container-icon.icon-unreachable {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.4;
|
||||
|
|
@ -341,23 +325,29 @@ a:hover {
|
|||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Pending state splits queued vs running (#769): queued ops show
|
||||
only the pending-state badge — the row sits unhighlighted so a
|
||||
long queue doesn't paint half the SW4RM tab amber. Running ops
|
||||
keep the amber row tint AND get a rotating amber ring on the
|
||||
agent icon so it's obvious which container is actually moving. */
|
||||
/* Pending state splits queued vs running — queued ops show only
|
||||
the pending-state badge (no row tint), running ops keep the
|
||||
amber row tint AND get a rotating amber ring on the icon. See
|
||||
docs/web-ui.md::Container row (Pending-state derivation) for
|
||||
the priority order. */
|
||||
.container-row.pending .actions { opacity: 0.4; pointer-events: none; }
|
||||
.container-row.pending-running {
|
||||
border-color: var(--amber);
|
||||
background: rgba(250, 179, 135, 0.05);
|
||||
}
|
||||
/* The spinner is a transparent border ring with two adjacent sides
|
||||
coloured amber, rotated by a CSS animation — looks like an
|
||||
orbiting arc around the icon. Override the icon's overflow:hidden
|
||||
so the ring can sit just outside the square; it composes naturally
|
||||
with the mauve selected-ring (selected + running shows both). */
|
||||
/* The spinner is a faint amber ring with one brighter arc on top
|
||||
that rotates around the icon — classic CSS spinner shape. The
|
||||
previous version coloured two adjacent border sides amber, which
|
||||
rendered as a rotating L-corner ("_|") rather than a smooth
|
||||
orbiting arc (mara on #804). Faint-ring + bright-arc reads
|
||||
unambiguously as a loading spinner.
|
||||
|
||||
Override the icon's overflow:hidden so the ring can sit just
|
||||
outside the square; it composes naturally with the mauve
|
||||
selected-ring (selected + running shows both). */
|
||||
@keyframes container-icon-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.container-row.pending-running > .container-icon {
|
||||
overflow: visible;
|
||||
|
|
@ -367,9 +357,8 @@ a:hover {
|
|||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid transparent;
|
||||
border: 2px solid rgba(250, 179, 135, 0.2);
|
||||
border-top-color: var(--amber);
|
||||
border-right-color: var(--amber);
|
||||
animation: container-icon-spin 1s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
@ -385,11 +374,13 @@ a:hover {
|
|||
font-weight: bold;
|
||||
}
|
||||
.container-row .head .meta { margin-left: auto; }
|
||||
/* Icon-only nav strip in the head row — the per-container backend-
|
||||
supplied link list (issue #262). Inline-flex + gap so a longer list
|
||||
(e.g. with `dashboardLinks` extras) doesn't cram (issue #333). Each
|
||||
link gets a comfortable hit target with a subtle hover so the
|
||||
icons read as interactive rather than decorative. */
|
||||
/* Icon-only nav strip in the head row — backend-supplied per-
|
||||
container link list. Inline-flex + gap so a longer list (e.g.
|
||||
with `dashboardLinks` extras) doesn't cram. Each link gets a
|
||||
comfortable hit target with a subtle hover so the icons read
|
||||
as interactive rather than decorative. See
|
||||
docs/web-ui.md::Container row Line 1 for the link-list source
|
||||
of truth. */
|
||||
.container-row .head .nav-strip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -441,11 +432,10 @@ a:hover {
|
|||
color: var(--cyan); border-color: var(--cyan);
|
||||
text-shadow: 0 0 6px rgba(137, 220, 235, 0.4);
|
||||
}
|
||||
/* Context-window usage badges on dashboard container rows. Thresholds
|
||||
are derived per-container: yellow ≥ 50% and red ≥ 75% of the model's
|
||||
context window (`ContainerView.context_window_tokens`), mirroring the
|
||||
harness compaction watermarks. Falls back to fixed 100k / 150k when
|
||||
the window is unknown. (issue #66) */
|
||||
/* Context-window usage badges on dashboard container rows. See
|
||||
docs/web-ui.md::Container row Line 1 for the per-container
|
||||
threshold derivation (yellow ≥ 50% / red ≥ 75% of the model's
|
||||
context window) and fallback values. */
|
||||
.badge-ctx-ok {
|
||||
color: var(--green); border-color: var(--green);
|
||||
opacity: 0.85;
|
||||
|
|
@ -478,10 +468,10 @@ a:hover {
|
|||
/* Per-container journald viewer + applied-config viewer. Both open
|
||||
in the side panel and lazy-fetch on open; output is monospace
|
||||
inside a bordered <pre>, controls (unit select + refresh) above.
|
||||
#541: the panel-body wrapper is a column flex container that fills
|
||||
the side-panel-body so the <pre> can flex-grow into a single tall
|
||||
scrollable surface instead of a short box at the top with the rest
|
||||
of the panel empty. */
|
||||
The panel-body wrapper is a column flex container that fills the
|
||||
side-panel-body so the <pre> can flex-grow into a single tall
|
||||
scrollable surface instead of a short box at the top with the
|
||||
rest of the panel empty. */
|
||||
.journal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -509,9 +499,9 @@ a:hover {
|
|||
color: var(--fg);
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.5em 0.7em;
|
||||
/* #541: take all leftover panel height + scroll inside the pre so
|
||||
long log fetches don't push the controls off-screen. `min-height:
|
||||
0` is the canonical "let me actually flex-shrink for overflow"
|
||||
/* Take all leftover panel height + scroll inside the pre so long
|
||||
log fetches don't push the controls off-screen. `min-height: 0`
|
||||
is the canonical "let me actually flex-shrink for overflow"
|
||||
escape hatch on flex children. */
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
|
|
@ -605,9 +595,8 @@ code {
|
|||
flex-wrap: wrap;
|
||||
gap: 0.3em;
|
||||
}
|
||||
/* When the approval was requested — right-aligned in the head row;
|
||||
goes amber once it has been pending ≥ 1h so a stale request stands
|
||||
out at a glance (issue #272). */
|
||||
/* Approval requested-at chip — right-aligned, goes amber after
|
||||
1h. See docs/web-ui.md::Approval card. */
|
||||
.approval-ts {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
|
|
@ -660,7 +649,8 @@ code {
|
|||
border-color: var(--purple);
|
||||
background: rgba(203, 166, 247, 0.08);
|
||||
}
|
||||
/* Image / tabbed file preview (issues #188, #192) */
|
||||
/* Image / tabbed file preview — see docs/web-ui.md::Side panel
|
||||
for the type-aware preview shapes. */
|
||||
.preview-host { margin-top: 0.5em; }
|
||||
.img-preview {
|
||||
display: block;
|
||||
|
|
@ -734,7 +724,7 @@ code {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Bulk select-all / -none control above the meta-inputs tree (#275). */
|
||||
/* Bulk select-all / -none control above the meta-inputs tree. */
|
||||
.meta-inputs-bulk {
|
||||
margin: 0 0 0.5em;
|
||||
font-size: 0.8em;
|
||||
|
|
@ -754,7 +744,7 @@ code {
|
|||
border-color: var(--cyan);
|
||||
text-shadow: 0 0 6px currentColor;
|
||||
}
|
||||
/* Tree twig glyph prefixing a nested (sub-)input row (#275). */
|
||||
/* Tree twig glyph prefixing a nested (sub-)input row. */
|
||||
.meta-input-twig {
|
||||
color: var(--purple-dim);
|
||||
margin-right: 0.1em;
|
||||
|
|
@ -780,7 +770,7 @@ code {
|
|||
cursor: not-allowed;
|
||||
}
|
||||
/* In-progress banner for the META INPUTS panel: shown while a
|
||||
dashboard-triggered meta-update runs in the background (issue #259). */
|
||||
dashboard-triggered meta-update runs in the background. */
|
||||
.meta-update-running {
|
||||
margin: 0 0 0.7em;
|
||||
padding: 0.4em 0.7em;
|
||||
|
|
@ -836,11 +826,10 @@ code {
|
|||
.rqe-source-approval { color: var(--green); border-color: var(--green); }
|
||||
.rqe-when { color: var(--muted); font-size: 0.85em; }
|
||||
.rqe-reason { color: var(--muted); font-size: 0.85em; flex: 1 1 auto; }
|
||||
/* #437: in-flight step indicator on running queue entries — sub-line
|
||||
below the main row, indented under the state glyph + kind. Cyan
|
||||
keeps it visually grouped with the running spinner instead of
|
||||
blending into the muted reason/timing chips. flex-basis: 100% so
|
||||
it always wraps to its own line. */
|
||||
/* In-flight step indicator on running queue entries — cyan sub-
|
||||
line below the main row, wraps to its own line via flex-basis:
|
||||
100%. See docs/web-ui.md::R3BU1LD QU3U3 for the step-annotation
|
||||
semantics. */
|
||||
.rqe-step {
|
||||
flex-basis: 100%;
|
||||
margin: 0.1em 0 0 1.8em;
|
||||
|
|
@ -858,12 +847,11 @@ code {
|
|||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* #575: cancel-X for queued rebuild_queue entries. Quiet by default
|
||||
(sits at the far right via margin-left: auto), lights red on hover.
|
||||
Renders only when `state === 'queued'` per renderQueueEntry's
|
||||
guard — Running / terminal rows don't get the affordance. Mirrors
|
||||
the side-panel-close glyph shape (✗) without the full B6N-style
|
||||
`btn-deny` width so it stays unobtrusive in a list row. */
|
||||
/* Cancel-X for queued rebuild_queue entries — sits at far right
|
||||
via margin-left: auto, lights red on hover. Mirrors the side-
|
||||
panel-close glyph shape (✗) so it stays unobtrusive in a list
|
||||
row. See docs/web-ui.md::R3BU1LD QU3U3 for the queued-only
|
||||
gating. */
|
||||
.rqe-cancel {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
|
@ -942,10 +930,9 @@ ul form.inline { display: inline-block; }
|
|||
.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; }
|
||||
/* #486 — M0V3 affordance (selection bar). Mauve picks up the same
|
||||
accent the question-override / mid-status surfaces use; reads as
|
||||
"structural change" rather than the destructive red / amber chrome
|
||||
of destroy / rebuild. */
|
||||
/* M0V3 affordance (selection bar) — mauve reads as "structural
|
||||
change" rather than the destructive red / amber chrome of
|
||||
destroy / rebuild. See docs/web-ui.md::Selection bar. */
|
||||
.btn-move { color: var(--mauve); border-color: var(--mauve); 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); }
|
||||
|
|
@ -959,7 +946,7 @@ ul form.inline { display: inline-block; }
|
|||
text-shadow: 0 0 6px currentColor;
|
||||
box-shadow: 0 0 8px -2px currentColor;
|
||||
}
|
||||
/* #474: 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
|
||||
isn't deletion). */
|
||||
.btn-edit-schedule { color: var(--yellow, #f9e2af); border-color: var(--yellow, #f9e2af); }
|
||||
|
|
@ -1224,10 +1211,8 @@ summary:hover { color: var(--purple); }
|
|||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5em 0.8em;
|
||||
/* #450: no max-height cap — let the inbox grow to fill the
|
||||
side-panel-body which already scrolls (`overflow: auto`). The
|
||||
pre-#450 24em cap clamped the list well short of the available
|
||||
panel height even on tall viewports. */
|
||||
/* No max-height cap — let the inbox grow to fill the
|
||||
side-panel-body which already scrolls. */
|
||||
}
|
||||
.inbox li {
|
||||
padding: 0.25em 0;
|
||||
|
|
@ -1263,17 +1248,9 @@ summary:hover { color: var(--purple); }
|
|||
text-indent: 0;
|
||||
}
|
||||
.live .msgrow .msg-body {
|
||||
/* #485: body takes a full flex line of its own beneath the
|
||||
metadata chips (ts / arrow / from / sep / to). Previously the
|
||||
body sat inline with `flex: 1 1 0`, eating whatever the chips
|
||||
left — which on a long timestamp + agent names + arrows meant
|
||||
the body started ~30ch in and wrapped awkwardly. Pushing
|
||||
`flex-basis: 100%` forces the body to wrap to its own line in
|
||||
the existing `flex-wrap: wrap` row, where it can use the full
|
||||
width down to the row's content edge.
|
||||
`min-width: 0` still applies so `word-break: break-word`
|
||||
actually kicks in instead of forcing the row wider than its
|
||||
container. */
|
||||
/* Body takes a full flex line of its own beneath the metadata
|
||||
chips. See docs/web-ui.md::FL0W page (MESS4GE FL0W) for the
|
||||
flex-basis: 100% + min-width: 0 rationale. */
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
@ -1372,9 +1349,9 @@ footer {
|
|||
font-size: 0.9em;
|
||||
}
|
||||
footer a { color: var(--purple); }
|
||||
/* Slug banner now lives at the page footer (#389 follow-up) — give
|
||||
it a slim top margin so it doesn't crash into the prior content,
|
||||
and bottom margin separating from the divider/link line. */
|
||||
/* Slug banner lives at the page footer — slim top margin so it
|
||||
doesn't crash into the prior content, bottom margin separating
|
||||
from the divider/link line. */
|
||||
footer .banner-thin {
|
||||
margin-bottom: 0.8em;
|
||||
}
|
||||
|
|
@ -1420,13 +1397,11 @@ footer .banner-thin {
|
|||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/* #451: width is a CSS variable so the drag handle (added by
|
||||
Panel.bind) can update it live, and so localStorage-persisted
|
||||
widths apply on first paint. Default min(760px, 94vw) preserves
|
||||
the pre-#451 behaviour for operators who never drag. JS sets
|
||||
`--side-panel-w` via inline style on the drawer; persistence
|
||||
lives in localStorage (key `hyperhive:side-panel-width`),
|
||||
replayed onto the var by `Panel.applyStoredWidth` at bind. */
|
||||
/* Width is a CSS variable so the drag handle can update it live
|
||||
(JS sets `--side-panel-w` on the drawer) and localStorage-
|
||||
persisted widths apply on first paint. Default min(760px, 94vw)
|
||||
covers operators who never drag. See docs/web-ui.md::Side panel
|
||||
for the drag-to-resize mechanism + localStorage key. */
|
||||
width: var(--side-panel-w, min(760px, 94vw));
|
||||
/* Clamp so a stored width can never push the drawer off-screen
|
||||
or shrink it past readability. min content width matches the
|
||||
|
|
@ -1444,12 +1419,11 @@ footer .banner-thin {
|
|||
.side-panel.open { pointer-events: auto; }
|
||||
.side-panel.open .side-panel-backdrop { opacity: 1; }
|
||||
.side-panel.open .side-panel-drawer { transform: translateX(0); }
|
||||
/* #451: drag-to-resize handle on the drawer's left edge. The handle
|
||||
itself is invisible until hover/drag so it doesn't compete with the
|
||||
2px mauve `border-left` for the visual boundary. Pointer-cursor
|
||||
tells the operator the edge is grabbable; the brighter glow during
|
||||
drag (`body.side-panel-resizing`) is the affordance the eye
|
||||
tracks. */
|
||||
/* Drag-to-resize handle on the drawer's left edge. Invisible until
|
||||
hover/drag so it doesn't compete with the 2px mauve border-left
|
||||
for the visual boundary. Pointer-cursor tells the operator the
|
||||
edge is grabbable; the brighter glow during drag
|
||||
(body.side-panel-resizing) is the affordance the eye tracks. */
|
||||
.side-panel-resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -1547,22 +1521,17 @@ body.side-panel-resizing * { cursor: ew-resize !important; }
|
|||
padding: 0.2em 0.5em;
|
||||
}
|
||||
|
||||
/* ─── /flow.html — full-page chat (#369) ───────────────────────────
|
||||
The all-agents chat surface lives on its own page so it can claim
|
||||
full-viewport vibec0re styling (operator @ #369#issuecomment-3437).
|
||||
Same shape as the per-agent live page (#362): frosted-glass header
|
||||
at top, frosted composer docked at bottom, terminal scrolls
|
||||
behind both. Operator inbox lives behind a header pill that opens
|
||||
the side-panel flyout — preserves the "inbox + chat in one view"
|
||||
ergonomics without stealing terminal real estate. */
|
||||
/* ─── /flow.html — full-page chat ─────────────────────────────────
|
||||
See docs/web-ui.md::FL0W page for the page-vs-pane rationale
|
||||
and the inbox-flyout-as-pill ergonomics. Shape mirrors the
|
||||
per-agent live page (frosted-glass header + composer, full-
|
||||
viewport terminal). */
|
||||
|
||||
:root {
|
||||
/* Approximate height of the flow chrome (tabbar + dashboard-chrome
|
||||
padding). The banner-thin slug used to live in the chrome and
|
||||
padded this up to 4.7em; with the slug moved out (#389 follow-up:
|
||||
slug lives at the page footer on /, simply omitted on /flow.html
|
||||
since there's no normal-flow footer position in the full-viewport
|
||||
terminal), the chrome is just tabs now and shrinks accordingly. */
|
||||
padding). The slug banner lives at the page footer on /, omitted
|
||||
on /flow.html since the full-viewport terminal has no normal-flow
|
||||
footer position. */
|
||||
--flow-header-h: 3.6em;
|
||||
--flow-composer-h: 3.6em;
|
||||
--flow-frost-bg: rgba(30, 30, 46, 0.74);
|
||||
|
|
@ -1584,8 +1553,8 @@ body.flow-shell {
|
|||
var(--bg);
|
||||
}
|
||||
|
||||
/* Flow chrome (#383): reuses the dashboard's `.dashboard-chrome` +
|
||||
tabbar so the operator can switch tabs from the flow page without
|
||||
/* Flow chrome reuses the dashboard's `.dashboard-chrome` + tabbar
|
||||
so the operator can switch tabs from the flow page without
|
||||
navigating back first. The chrome must be fixed-position (vs
|
||||
sticky on the dashboard) since flow-shell has `overflow: hidden`
|
||||
on body and the main area absolute-positions the terminal. */
|
||||
|
|
@ -1613,15 +1582,13 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
border-color: var(--purple-dim);
|
||||
box-shadow: 0 -2px 12px -4px rgba(203, 166, 247, 0.4);
|
||||
}
|
||||
/* Legacy `.flow-title` / `.flow-hint` / `.flow-back` rules were
|
||||
removed in #383 — the flow page now uses the shared chrome with
|
||||
the dashboard tab strip, no need for FL0W-specific title/hint
|
||||
elements. The `.notif-row` styling lives under the shared
|
||||
`.tabbar #notif-row` selector earlier in the file. */
|
||||
/* `.notif-row` styling lives under the shared `.tabbar #notif-row`
|
||||
selector earlier in the file — the flow page reuses the dashboard
|
||||
tab strip rather than carrying its own title/hint/back chrome. */
|
||||
|
||||
/* Inbox pill — operator inbox flyout trigger. Sits right under the
|
||||
header so it stays in the operator's gaze without crowding the
|
||||
chat. Same shape as the agent page's pills (#362). */
|
||||
chat. Same shape as the agent page's header pills. */
|
||||
.flow-pill {
|
||||
position: fixed;
|
||||
top: calc(var(--flow-header-h) + 0.8em);
|
||||
|
|
@ -1690,11 +1657,10 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
overflow: auto;
|
||||
}
|
||||
/* Tail pill (↓ N new): bottom offset clears the floating composer.
|
||||
z-index escapes the stacking context the .terminal-wrap's
|
||||
backdrop-filter creates (issue #375) — tabs.js anchors the pill
|
||||
on .flow-main now (not .terminal-wrap), so this z-index reaches
|
||||
the root stacking context and properly floats above the
|
||||
composer at z-index 30. */
|
||||
Pill is anchored on .flow-main (not .terminal-wrap) so the
|
||||
backdrop-filter stacking context doesn't trap its z-index — see
|
||||
docs/web-ui.md::Per-agent page (Terminal-wrap) for the same
|
||||
gotcha on the agent page. */
|
||||
.flow-main .tail-pill {
|
||||
bottom: calc(var(--flow-composer-h) + 0.6em);
|
||||
z-index: 35;
|
||||
|
|
@ -1720,10 +1686,11 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
surface the messages via the pill/flyout instead. */
|
||||
.flow-inbox-headless { display: none !important; }
|
||||
|
||||
/* ─── scheduled prompts tab (#459) ─────────────────────────────────────
|
||||
/* ─── scheduled prompts tab ────────────────────────────────────────
|
||||
Creation form at the top, list of queued schedule cards below.
|
||||
Cards show: id + source + due-in + cancel-all in the header,
|
||||
the prompt body, then a targets table with per-row cancel. */
|
||||
the prompt body, then a targets table with per-row cancel. See
|
||||
docs/web-ui.md::SCH3DUL3S tab. */
|
||||
|
||||
.schedule-edit-form {
|
||||
display: flex;
|
||||
|
|
@ -1735,9 +1702,9 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
padding: 0.8em 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
/* #474 — inline edit form opens directly under the schedule row's
|
||||
actions strip, indented slightly so it visually nests under the
|
||||
row it edits. */
|
||||
/* Inline edit form opens directly under the schedule row's
|
||||
actions strip, indented slightly so it visually nests under
|
||||
the row it edits. */
|
||||
.schedule-edit-form-wrapper {
|
||||
margin-top: 0.5em;
|
||||
padding-left: 0.5em;
|
||||
|
|
@ -1800,7 +1767,7 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Interval composer (#466). Preset chip row + d/h/m/s inputs + live
|
||||
/* Interval composer — preset chip row + d/h/m/s inputs + live
|
||||
preview, so the operator never has to multiply seconds by hand. */
|
||||
.schedule-interval-presets {
|
||||
display: flex;
|
||||
|
|
@ -1844,10 +1811,9 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
}
|
||||
.schedule-interval-preview-oneshot { color: var(--muted); font-style: italic; }
|
||||
|
||||
/* #535 — schedules-as-table. One row per schedule, attribute columns
|
||||
/* Schedules-as-table — one row per schedule, attribute columns
|
||||
on the left, one ✓/✕ column per agent in the middle, actions
|
||||
column on the right. Agent column headers tilt -45° so a stack of
|
||||
short agent names fits in ~28px each. */
|
||||
column on the right. See docs/web-ui.md::SCH3DUL3S tab. */
|
||||
.schedules-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
|
@ -1867,11 +1833,11 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
.schedules-table-id { width: 3em; }
|
||||
.schedules-table-body-th { min-width: 12em; }
|
||||
.schedules-table-actions-th { width: 7em; }
|
||||
/* Tilted agent column headers (#535). Header cell is narrow (~28px)
|
||||
and tall (~90px); the inner <div> rotates -45° about its bottom-left
|
||||
corner, with a translate to slide the text up alongside the cell
|
||||
border. The inner <span> carries the actual baseline so the
|
||||
underline (border-bottom on the cell) aligns with the rotated text. */
|
||||
/* Tilted agent column headers — narrow (~28px) and tall (~90px);
|
||||
the inner <div> rotates -45° about its bottom-left corner, with
|
||||
a translate to slide the text up alongside the cell border. The
|
||||
inner <span> carries the actual baseline so the underline
|
||||
(border-bottom on the cell) aligns with the rotated text. */
|
||||
.schedules-table-agent-th {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
|
|
@ -1955,8 +1921,8 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
padding: 0.1em 0.45em;
|
||||
}
|
||||
|
||||
/* #564 — inline create row at the bottom of the schedules table.
|
||||
Cells host inputs directly so the operator can fill + click + to
|
||||
/* Inline create row at the bottom of the schedules table. Cells
|
||||
host inputs directly so the operator can fill + click + to
|
||||
queue a new schedule without leaving the table view. Slightly
|
||||
different background tone so it reads as "this isn't a schedule
|
||||
yet, it's the create form". */
|
||||
|
|
@ -2058,11 +2024,11 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* Selection bar (#443). Sticky-bottom strip that surfaces bulk
|
||||
actions when ≥1 agent is selected (click the icon). Visually
|
||||
echoes the flow composer's frosted-mauve treatment so the chrome
|
||||
reads as part of the same vibecore family. Hidden when empty —
|
||||
leaves the page footer's normal-flow position untouched. */
|
||||
/* Selection bar — sticky-bottom strip that surfaces bulk actions
|
||||
when ≥1 agent is selected (click the icon). Visually echoes the
|
||||
flow composer's frosted-mauve treatment so the chrome reads as
|
||||
part of the same vibecore family. Hidden when empty. See
|
||||
docs/web-ui.md::Selection bar for the bulk-action semantics. */
|
||||
.selection-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
|
|
@ -2118,11 +2084,11 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
selection set is non-empty). */
|
||||
body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
|
||||
|
||||
/* #486 — M0V3 → <pick> picker. Inline `<select>` + button pair
|
||||
sitting alongside the bulk action buttons in the selection bar.
|
||||
The select inherits the terminal-y monospace look so it doesn't
|
||||
read as system-chrome popping out of the swarm aesthetic. Only
|
||||
surfaces when exactly one agent is selected. */
|
||||
/* M0V3 → <pick> picker — inline `<select>` + button pair sitting
|
||||
alongside the bulk action buttons in the selection bar. The
|
||||
select inherits the terminal-y monospace look so it doesn't read
|
||||
as system-chrome popping out of the swarm aesthetic. See
|
||||
docs/web-ui.md::Selection bar for the picker semantics. */
|
||||
.move-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -8,12 +8,11 @@
|
|||
</head>
|
||||
<body class="flow-shell">
|
||||
|
||||
<!-- Fixed-overlay chrome — just the tab strip (#389 follow-up:
|
||||
slug moved to the dashboard's page footer; the flow page is a
|
||||
full-viewport terminal with no normal-flow footer position, so
|
||||
the slug simply doesn't appear here). The operator can still
|
||||
switch tabs from the flow page without navigating back; FL0W is
|
||||
the current page, SW4RM / Y3R C4LL / SYST3M cross-link to the
|
||||
<!-- Fixed-overlay chrome — just the tab strip. The full-viewport
|
||||
terminal has no normal-flow footer position so the dashboard's
|
||||
slug doesn't appear here. The operator can still switch tabs
|
||||
from the flow page without navigating back; FL0W is the current
|
||||
page, SW4RM / Y3R C4LL / SYST3M / SCH3DUL3S cross-link to the
|
||||
dashboard with the matching hash. -->
|
||||
<header class="dashboard-chrome flow-chrome" id="flow-header">
|
||||
<nav class="tabbar" id="tabbar" role="tablist">
|
||||
|
|
@ -55,7 +54,7 @@
|
|||
|
||||
<!-- Operator inbox flyout trigger — count + click → side panel
|
||||
(singleton, declared below). Hidden until the inbox is non-
|
||||
empty. Mirrors the agent page's pill pattern (#362). -->
|
||||
empty. Mirrors the agent page's header pill pattern. -->
|
||||
<button type="button" id="inbox-pill" class="flow-pill" hidden
|
||||
title="open operator inbox">
|
||||
<span class="flow-pill-icon" aria-hidden="true">📬</span>
|
||||
|
|
@ -111,10 +110,10 @@
|
|||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Flow-specific bundle (#406 step 2). Contains the broker
|
||||
terminal init, the operator-inbox derived store, the inbox
|
||||
pill flyout, and the @-mention composer. Tab renderers etc.
|
||||
live in `/static/tabs.js` which /flow.html doesn't load. -->
|
||||
<!-- Flow-specific bundle. Contains the broker terminal init, the
|
||||
operator-inbox derived store, the inbox pill flyout, and the
|
||||
@-mention composer. Tab renderers etc. live in
|
||||
`/static/tabs.js` which /flow.html doesn't load. -->
|
||||
<script type="module" src="/static/flow.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
// /flow.html entry point (#406 step 2 — flow-specific split from the
|
||||
// previous combined entry; #406 step 3 renamed that combined entry
|
||||
// from `app.js` to `tabs.js`).
|
||||
//
|
||||
// Owns the full-page broker terminal, the operator-inbox derived store
|
||||
// (populated from the broker stream), the inbox pill flyout, and the
|
||||
// @-mention compose box. Pulls shared infrastructure (DOM helpers, side
|
||||
// panel, OS notifications, path linkification) from `./common.js`.
|
||||
// /flow.html entry point. Owns the full-page broker terminal, the
|
||||
// operator-inbox derived store (populated from the broker stream),
|
||||
// the inbox pill flyout, and the @-mention compose box. Pulls shared
|
||||
// infrastructure (DOM helpers, side panel, OS notifications, path
|
||||
// linkification) from `./common.js`.
|
||||
//
|
||||
// Does NOT contain the dashboard's tab renderers, mutation-event
|
||||
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
|
||||
|
|
@ -106,11 +103,11 @@ import {
|
|||
if (!flow) return;
|
||||
flow.innerHTML = '';
|
||||
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
|
||||
// Pulse the page banner whenever a broker event lands. (Note:
|
||||
// post-#389 the `.banner` lives in the dashboard's <footer>, not
|
||||
// in the flow page chrome — `pulseBanner` no-ops on /flow.html
|
||||
// since there's no element to find. Kept for parity if a future
|
||||
// chrome change reintroduces a banner.)
|
||||
// Pulse the page banner whenever a broker event lands. The
|
||||
// `.banner` element lives in the dashboard's <footer> rather than
|
||||
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
|
||||
// there's no element to find. Kept for parity if a future chrome
|
||||
// change reintroduces a banner.
|
||||
const banner = document.querySelector('.banner');
|
||||
let bannerOffTimer = null;
|
||||
function pulseBanner() {
|
||||
|
|
@ -174,33 +171,28 @@ import {
|
|||
// Register this row so future replies can reference it.
|
||||
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
|
||||
}
|
||||
// Anchor the `↓ N new` pill in `.flow-main` (NOT the default
|
||||
// `log.parentElement` = `.terminal-wrap`). `.terminal-wrap`
|
||||
// applies `backdrop-filter`, which creates a CSS stacking
|
||||
// context — the pill's z-index would otherwise be trapped
|
||||
// inside and clipped under the fixed composer (issue #375).
|
||||
// `.flow-main` has no backdrop-filter / stacking-context
|
||||
// creators, so the pill's z-index reaches the root and floats
|
||||
// above the composer.
|
||||
// Anchor the `↓ N new` pill in `.flow-main` rather than the
|
||||
// default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent
|
||||
// page (Terminal-wrap) for the backdrop-filter stacking-context
|
||||
// gotcha (same shape on the flow page).
|
||||
const flowMain = document.querySelector('.flow-main');
|
||||
termCreate({
|
||||
logEl: flow,
|
||||
pillAnchor: flowMain,
|
||||
historyUrl: '/dashboard/history',
|
||||
// #408: server-side filter — only the kinds this page actually
|
||||
// renders or routes (sent/delivered → broker terminal,
|
||||
// Server-side filter — only the kinds this page actually renders
|
||||
// or routes (sent/delivered → broker terminal,
|
||||
// container_state_changed/_removed → local autocomplete cache).
|
||||
// Backend (#499) pre-parses the allow-list at subscribe time so
|
||||
// the per-frame hot path is one HashSet::contains and the
|
||||
// Backend pre-parses the allow-list at subscribe time so the
|
||||
// per-frame hot path is one HashSet::contains and the
|
||||
// JSON-serialise is skipped entirely on irrelevant kinds. The
|
||||
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
|
||||
// since it routes every mutation kind into its derived stores.
|
||||
streamUrl: '/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
|
||||
// #448: route through the SharedWorker so this page's SSE shares
|
||||
// a single backend connection with /index.html (and any other
|
||||
// open hyperhive tab). Worker keys on the full URL (incl.
|
||||
// query string), so the filtered subscribe is its own upstream
|
||||
// — won't accidentally share with tabs.js's wider subscribe.
|
||||
// Route through the SharedWorker — see docs/web-ui.md (SSE
|
||||
// multiplexing paragraph). Worker keys on the full URL incl.
|
||||
// query string, so this filtered subscribe is its own upstream
|
||||
// and won't accidentally share with tabs.js's wider subscribe.
|
||||
streamFactory: openStream,
|
||||
renderers: {
|
||||
sent: (ev, api) => renderMsg(ev, api, '→'),
|
||||
|
|
@ -232,10 +224,9 @@ import {
|
|||
// Re-sync the local containers cache on every SSE (re)connect.
|
||||
// Live mutation events that fired during a disconnect window
|
||||
// are never replayed, so without this the compose autocomplete
|
||||
// could drift stale (issue #163). We don't try to recover
|
||||
// missed broker rows here — operator inbox briefly stales on
|
||||
// reconnect; HiveTerminal's history-replay covers the next
|
||||
// page load.
|
||||
// could drift stale. We don't try to recover missed broker rows
|
||||
// here — operator inbox briefly stales on reconnect; the
|
||||
// history-replay covers the next page load.
|
||||
onStreamOpen: () => {
|
||||
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
|
||||
if (!s || !Array.isArray(s.containers)) return;
|
||||
|
|
|
|||
|
|
@ -8,12 +8,11 @@
|
|||
</head>
|
||||
<body class="dashboard-shell">
|
||||
|
||||
<!-- Sticky chrome — just the tab strip now (#389 follow-up: the
|
||||
"WE ARE THE WIRED" slug moved out of chrome entirely and lives
|
||||
at the page footer below `<main>`; chrome is navigation only).
|
||||
Tabs route via the URL hash so F5 / back-button / shared links
|
||||
keep you on the same view. JS owns the actual show/hide; this
|
||||
is just the menu. -->
|
||||
<!-- Sticky chrome — just the tab strip. The "WE ARE THE WIRED"
|
||||
slug lives at the page footer below `<main>`; chrome is
|
||||
navigation only. Tabs route via the URL hash so F5 / back-
|
||||
button / shared links keep you on the same view. JS owns
|
||||
the show/hide. -->
|
||||
<header class="dashboard-chrome">
|
||||
<nav class="tabbar" id="tabbar" role="tablist">
|
||||
<a class="tab" id="tab-swarm" href="#swarm" role="tab"
|
||||
|
|
@ -34,10 +33,10 @@
|
|||
<span class="tab-label">◆ SYST3M ◆</span>
|
||||
<span class="tab-count" id="tab-count-system" hidden></span>
|
||||
</a>
|
||||
<!-- SCH3DUL3S (#459): scheduled-prompts surface. List of
|
||||
queued schedules + an operator-direct creation form.
|
||||
Count pill mirrors the active (non-cancelled) schedule
|
||||
count; hidden when zero. -->
|
||||
<!-- SCH3DUL3S: scheduled-prompts surface. List of queued
|
||||
schedules + an operator-direct creation form. Count pill
|
||||
mirrors the active (non-cancelled) schedule count; hidden
|
||||
when zero. -->
|
||||
<a class="tab" id="tab-schedules" href="#schedules" role="tab"
|
||||
aria-controls="tab-pane-schedules"
|
||||
data-tab="schedules">
|
||||
|
|
@ -45,24 +44,25 @@
|
|||
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
||||
</a>
|
||||
|
||||
<!-- M4TR1X (#607): optional matrix web client (fluffychat-web by
|
||||
default) mounted at /matrix/ by hive-c0re's dashboard router
|
||||
when `hyperhive.matrix.gui.enable = true`. Same-origin same-
|
||||
tab navigation (the static dist is its own SPA). Hidden in JS
|
||||
when `state.matrix_gui_enabled === false` so operators without
|
||||
matrix-gui on don't see a dead link (#609 covers the
|
||||
post-#15 nginx-front re-root + .well-known auto-discovery). -->
|
||||
<!-- M4TR1X: optional matrix web client (fluffychat-web by
|
||||
default) mounted at /matrix/ by the gateway when
|
||||
`hyperhive.matrix.gui.enable = true`. Same-origin
|
||||
navigation (the static dist is its own SPA). Hidden in
|
||||
JS when `state.matrix_gui_enabled === false` so operators
|
||||
without matrix-gui on don't see a dead link. See
|
||||
docs/web-ui.md::Tab strip for the gating model and
|
||||
docs/gateway.md for the matrix vhost + .well-known
|
||||
auto-discovery. -->
|
||||
<a class="tab tab-link" id="tab-matrix" href="/matrix/" hidden
|
||||
title="open the matrix chat client (fluffychat-web)">
|
||||
<span class="tab-label">◆ M4TR1X ◆ →</span>
|
||||
</a>
|
||||
|
||||
<!-- FL0W is its own page (`/flow.html`), not a tab — per
|
||||
operator @ #369#issuecomment-3437 ("yes terminal can be a
|
||||
separate page"). The link lives in the tab strip so it
|
||||
reads as a peer surface; clicking navigates rather than
|
||||
swapping panes in place. Count pill mirrors the dashboard's
|
||||
operator-inbox length and is hidden when zero. -->
|
||||
<!-- FL0W is its own page (`/flow.html`), not a tab. The link
|
||||
lives in the tab strip so it reads as a peer surface;
|
||||
clicking navigates rather than swapping panes in place.
|
||||
Count pill mirrors the dashboard's operator-inbox length
|
||||
and is hidden when zero. See docs/web-ui.md::FL0W page. -->
|
||||
<a class="tab tab-link" id="tab-flow" href="/flow.html"
|
||||
title="open the all-agents chat in a dedicated full-page terminal">
|
||||
<span class="tab-label">◆ FL0W ◆ →</span>
|
||||
|
|
@ -86,9 +86,8 @@
|
|||
|
||||
<!-- SW4RM: the swarm itself. Container cards (the central thing
|
||||
the operator looks at) and rebuild queue / cascade visualisation
|
||||
that drives them. The tab label itself reads SW4RM, so the
|
||||
inline C0NTAINERS h2 heading + divider would be redundant —
|
||||
dropped per #385. -->
|
||||
that drives them. No inline `C0NTAINERS` h2 heading + divider
|
||||
— the tab label SW4RM already says it. -->
|
||||
<section class="tab-pane" id="tab-pane-swarm"
|
||||
role="tabpanel" aria-labelledby="tab-swarm">
|
||||
<div id="containers-section">
|
||||
|
|
@ -117,11 +116,8 @@
|
|||
|
||||
<!-- SYST3M: passive / rare-interaction state. Meta inputs (lock
|
||||
bumps), rebuild queue (watch only), kept state from previous
|
||||
tombstoned agents. Queued reminders moved to the SCH3DUL3S
|
||||
tab in #460 — they're conceptually "fire X at time Y" too,
|
||||
just self-scheduled by agents instead of operator-set.
|
||||
Headings stay; the per-section content auto-compresses to a
|
||||
one-line summary when empty (separate JS toggle). -->
|
||||
tombstoned agents. Per-section content auto-compresses to a
|
||||
one-line summary when empty (JS toggle). -->
|
||||
<section class="tab-pane" id="tab-pane-system"
|
||||
role="tabpanel" aria-labelledby="tab-system">
|
||||
<h2>◆ M3T4 1NPUTS ◆</h2>
|
||||
|
|
@ -145,16 +141,15 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SCH3DUL3S (#459, #564): scheduled prompts. Creation + edit
|
||||
are folded into the same table now (#564) — empty bottom row
|
||||
is the create form (fill cells, click +), inline-edit-row
|
||||
expands on the `✎` toggle for existing schedules. Live
|
||||
schedules list driven by GET /api/schedules; POST /api/schedules
|
||||
to create, PATCH /api/schedules/{id} to edit, POST
|
||||
/api/schedules/{id}/cancel for per-target / whole-row cancel.
|
||||
#444 backend doesn't emit SchedulesChanged dashboard event yet,
|
||||
so the list re-fetches on tab activation + after each submit /
|
||||
cancel. Live SSE wiring is the future PR C. -->
|
||||
<!-- SCH3DUL3S: scheduled prompts. Creation + edit are folded
|
||||
into the same table — empty bottom row is the create form
|
||||
(fill cells, click +), inline-edit-row expands on the `✎`
|
||||
toggle for existing schedules. Schedules list driven by
|
||||
GET /api/schedules; POST /api/schedules to create, PATCH
|
||||
/api/schedules/{id} to edit, POST /api/schedules/{id}/cancel
|
||||
for per-target / whole-row cancel. No SchedulesChanged SSE
|
||||
event yet, so the list re-fetches on tab activation + after
|
||||
each submit / cancel. See docs/web-ui.md::SCH3DUL3S tab. -->
|
||||
<section class="tab-pane" id="tab-pane-schedules"
|
||||
role="tabpanel" aria-labelledby="tab-schedules">
|
||||
<h2>◆ SCH3DUL3S ◆</h2>
|
||||
|
|
@ -164,12 +159,12 @@
|
|||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<!-- QU3U3D R3M1ND3RS (#460): self-scheduled agent reminders.
|
||||
Moved here from the SYST3M tab so the operator has one
|
||||
place for everything that fires at a future time —
|
||||
operator-set schedules above, agent-self reminders here.
|
||||
Backed by GET /api/reminders; refresh handled by
|
||||
refreshReminders() (called from refreshState). -->
|
||||
<!-- QU3U3D R3M1ND3RS: self-scheduled agent reminders. Lives
|
||||
on this tab so the operator has one place for everything
|
||||
that fires at a future time — operator-set schedules
|
||||
above, agent-self reminders here. Backed by GET
|
||||
/api/reminders; refresh handled by refreshReminders()
|
||||
(called from refreshState). -->
|
||||
<h2>◆ QU3U3D R3M1ND3RS ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.</p>
|
||||
|
|
@ -209,11 +204,10 @@
|
|||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Selection action bar (#443). Sticky-bottom strip that slides
|
||||
into view when one or more agent cards is selected (click the
|
||||
icon to toggle). Shows the selection count + actions that
|
||||
apply to ALL selected; disabled-with-tooltip for actions that
|
||||
don't (mara picked option B). Hidden when selection is empty. -->
|
||||
<!-- Selection action bar. Sticky-bottom strip that slides into
|
||||
view when one or more agent cards is selected (click the icon
|
||||
to toggle). See docs/web-ui.md::Selection bar for the bulk
|
||||
action gating + clear semantics. -->
|
||||
<div id="selection-bar" class="selection-bar" hidden role="toolbar"
|
||||
aria-label="bulk agent actions">
|
||||
<span class="selection-count" id="selection-count"></span>
|
||||
|
|
@ -223,11 +217,10 @@
|
|||
title="clear selection (esc)">✕ clear</button>
|
||||
</div>
|
||||
|
||||
<!-- Single bundled entry (#406 step 3 — renamed from app.js to
|
||||
tabs.js since this bundle is the dashboard *tabs* surface only;
|
||||
flow.html has its own flow.js bundle). esbuild folds
|
||||
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
|
||||
flow.html has its own flow.js bundle. esbuild folds
|
||||
@hive/shared/terminal.js and the marked npm package into
|
||||
tabs.js; load order is preserved by the module bundler. -->
|
||||
tabs.js. -->
|
||||
<script type="module" src="/static/tabs.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ window.marked = marked;
|
|||
// agent rebuild ripple) runs in the background. Cold-loaded from
|
||||
// `s.meta_update_running`, then flipped live by the
|
||||
// `meta_update_running` event. Drives the META INPUTS panel's
|
||||
// disabled "updating…" state (issue #259).
|
||||
// disabled "updating…" state.
|
||||
let metaUpdateRunning = false;
|
||||
function syncTombstonesFromSnapshot(s) {
|
||||
tombstonesState = (s.tombstones || []).slice();
|
||||
|
|
@ -802,7 +802,7 @@ window.marked = marked;
|
|||
// and own descendants on the client side; the
|
||||
// backend rechecks via `topology::set_parent`).
|
||||
//
|
||||
// Backend lives at POST /api/topology/set-parent (dashboard.rs#2170),
|
||||
// Backend lives at POST /api/topology/set-parent (dashboard.rs),
|
||||
// form-encoded `child=<name>&new_parent=<target-or-empty>`. The
|
||||
// backend re-emits container snapshots on success, so the tree
|
||||
// repaints without a separate refresh.
|
||||
|
|
@ -830,11 +830,10 @@ window.marked = marked;
|
|||
});
|
||||
|
||||
// M0V3 → <pick>: inline `<select>` of candidate parents + submit
|
||||
// button. Available for any selection size (mara on #695 — was
|
||||
// single-agent only in v1). Picker omits each selected agent itself
|
||||
// plus the union of every selected agent's descendants (cycle-safe;
|
||||
// backend `topology::set_parent` re-checks). Empty candidate list ⇒
|
||||
// disable the picker.
|
||||
// button. Available for any selection size. Picker omits each
|
||||
// selected agent itself plus the union of every selected agent's
|
||||
// descendants (cycle-safe; backend `topology::set_parent`
|
||||
// re-checks). Empty candidate list ⇒ disable the picker.
|
||||
const candidates = validReparentCandidates(selected, containers);
|
||||
const wrap = el('span', { class: 'move-picker' });
|
||||
const selectTitle = selected.length === 1
|
||||
|
|
@ -1033,11 +1032,9 @@ window.marked = marked;
|
|||
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
||||
} else {
|
||||
pre.textContent = text || '(empty)';
|
||||
// Auto-scroll to the newest lines on fresh fetch. #541
|
||||
// moved the scroll surface from side-panel-body onto the
|
||||
// <pre> itself (the panel-body now fills the viewport and
|
||||
// the <pre> is the inner overflow container), so scroll
|
||||
// the <pre> instead of the side-panel-body.
|
||||
// Auto-scroll to the newest lines on fresh fetch. The
|
||||
// scroll surface is the <pre> itself (panel-body fills
|
||||
// the viewport, <pre> is the inner overflow container).
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -2946,9 +2943,9 @@ window.marked = marked;
|
|||
if (!confirm(prompt)) return;
|
||||
// Capture child nodes so we can restore on error, then replace
|
||||
// with DOM-built content (textContent + element children rather
|
||||
// than innerHTML — per argus's review note on #471, the format
|
||||
// string only carries server-side ints/bool today but textContent
|
||||
// is the safer pattern if a stringy field ever lands).
|
||||
// than innerHTML — the format string only carries server-side
|
||||
// ints/bool today but textContent is the safer pattern if a
|
||||
// stringy field ever lands).
|
||||
const originalChildren = btn ? Array.from(btn.childNodes) : [];
|
||||
const restoreBtn = () => {
|
||||
if (!btn) return;
|
||||
|
|
@ -3206,7 +3203,7 @@ window.marked = marked;
|
|||
};
|
||||
})();
|
||||
|
||||
// ─── tab routing (#369) ────────────────────────────────────────────────
|
||||
// ─── tab routing ───────────────────────────────────────────────────────
|
||||
// Hash-based: `#swarm` / `#call` / `#system` activate the matching
|
||||
// pane on the dashboard. Empty hash defaults to SW4RM. FL0W is NOT
|
||||
// a tab — it's a separate page (`/flow.html`) reached via the
|
||||
|
|
|
|||
|
|
@ -6,13 +6,15 @@
|
|||
colour; pages that don't emit a given kind simply never produce that
|
||||
class — the unused rule sits in the bundle harmlessly.
|
||||
|
||||
`.terminal-wrap` provides the crust-on-black phosphor chrome that makes
|
||||
the agent page feel like a terminal. Pages can opt in by wrapping a
|
||||
block in this class; or skip it and the rows still render with their
|
||||
class colours, just without the frame.
|
||||
`.terminal-wrap` provides the crust-on-black phosphor chrome that
|
||||
makes the agent page feel like a terminal. Pages can opt in by
|
||||
wrapping a block in this class; or skip it and the rows still render
|
||||
with their class colours, just without the frame.
|
||||
|
||||
No `.term-input` here — composers are a separate concern (see
|
||||
hive-fr0nt::COMPOSER_CSS / COMPOSER_JS once introduced). */
|
||||
No `.term-input` here — composers are a separate concern, owned by
|
||||
each page's own CSS. Row taxonomy + layout contract documented in
|
||||
`docs/web-ui.md::Shared terminal pane` and
|
||||
`docs/terminal-rendering.md`. */
|
||||
|
||||
.terminal-wrap {
|
||||
position: relative;
|
||||
|
|
@ -213,7 +215,7 @@ details.row > pre.diff-body .diff-ctx { color: var(--fg); }
|
|||
border-radius: 0;
|
||||
}
|
||||
.live .row .md a { color: var(--cyan); text-decoration: underline; }
|
||||
/* Auto-linkified bare URLs in plain rows + tool-body blocks (issue #233). */
|
||||
/* Auto-linkified bare URLs in plain rows + tool-body blocks. */
|
||||
.live .row a { color: var(--cyan); text-decoration: underline; }
|
||||
.live .row a:hover { color: var(--fg); }
|
||||
.live .row .md strong { color: inherit; font-weight: bold; }
|
||||
|
|
|
|||
|
|
@ -50,12 +50,9 @@
|
|||
// count=0); pages use it to set state flags from the replayed history.
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
// Snap-to-bottom animation duration (#400 + mara feedback). Browser
|
||||
// default `scrollTo({ behavior: 'smooth' })` runs ~500ms, which read
|
||||
// as "still smooth, but visibly slow." 140ms with ease-out is fast
|
||||
// enough to feel snap-y, slow enough that the row's destination
|
||||
// reads as motion (not a jump). Distances under SCROLL_SNAP_PX
|
||||
// short-circuit to instant — animating a 12px nudge is just jitter.
|
||||
// Snap-to-bottom animation duration. See docs/web-ui.md::Shared
|
||||
// terminal pane (Sticky-bottom + snap animation) for the 140ms-vs-
|
||||
// 500ms-browser-default + 24px short-circuit rationale.
|
||||
const SCROLL_ANIM_MS = 140;
|
||||
const SCROLL_SNAP_PX = 24;
|
||||
|
||||
|
|
@ -77,16 +74,9 @@ export function create(opts) {
|
|||
// handler so both programmatic scrollTop assignments and
|
||||
// operator-driven wheel/drag stay in sync.
|
||||
let stickToBottom = true;
|
||||
// Guards scroll-event-handler from misreading the position while
|
||||
// our own animation is mid-flight (#400). The animation drives
|
||||
// scrollTop with rAF, which fires a stream of scroll events as
|
||||
// the position eases toward the target — the position passes
|
||||
// through "not near bottom" before settling. Without this gate,
|
||||
// the scroll handler flips `stickToBottom` to false mid-animation,
|
||||
// which then causes the MutationObserver to skip the next snap
|
||||
// and leaves the operator stranded mid-scroll. Set to the
|
||||
// animation's nominal end + small headroom; each fresh snap
|
||||
// re-arms it so back-to-back snaps stay gated.
|
||||
// Scroll-handler gate during in-flight snap animations — see
|
||||
// docs/web-ui.md::Shared terminal pane (Mid-animation scroll-event
|
||||
// guard) for the eased-through-not-near-bottom rationale.
|
||||
let smoothScrollingUntil = 0;
|
||||
// rAF id for the current snap animation. Cancelled when a new
|
||||
// snap starts so we never have two animations fighting over
|
||||
|
|
@ -96,16 +86,10 @@ export function create(opts) {
|
|||
function isNearBottom() {
|
||||
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
||||
}
|
||||
// Snap the log to the bottom with a brief eased animation
|
||||
// (#400 + mara: snappier than the browser's default 500ms smooth
|
||||
// scroll). Each call cancels the previous frame loop and starts a
|
||||
// fresh one, so a burst of mutations coalesces into one ride to
|
||||
// the latest bottom. Re-evaluates the target each frame so a
|
||||
// renderer mutation landing mid-animation extends the destination
|
||||
// without a visible jump. Falls back to instant scroll when
|
||||
// `currentNoAnim` is true (backfill replay — operator never sees
|
||||
// intermediate positions, animation is wasted frames) or when the
|
||||
// remaining distance is under SCROLL_SNAP_PX.
|
||||
// Snap the log to the bottom with a brief eased animation. Cancels
|
||||
// any in-flight frame loop so back-to-back snaps coalesce; falls
|
||||
// back to instant scroll during `currentNoAnim` backfill replay
|
||||
// or under SCROLL_SNAP_PX. See docs/web-ui.md::Shared terminal pane.
|
||||
function snapToBottom(immediate) {
|
||||
stickToBottom = true;
|
||||
if (scrollAnimRaf) {
|
||||
|
|
@ -161,48 +145,26 @@ export function create(opts) {
|
|||
pill.classList.add('visible');
|
||||
}
|
||||
log.addEventListener('scroll', () => {
|
||||
// Mid-smooth-scroll: ignore the intermediate scroll events. The
|
||||
// gate releases when the animation has had time to settle (or
|
||||
// when the next snap re-arms it). Without this, easing toward
|
||||
// bottom would flip `stickToBottom` false partway and the next
|
||||
// MO callback would skip the snap.
|
||||
// Swallow scroll events during smooth-snap animations — see
|
||||
// docs/web-ui.md::Shared terminal pane (Mid-animation scroll-event
|
||||
// guard).
|
||||
if (Date.now() < smoothScrollingUntil) return;
|
||||
stickToBottom = isNearBottom();
|
||||
if (stickToBottom) { unseen = 0; updatePill(); }
|
||||
});
|
||||
// Post-append mutations (issue #393). Renderers commonly call
|
||||
// `api.row(cls, text)` to create the row shell, then mutate it
|
||||
// by appending more children (badges, multi-line bodies, tool
|
||||
// result panes) AFTER api.row returned. The afterAppend scroll
|
||||
// below only sees the row's INITIAL height — once the renderer
|
||||
// adds the body, the row's grown past the visible bottom and
|
||||
// the operator is left scrolled to the row's TOP, breaking
|
||||
// stick-to-bottom for every subsequent event.
|
||||
//
|
||||
// Fix: MutationObserver on the log subtree. Fires once per
|
||||
// microtask after each batch of synchronous mutations, so it
|
||||
// runs once per renderer call regardless of how many children
|
||||
// the renderer appends. When `stickToBottom` is true, snap to
|
||||
// bottom again — catches whatever the renderer added after the
|
||||
// afterAppend hop. Programmatic `scrollTop = scrollHeight`
|
||||
// assignments don't re-trigger MO (the scroll itself isn't a
|
||||
// DOM mutation), so no feedback loop.
|
||||
// Post-append mutation snap — catches renderer mutations that land
|
||||
// after `api.row` returns (badges, multi-line bodies, tool
|
||||
// panes). See docs/web-ui.md::Shared terminal pane (Post-append
|
||||
// MutationObserver) for why the pre-append `afterAppend` hop
|
||||
// alone isn't enough.
|
||||
const mo = new MutationObserver(() => {
|
||||
if (stickToBottom) snapToBottom();
|
||||
});
|
||||
mo.observe(log, { childList: true, subtree: true, characterData: true });
|
||||
|
||||
// Auto-scroll decision uses the PRE-append scroll position
|
||||
// (issue #375). Checking after the append underestimates
|
||||
// "nearness" because the new row's own height has already pushed
|
||||
// `scrollHeight - scrollTop - clientHeight` past the threshold,
|
||||
// even when the user was visually at the bottom an instant ago.
|
||||
// Each row/details/detailsDiff captures `nearBottomBeforeAppend`
|
||||
// and hands it to afterAppend so the auto-scroll triggers
|
||||
// whenever the operator was at the bottom when the row landed.
|
||||
// (The MutationObserver above catches the AFTER-row mutations
|
||||
// too, but this initial scroll keeps the visual lag to one
|
||||
// frame instead of one microtask + frame.)
|
||||
// Pre-append nearBottom snapshot drives the initial snap decision —
|
||||
// see docs/web-ui.md::Shared terminal pane (Post-append
|
||||
// MutationObserver) for why we need both this and the MO.
|
||||
function afterAppend(wasNearBottom) {
|
||||
if (currentNoAnim || wasNearBottom) {
|
||||
snapToBottom();
|
||||
|
|
@ -318,12 +280,11 @@ export function create(opts) {
|
|||
let live = false;
|
||||
let buffered = [];
|
||||
|
||||
// #448: callers can supply a `streamFactory(url)` that returns an
|
||||
// EventSource-shaped object (must expose onmessage/onopen/onerror
|
||||
// + .close()). The dashboard pages pass a SharedWorker-backed
|
||||
// factory so all open hyperhive tabs share ONE upstream SSE
|
||||
// connection. Default keeps the direct `new EventSource(url)`
|
||||
// behaviour so non-dashboard consumers (per-agent UI) are unchanged.
|
||||
// Optional streamFactory(url) → EventSource-shaped facade. Lets
|
||||
// the dashboard hand the factory a SharedWorker-backed source so
|
||||
// open hyperhive tabs share one upstream — see
|
||||
// docs/web-ui.md::Shared terminal pane (Backfill + SSE). Default
|
||||
// falls back to `new EventSource(url)`.
|
||||
const es = opts.streamFactory
|
||||
? opts.streamFactory(opts.streamUrl)
|
||||
: new EventSource(opts.streamUrl);
|
||||
|
|
@ -351,7 +312,7 @@ export function create(opts) {
|
|||
// during a disconnect window, so a consumer with
|
||||
// snapshot-derived state (the dashboard's /api/state stores)
|
||||
// must re-sync here or it shows stale state until a manual
|
||||
// reload (issue #163).
|
||||
// reload.
|
||||
if (opts.onStreamOpen) {
|
||||
try { opts.onStreamOpen(); }
|
||||
catch (err) { console.error('onStreamOpen threw', err); }
|
||||
|
|
@ -370,7 +331,7 @@ export function create(opts) {
|
|||
// carried by the history endpoint; deduping them against the
|
||||
// broker-history seq would wrongly drop ones that fired
|
||||
// between a consumer's own snapshot read and this history
|
||||
// fetch (issue #163). ev.seq absent/0 → no dedupe possible.
|
||||
// fetch. ev.seq absent/0 → no dedupe possible.
|
||||
if (boundarySeq != null
|
||||
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
|
||||
&& historyKinds && historyKinds.has(ev.kind)) {
|
||||
|
|
@ -427,9 +388,9 @@ export function create(opts) {
|
|||
}
|
||||
|
||||
// Build a DocumentFragment from `text`, turning bare http(s) URLs into
|
||||
// clickable links that open in a new tab. Non-URL text stays as plain
|
||||
// text nodes — no innerHTML, so this is XSS-safe. Trailing sentence
|
||||
// punctuation is kept out of the link. (issue #233)
|
||||
// clickable links that open in a new tab. See docs/web-ui.md::Shared
|
||||
// terminal pane (linkify) for the text-node-only / no-innerHTML
|
||||
// XSS-safety + trailing-punctuation strip.
|
||||
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
|
||||
export function linkify(text) {
|
||||
const str = text == null ? '' : String(text);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,8 +15,6 @@ use std::path::Path;
|
|||
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::client;
|
||||
|
||||
const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json";
|
||||
const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json";
|
||||
const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.json";
|
||||
|
|
@ -99,25 +97,29 @@ async fn update_marketplaces() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`. When
|
||||
/// `notify_recipient` is `Some(name)`, install failures also get sent
|
||||
/// as a hyperhive message to that recipient (typically `"manager"` for
|
||||
/// sub-agents) so it surfaces in the inbox rather than being buried in
|
||||
/// journald. The manager itself passes `None` — there's nobody above
|
||||
/// it to notify.
|
||||
pub async fn install_configured(socket: &Path, notify_recipient: Option<&str>) {
|
||||
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
|
||||
/// Returns a list of human-readable failure messages so the caller can
|
||||
/// route them through their own per-role surface (turn-failure-style
|
||||
/// notification, see `Surface::send_to_parent`). Pre-#692 this function
|
||||
/// hardcoded `"manager"` as the failure-notification recipient via a
|
||||
/// `notify_recipient: Option<&str>` arg; mara on #778 wanted the
|
||||
/// manager-name special case gone. Now plugins.rs is wire-agnostic and
|
||||
/// the caller picks the recipient via the same `<parent>` sentinel
|
||||
/// failure-notify uses everywhere else (#703).
|
||||
pub async fn install_configured(socket: &Path) -> Vec<String> {
|
||||
let _ = socket; // Reserved for future telemetry; currently unused.
|
||||
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
|
||||
return;
|
||||
return Vec::new();
|
||||
};
|
||||
let specs: Vec<String> = match serde_json::from_str(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
|
||||
return;
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
if specs.is_empty() {
|
||||
return;
|
||||
return Vec::new();
|
||||
}
|
||||
add_marketplaces().await;
|
||||
if auto_update_enabled().await {
|
||||
|
|
@ -125,6 +127,7 @@ pub async fn install_configured(socket: &Path, notify_recipient: Option<&str>) {
|
|||
} else {
|
||||
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
|
||||
}
|
||||
let mut failures = Vec::new();
|
||||
for spec in specs {
|
||||
match Command::new("claude")
|
||||
.args(["plugin", "install", &spec])
|
||||
|
|
@ -142,43 +145,18 @@ pub async fn install_configured(socket: &Path, notify_recipient: Option<&str>) {
|
|||
stderr = %stderr,
|
||||
"claude plugin install failed",
|
||||
);
|
||||
if let Some(to) = notify_recipient {
|
||||
notify(
|
||||
socket,
|
||||
to,
|
||||
format!(
|
||||
"claude plugin install failed for `{spec}`:\n{}",
|
||||
stderr.trim()
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
failures.push(format!(
|
||||
"claude plugin install failed for `{spec}`:\n{}",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
|
||||
if let Some(to) = notify_recipient {
|
||||
notify(
|
||||
socket,
|
||||
to,
|
||||
format!("claude plugin install spawn failed for `{spec}`: {e}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
failures.push(format!(
|
||||
"claude plugin install spawn failed for `{spec}`: {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort hyperhive send. Swallows transport errors — the warn log
|
||||
/// is already in journald and the harness boot must not stall waiting
|
||||
/// for the broker to be reachable.
|
||||
async fn notify(socket: &Path, to: &str, body: String) {
|
||||
let req = hive_sh4re::AgentRequest::Send {
|
||||
to: to.to_owned(),
|
||||
body,
|
||||
in_reply_to: None,
|
||||
};
|
||||
if let Err(e) = client::request::<_, hive_sh4re::AgentResponse>(socket, &req).await {
|
||||
tracing::warn!(error = ?e, "failed to notify {to} of plugin install failure");
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
|
@ -78,7 +78,19 @@ pub type Flavor = mcp::Flavor;
|
|||
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the TCP listener cannot bind to the given port.
|
||||
/// Returns an error if neither the TCP listener (default) nor the
|
||||
/// unix-socket bind (`HIVE_WEB_SOCKET`, if set) can be acquired, or
|
||||
/// if `HIVE_STATIC_DIR` is missing.
|
||||
///
|
||||
/// # Binding modes (#784 phase 1)
|
||||
///
|
||||
/// When `HIVE_WEB_SOCKET` is set + non-empty, bind a `UnixListener`
|
||||
/// at that path so the gateway can `proxy_pass unix:…` instead of
|
||||
/// reaching us over a TCP loopback that won't work post-#14 (private
|
||||
/// netns). When the env var is unset, fall back to TCP bind on `port`
|
||||
/// — the legacy path the gateway's `agent-ports.json` map drives. The
|
||||
/// gateway can transition to socket upstreams independently of any
|
||||
/// agent re-binding because the env var is opt-in per agent.
|
||||
pub async fn serve(
|
||||
label: String,
|
||||
port: u16,
|
||||
|
|
@ -138,13 +150,54 @@ pub async fn serve(
|
|||
// hyperhive.frontend.mergedDist in nix).
|
||||
.fallback_service(ServeDir::new(&static_dir))
|
||||
.with_state(state);
|
||||
// `HIVE_WEB_SOCKET` opt-in (#784 phase 1): when set, bind a
|
||||
// `UnixListener` at the given path. Empty string treated as
|
||||
// unset so a stray `HIVE_WEB_SOCKET=` doesn't trap us into an
|
||||
// un-bindable empty path. Falls through to the TCP path below
|
||||
// otherwise.
|
||||
if let Some(socket_path) = std::env::var_os("HIVE_WEB_SOCKET")
|
||||
&& !socket_path.is_empty()
|
||||
{
|
||||
let path = PathBuf::from(socket_path);
|
||||
let listener = bind_unix(&path)?;
|
||||
tracing::info!(socket = %path.display(), "web UI listening on unix socket");
|
||||
axum::serve(listener, app).await?;
|
||||
return Ok(());
|
||||
}
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = bind_with_retry(addr, "web UI").await?;
|
||||
tracing::info!(%port, "web UI listening");
|
||||
tracing::info!(%port, "web UI listening on tcp");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bind a `UnixListener` at `path`. Best-effort unlinks any stale
|
||||
/// socket left over from a previous (crashed) harness — clean exit
|
||||
/// removes it, but `bind(2)` refuses to overwrite an existing file.
|
||||
/// Also `mkdir -p` the parent so a freshly-created `/run/hive-agent/`
|
||||
/// bind-mount target works on first boot.
|
||||
///
|
||||
/// Permissions: mode `0o660` so peers in the same unix group (the
|
||||
/// gateway container, when bind-mounting the socket dir with a
|
||||
/// shared group) can `connect(2)`. The bind-mount source dir's
|
||||
/// ownership + ACL is the real access gate; this is defence-in-depth.
|
||||
fn bind_unix(path: &Path) -> Result<tokio::net::UnixListener> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create socket parent dir {}", parent.display()))?;
|
||||
}
|
||||
// Best-effort: ENOENT is fine (no stale file); any other error
|
||||
// surfaces via the bind below with a clearer "AddrInUse" / perms
|
||||
// message than a partial cleanup would.
|
||||
let _ = std::fs::remove_file(path);
|
||||
let listener = tokio::net::UnixListener::bind(path)
|
||||
.with_context(|| format!("bind unix socket at {}", path.display()))?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
|
||||
.with_context(|| format!("set perms on {}", path.display()))?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static assets + state snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
269
hive-c0re/src/agent_sockets.rs
Normal file
269
hive-c0re/src/agent_sockets.rs
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
//! `/var/lib/hyperhive/agent-sockets.json` writer (#784 phase 2,
|
||||
//! prerequisite to #14 container netns isolation).
|
||||
//!
|
||||
//! Sibling to `agent_ports.rs`. The gateway needs to know which unix
|
||||
//! socket to `proxy_pass` to per agent once the per-agent web UI
|
||||
//! flips off TCP and on to `UnixListener::bind` (#784 phase 1
|
||||
//! landed via PR #800). This file is the source of truth for
|
||||
//! "which agents exist + where to reach their web UI over a domain
|
||||
//! socket" from the gateway's POV — read at request-handling time,
|
||||
//! not at gateway build time, so a `nixos-container update` of the
|
||||
//! gateway isn't needed every time an agent spawns / moves /
|
||||
//! destroys.
|
||||
//!
|
||||
//! Shape (flat object keyed by logical agent name → socket path):
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "iris": "/run/hive-agent/iris.sock",
|
||||
//! "atlas": "/run/hive-agent/atlas.sock",
|
||||
//! "argus": "/run/hive-agent/argus.sock",
|
||||
//! "damocles": "/run/hive-agent/damocles.sock"
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Socket paths are deterministic from the agent name —
|
||||
//! [`socket_path_for`] computes them, so a name alone resolves to a
|
||||
//! reproducible path. Manager is intentionally excluded from the map
|
||||
//! (same reasoning as `agent_ports.rs`: the gateway routes the
|
||||
//! manager's UI at `/` straight to the dashboard upstream, not via
|
||||
//! per-agent `/agent/<name>/`).
|
||||
//!
|
||||
//! Coexists with `agent-ports.json` during the #784 phase-3
|
||||
//! transition: agents that haven't opted in to `HIVE_WEB_SOCKET` yet
|
||||
//! still appear in both files; the gateway picks the socket upstream
|
||||
//! when one exists, falls back to the TCP port otherwise. Step 4
|
||||
//! drops the TCP path entirely once every agent's web UI has flipped.
|
||||
//!
|
||||
//! Atomicity: same `<path>.tmp` + `rename()` shape as `agent_ports.rs`
|
||||
//! so the gateway's nginx worker never reads a partial file.
|
||||
//!
|
||||
//! ## Per-agent subdir layout
|
||||
//!
|
||||
//! `<sockets-root>/<name>/web.sock`, NOT `<sockets-root>/<name>.sock`.
|
||||
//! Each agent's container bind-mounts the per-agent SUBDIR
|
||||
//! (`/run/hive-agent/<name>/`), and the harness binds the socket
|
||||
//! inside it. File-level bind-mounts don't survive the harness's
|
||||
//! "unlink stale socket then `bind(2)` a new one" cycle — the unlink
|
||||
//! drops the bind, the rebind happens in private container
|
||||
//! namespace, host never sees the new inode. Bind-mounting the
|
||||
//! parent dir keeps both sides looking at the same dir inode so the
|
||||
//! socket appears on the host the moment the harness binds it.
|
||||
//!
|
||||
//! Per-agent dir isolation (one dir per agent rather than a shared
|
||||
//! `/run/hive-agent/` bind) satisfies mara on #800: an agent's
|
||||
//! container only sees its own dir + socket, never siblings'.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::lifecycle::MANAGER_NAME;
|
||||
|
||||
const HOST_SOCKETS_PATH: &str = "/var/lib/hyperhive/agent-sockets.json";
|
||||
|
||||
/// Host-side parent directory holding per-agent socket subdirs. The
|
||||
/// gateway container bind-mounts this whole tree (read-only) so it
|
||||
/// can `proxy_pass` to any agent. Each agent's container bind-mounts
|
||||
/// only its own `<name>/` subdir, scoping access per mara's #800
|
||||
/// directive ("agents can only access their own sockets").
|
||||
pub const AGENT_SOCKET_DIR: &str = "/run/hive-agent";
|
||||
|
||||
/// Socket filename inside each per-agent subdir. Fixed so the path
|
||||
/// derives entirely from `(AGENT_SOCKET_DIR, name)` — no second
|
||||
/// degree of freedom for callers to get wrong.
|
||||
pub const SOCKET_FILENAME: &str = "web.sock";
|
||||
|
||||
#[must_use]
|
||||
pub fn host_sockets_path() -> PathBuf {
|
||||
PathBuf::from(HOST_SOCKETS_PATH)
|
||||
}
|
||||
|
||||
/// Per-agent socket subdir on the host. Lifecycle pre-creates this
|
||||
/// before container start so the bind-mount source exists; the
|
||||
/// harness binds the socket inside it as `web.sock`.
|
||||
#[must_use]
|
||||
pub fn agent_dir_for(name: &str) -> PathBuf {
|
||||
Path::new(AGENT_SOCKET_DIR).join(name)
|
||||
}
|
||||
|
||||
/// Compute the deterministic socket path for an agent. Pure function
|
||||
/// of the agent name so the value matches whatever
|
||||
/// [`agent_sockets::write`] writes for that agent, and whatever the
|
||||
/// harness binds via `HIVE_WEB_SOCKET` post-#784 phase 1.
|
||||
#[must_use]
|
||||
pub fn socket_path_for(name: &str) -> PathBuf {
|
||||
agent_dir_for(name).join(SOCKET_FILENAME)
|
||||
}
|
||||
|
||||
/// Compute the agent-socket map for the given logical agent names.
|
||||
/// Sub-agents only — manager is filtered out at the call boundary
|
||||
/// for the same reason it's filtered from `agent_ports::build_map`
|
||||
/// (manager UI is routed via the c0re dashboard upstream, not via
|
||||
/// `/agent/<name>/`).
|
||||
///
|
||||
/// `BTreeMap` keeps the JSON output sorted by key so a re-emit
|
||||
/// without churn produces byte-identical output — same idempotency
|
||||
/// shape `agent_ports::write` relies on.
|
||||
#[must_use]
|
||||
pub fn build_map(names: &[String]) -> BTreeMap<String, PathBuf> {
|
||||
names
|
||||
.iter()
|
||||
.filter(|n| n.as_str() != MANAGER_NAME)
|
||||
.map(|n| (n.clone(), socket_path_for(n)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render the map as pretty-printed JSON. Pretty so a human peek at
|
||||
/// `cat /var/lib/hyperhive/agent-sockets.json` shows one row per agent
|
||||
/// — keeps the file readable without a separate jq step (mirrors
|
||||
/// `agent_ports::render`).
|
||||
fn render(map: &BTreeMap<String, PathBuf>) -> String {
|
||||
// Serialize as strings (PathBuf → JSON string via the Display
|
||||
// impl). BTreeMap → serde_json::to_string_pretty preserves key
|
||||
// order, so the output is deterministic across calls with the
|
||||
// same agent set.
|
||||
let stringly: BTreeMap<&String, String> = map
|
||||
.iter()
|
||||
.map(|(k, v)| (k, v.display().to_string()))
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&stringly)
|
||||
.expect("BTreeMap<&String, String> is always serialisable")
|
||||
}
|
||||
|
||||
/// Atomically write the JSON for `names` to
|
||||
/// `/var/lib/hyperhive/agent-sockets.json`. Writes via a sibling
|
||||
/// `<path>.tmp` + rename so a crashing process never leaves a
|
||||
/// partial file behind that the gateway worker would fail to parse.
|
||||
///
|
||||
/// Idempotent — if the rendered content matches what's already on
|
||||
/// disk, the write + rename are skipped so the file's mtime stays
|
||||
/// stable and inotify watchers in the gateway (or any future
|
||||
/// watchers) don't fire spurious reload events. Mirrors the
|
||||
/// `agent_ports::write` shape — keep them in lockstep.
|
||||
pub fn write(names: &[String]) -> Result<()> {
|
||||
let map = build_map(names);
|
||||
let body = render(&map);
|
||||
let path = host_sockets_path();
|
||||
if std::fs::read_to_string(&path).ok().as_deref() == Some(&body) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create {}", parent.display()))?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, &body)
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, &path).with_context(|| {
|
||||
format!(
|
||||
"rename {} -> {} (atomic publish)",
|
||||
tmp.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn socket_path_for_uses_subdir_layout() {
|
||||
// Per-agent subdir + fixed socket filename — see module-level
|
||||
// "Per-agent subdir layout" for why this isn't a flat
|
||||
// `<name>.sock`. Pin both ends so a future move (e.g. to
|
||||
// `/run/hyperhive/sockets/`) requires updating both the
|
||||
// constant and the consumers.
|
||||
let p = socket_path_for("iris");
|
||||
assert_eq!(p, Path::new("/run/hive-agent/iris/web.sock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_dir_for_is_socket_parent() {
|
||||
// `agent_dir_for` is what lifecycle bind-mounts per agent;
|
||||
// `socket_path_for` lives inside it. Keep them in lockstep so
|
||||
// a divergence (e.g. typo in one constant) surfaces here
|
||||
// rather than as a confusing nspawn bind-source-not-found at
|
||||
// container start.
|
||||
let dir = agent_dir_for("iris");
|
||||
let sock = socket_path_for("iris");
|
||||
assert_eq!(sock.parent(), Some(dir.as_path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_map_filters_manager() {
|
||||
// Use `MANAGER_NAME` in the input so the assert actually
|
||||
// exercises the filter path — a literal `"hm1nd"` would pass
|
||||
// trivially if the constant ever changed and the filter
|
||||
// silently became a no-op (same pattern as #748 fix on
|
||||
// agent_ports::build_map).
|
||||
let names: Vec<String> = ["iris", MANAGER_NAME, "argus"]
|
||||
.iter()
|
||||
.map(|s| (*s).to_owned())
|
||||
.collect();
|
||||
let map = build_map(&names);
|
||||
assert!(!map.contains_key(MANAGER_NAME));
|
||||
assert!(map.contains_key("iris"));
|
||||
assert!(map.contains_key("argus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_map_uses_socket_path_for() {
|
||||
// Map values agree with the helper so callers can use either
|
||||
// (build_map for the bulk write, socket_path_for for one-off
|
||||
// lookups) without divergence.
|
||||
let names = vec!["iris".to_owned()];
|
||||
let map = build_map(&names);
|
||||
assert_eq!(map.get("iris"), Some(&socket_path_for("iris")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_map_handles_empty_input() {
|
||||
let map = build_map(&[]);
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_map_dedupes_via_btreemap_key_collision() {
|
||||
// Duplicate inputs collapse via the map; no callsite passes
|
||||
// dups today, but guarding the invariant here means a future
|
||||
// bug doesn't surface as a corrupt JSON doc (two `"iris":`
|
||||
// keys). Mirrors agent_ports test.
|
||||
let names = vec!["iris".to_owned(), "iris".to_owned()];
|
||||
let map = build_map(&names);
|
||||
assert_eq!(map.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_is_pretty_and_sorted() {
|
||||
let mut map = BTreeMap::new();
|
||||
map.insert("zeta".to_owned(), PathBuf::from("/run/hive-agent/zeta/web.sock"));
|
||||
map.insert("alpha".to_owned(), PathBuf::from("/run/hive-agent/alpha/web.sock"));
|
||||
let body = render(&map);
|
||||
// Pretty-print = newlines between keys + indentation.
|
||||
assert!(body.contains('\n'));
|
||||
// BTreeMap sorts → alpha before zeta in output.
|
||||
let alpha_pos = body.find("alpha").expect("alpha in output");
|
||||
let zeta_pos = body.find("zeta").expect("zeta in output");
|
||||
assert!(
|
||||
alpha_pos < zeta_pos,
|
||||
"sorted order broken:\n{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_emits_paths_as_strings() {
|
||||
// PathBuf-valued map serialises as plain JSON strings (not
|
||||
// some {"inner": "..."} wrapper). Pin the shape so the
|
||||
// gateway-side reader can deserialise into String values
|
||||
// without nested struct logic.
|
||||
let mut map = BTreeMap::new();
|
||||
map.insert("iris".to_owned(), PathBuf::from("/run/hive-agent/iris/web.sock"));
|
||||
let body = render(&map);
|
||||
assert!(body.contains("\"iris\""));
|
||||
assert!(body.contains("\"/run/hive-agent/iris/web.sock\""));
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
pub mod actions;
|
||||
pub mod agent_ports;
|
||||
pub mod agent_server;
|
||||
pub mod agent_sockets;
|
||||
pub mod approvals;
|
||||
pub mod auto_update;
|
||||
pub mod broker;
|
||||
|
|
|
|||
|
|
@ -1141,6 +1141,40 @@ fn set_nspawn_flags(
|
|||
// empty RO dir instead of a container that won't boot.
|
||||
std::fs::create_dir_all(&config_dir).with_context(|| format!("create {config_dir}"))?;
|
||||
let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config");
|
||||
|
||||
// Per-agent socket subdir (#784 phase 2 step 2b). Bind-mounts
|
||||
// `/run/hive-agent/<name>/` into the container at the same
|
||||
// path so the harness's `HIVE_WEB_SOCKET` bind has a stable
|
||||
// location both sides can see. Sub-agents only — the
|
||||
// manager's UI is served at `/` via the c0re dashboard
|
||||
// upstream, not via `/agent/<name>/`, so it never needs the
|
||||
// per-agent socket dir.
|
||||
//
|
||||
// Bind-mounting the SUBDIR (not the socket file) is mandatory:
|
||||
// the harness's `bind_unix` helper unlinks any stale socket
|
||||
// before calling `bind(2)`, and a file bind-mount drops its
|
||||
// host-side anchor on unlink — the rebind would land in the
|
||||
// container's private namespace, invisible to the gateway.
|
||||
// Dir bind keeps the same dir inode visible on both sides, so
|
||||
// the new `web.sock` shows up on the host the moment the
|
||||
// harness binds it.
|
||||
//
|
||||
// Per-agent dir (rather than a shared `/run/hive-agent/`
|
||||
// mount) means the agent's container only sees its own
|
||||
// subdir — never siblings' (mara on #800: "agents can only
|
||||
// access their own sockets").
|
||||
//
|
||||
// mkdir source defensively: nspawn refuses to start when the
|
||||
// bind source is missing, and on a fresh host `/run/hive-agent/`
|
||||
// doesn't exist yet.
|
||||
let socket_dir = crate::agent_sockets::agent_dir_for(agent_name);
|
||||
std::fs::create_dir_all(&socket_dir)
|
||||
.with_context(|| format!("create {}", socket_dir.display()))?;
|
||||
let _ = write!(
|
||||
binds,
|
||||
" --bind={socket_dir}:{socket_dir}",
|
||||
socket_dir = socket_dir.display(),
|
||||
);
|
||||
}
|
||||
let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\"");
|
||||
let mut lines: Vec<String> = original
|
||||
|
|
|
|||
|
|
@ -115,6 +115,16 @@ pub async fn sync_agents(
|
|||
tracing::warn!(error = ?e, "agent_ports::write failed (non-fatal)");
|
||||
}
|
||||
|
||||
// Refresh /var/lib/hyperhive/agent-sockets.json — sibling to the
|
||||
// ports map, drives the gateway's unix-socket upstreams once
|
||||
// agents opt in to `HIVE_WEB_SOCKET` (PR #800 / #784 phase 1).
|
||||
// Coexists with the TCP-port map during the transition: the
|
||||
// gateway picks the socket upstream when one exists, falls back
|
||||
// to the TCP port otherwise. Same best-effort + non-fatal shape.
|
||||
if let Err(e) = crate::agent_sockets::write(&agent_names) {
|
||||
tracing::warn!(error = ?e, "agent_sockets::write failed (non-fatal)");
|
||||
}
|
||||
|
||||
if initial {
|
||||
git(&dir, &["init", "--initial-branch=main"]).await?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ enum Verb {
|
|||
Diff(verbs::diff::Args),
|
||||
/// Get or set this user's watch subscription on a repo.
|
||||
Subscription(verbs::subscription::Args),
|
||||
/// List timeline events on an issue or PR (closes, label adds,
|
||||
/// assignments, commit refs, pushes, etc.) — the audit trail
|
||||
/// `view` + `comments` don't surface (closes #783).
|
||||
Timeline(verbs::timeline::Args),
|
||||
/// Upload a file as an attachment to an issue.
|
||||
AttachIssue(verbs::attach::IssueArgs),
|
||||
/// Upload a file as an attachment to a comment.
|
||||
|
|
@ -123,6 +127,7 @@ fn main() -> Result<()> {
|
|||
Verb::TreeSha(a) => verbs::tree_sha::run(&client, a),
|
||||
Verb::Diff(a) => verbs::diff::run(&client, a),
|
||||
Verb::Subscription(a) => verbs::subscription::run(&client, a),
|
||||
Verb::Timeline(a) => verbs::timeline::run(&client, a),
|
||||
Verb::AttachIssue(a) => verbs::attach::run_issue(&client, a),
|
||||
Verb::AttachComment(a) => verbs::attach::run_comment(&client, a),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
//! `comments <number> [--limit N]` — list all comments on an issue
|
||||
//! or PR. Closes the curl-fallback gap (#418). Use the global
|
||||
//! `--json` flag for JSON output (#421).
|
||||
//! `comments <number> [--limit N | --tail N]` — list comments on an
|
||||
//! issue or PR. Closes the curl-fallback gap (#418); `--tail`
|
||||
//! closes the third of the four #694 gaps (paging-for-long-threads
|
||||
//! awkwardness).
|
||||
//!
|
||||
//! - `--limit N` (default 50, Forgejo's cap) returns the first N
|
||||
//! comments — same shape this verb has always had.
|
||||
//! - `--tail N` returns the *last* N comments in chronological
|
||||
//! order. Reads the issue's `comments` count first to compute
|
||||
//! which page contains the tail, then fetches only `ceil(N/50) +
|
||||
//! 1` pages. No upstream cap — the work is bounded by `N`, not
|
||||
//! by the thread's length, so it stays cheap even on threads with
|
||||
//! thousands of comments. Use this for "what was the conclusion
|
||||
//! on this long thread?" without scrolling through the whole
|
||||
//! history.
|
||||
//!
|
||||
//! Use the global `--json` flag for JSON output (#421).
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
|
|
@ -9,42 +23,51 @@ use serde_json::{Value, json};
|
|||
use crate::client::Client;
|
||||
use crate::verbs::print_json;
|
||||
|
||||
/// Forgejo's per-page comment cap. The API caps `limit` at 50 even
|
||||
/// if a higher value is requested; pin it explicitly so the math
|
||||
/// downstream doesn't depend on a hidden default.
|
||||
const PAGE_SIZE: u64 = 50;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Issue or PR number.
|
||||
number: u64,
|
||||
/// Page size (Forgejo caps at 50 by default).
|
||||
#[arg(long, default_value_t = 50)]
|
||||
/// Page size for the head-of-thread shape (Forgejo caps at 50).
|
||||
/// Mutually exclusive with `--tail`.
|
||||
#[arg(long, default_value_t = 50, conflicts_with = "tail")]
|
||||
limit: u64,
|
||||
/// Return the last `N` comments in chronological order. Reads
|
||||
/// the issue's `comments` count first, then fetches only the
|
||||
/// `ceil(N/50) + 1` pages that contain the tail — work is
|
||||
/// bounded by N, not by thread length. Mutually exclusive with
|
||||
/// `--limit`.
|
||||
#[arg(long)]
|
||||
tail: Option<usize>,
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
let v = client.get_json(&format!(
|
||||
"/repos/{repo}/issues/{}/comments?limit={}",
|
||||
args.number, args.limit
|
||||
))?;
|
||||
let comments = match args.tail {
|
||||
Some(n) => fetch_tail(client, &repo, args.number, n)?,
|
||||
None => fetch_head(client, &repo, args.number, args.limit)?,
|
||||
};
|
||||
if client.json_mode() {
|
||||
let trimmed: Vec<Value> = v
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.get("id"),
|
||||
"user": c.get("user").and_then(|u| u.get("login")),
|
||||
"created_at": c.get("created_at"),
|
||||
"updated_at": c.get("updated_at"),
|
||||
"body": c.get("body"),
|
||||
"url": c.get("html_url"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
let trimmed: Vec<Value> = comments
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.get("id"),
|
||||
"user": c.get("user").and_then(|u| u.get("login")),
|
||||
"created_at": c.get("created_at"),
|
||||
"updated_at": c.get("updated_at"),
|
||||
"body": c.get("body"),
|
||||
"url": c.get("html_url"),
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
.collect();
|
||||
print_json(&Value::Array(trimmed))
|
||||
} else {
|
||||
for c in v.as_array().cloned().unwrap_or_default() {
|
||||
for c in &comments {
|
||||
let user = c
|
||||
.get("user")
|
||||
.and_then(|u| u.get("login"))
|
||||
|
|
@ -58,3 +81,124 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the first page's worth of comments (existing behaviour).
|
||||
fn fetch_head(client: &Client, repo: &str, number: u64, limit: u64) -> Result<Vec<Value>> {
|
||||
let v = client.get_json(&format!(
|
||||
"/repos/{repo}/issues/{number}/comments?limit={limit}"
|
||||
))?;
|
||||
Ok(v.as_array().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Fetch the last `n` comments on an issue/PR in chronological order.
|
||||
///
|
||||
/// Forgejo orders `/issues/<n>/comments` oldest-first and has no
|
||||
/// `direction=desc` knob, so naive "page everything and slice" pages
|
||||
/// from the WRONG end on long threads — the first 1000 comments
|
||||
/// instead of the last `n`. Fix: read the issue's `comments` count
|
||||
/// first to know how many exist, then start paginating from the
|
||||
/// page that contains item `total - n`. Work is bounded by
|
||||
/// `ceil(n/50) + 1` page fetches, regardless of thread length.
|
||||
fn fetch_tail(client: &Client, repo: &str, number: u64, n: usize) -> Result<Vec<Value>> {
|
||||
if n == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let issue = client.get_json(&format!("/repos/{repo}/issues/{number}"))?;
|
||||
let total = issue
|
||||
.get("comments")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0) as usize;
|
||||
if total == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Cap `n` at the actual total so the math below stays in range
|
||||
// when the caller asks for more comments than exist.
|
||||
let n = n.min(total);
|
||||
let page_size = PAGE_SIZE as usize;
|
||||
// 0-based index of the first comment we want; integer-divide to
|
||||
// get the 1-based page that contains it.
|
||||
let start_idx = total - n;
|
||||
let start_page = (start_idx / page_size) + 1;
|
||||
let last_page = (total - 1) / page_size + 1;
|
||||
let mut merged: Vec<Value> = Vec::with_capacity(n + page_size);
|
||||
for page in start_page..=last_page {
|
||||
let v = client.get_json(&format!(
|
||||
"/repos/{repo}/issues/{number}/comments?limit={PAGE_SIZE}&page={page}"
|
||||
))?;
|
||||
let arr = v.as_array().cloned().unwrap_or_default();
|
||||
if arr.is_empty() {
|
||||
// Page came back empty — either we miscounted (comments
|
||||
// deleted between the issue GET and now) or upstream's
|
||||
// playing tricks. Stop rather than spin.
|
||||
break;
|
||||
}
|
||||
merged.extend(arr);
|
||||
}
|
||||
// The first fetched page contains items from `start_page` × 50
|
||||
// back; we overshoot by `start_idx % 50` items. Slice the tail
|
||||
// to exactly `n` (or fewer if the count shrank under us).
|
||||
let overshoot = merged.len().saturating_sub(n);
|
||||
Ok(merged.into_iter().skip(overshoot).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Pure helper mirroring the page-arithmetic in `fetch_tail`:
|
||||
/// given a total comment count + requested tail size, return
|
||||
/// the (start_page, last_page) pair the network loop would
|
||||
/// walk. Lets us pin the pagination plan — the part that's
|
||||
/// easy to off-by-one — without touching the network.
|
||||
fn tail_plan(total: usize, n: usize) -> (usize, usize) {
|
||||
let n = n.min(total);
|
||||
let page_size = PAGE_SIZE as usize;
|
||||
let start_idx = total - n;
|
||||
let start_page = (start_idx / page_size) + 1;
|
||||
let last_page = (total - 1) / page_size + 1;
|
||||
(start_page, last_page)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_plan_small_thread() {
|
||||
// 5 total, tail 2 → page 1 covers everything; trim happens
|
||||
// via the merged.len() - n overshoot calculation, not pages.
|
||||
assert_eq!(tail_plan(5, 2), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_plan_exact_page_boundary() {
|
||||
// 50 total, tail 3 → all on page 1 (items 1..50).
|
||||
assert_eq!(tail_plan(50, 3), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_plan_crosses_page_boundary() {
|
||||
// 51 total, tail 3 → start_idx=48 lives on page 1, item 51
|
||||
// lives on page 2; fetch both.
|
||||
assert_eq!(tail_plan(51, 3), (1, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_plan_large_thread_bounded_pages() {
|
||||
// 5000 total, tail 3 → start_idx=4997 lives on page 100,
|
||||
// last_page=100. ONE page fetch on a 5000-comment thread —
|
||||
// the whole point of swapping to count-then-page (vs the
|
||||
// old "page everything, then slice the wrong end").
|
||||
assert_eq!(tail_plan(5000, 3), (100, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_plan_large_thread_spans_two_pages() {
|
||||
// 5000 total, tail 60 → start_idx=4940 on page 99, item 5000
|
||||
// on page 100. Two fetches even for n > PAGE_SIZE.
|
||||
assert_eq!(tail_plan(5000, 60), (99, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_plan_n_exceeds_total() {
|
||||
// 5 total, tail 100 → cap n at total; same plan as the
|
||||
// small-thread case above.
|
||||
assert_eq!(tail_plan(5, 100), (1, 1));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ pub mod pr;
|
|||
pub mod pr_create;
|
||||
pub mod pr_reviews;
|
||||
pub mod subscription;
|
||||
pub mod timeline;
|
||||
pub mod tree_sha;
|
||||
pub mod view;
|
||||
|
||||
|
|
|
|||
350
hive-forge/src/verbs/timeline.rs
Normal file
350
hive-forge/src/verbs/timeline.rs
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
//! `timeline <number> [--limit N]` — list timeline events on an
|
||||
//! issue or PR. Closes #783 (last piece of the #694 epic: agents kept
|
||||
//! falling back to curl for "who closed this?" / "when was this
|
||||
//! labelled?" archaeology). Composes naturally with `view <n>` /
|
||||
//! `comments <n>` — separate verb keeps the existing shapes stable.
|
||||
//!
|
||||
//! Forgejo's `/issues/{n}/timeline` endpoint returns BOTH the actual
|
||||
//! comments AND the event entries (label, assignee, close, reopen,
|
||||
//! pull_push, etc.) in chronological order. We render each row in
|
||||
//! a human-readable form by default; pass the global `--json` flag
|
||||
//! for the raw API shape.
|
||||
//!
|
||||
//! `--tail N` is a follow-up (the timeline endpoint doesn't expose a
|
||||
//! total-count field so we can't use the count-then-page trick that
|
||||
//! `comments --tail` lands in #770; future shape probably mirrors
|
||||
//! `comments --tail` once Forgejo grows a `count` query or we accept
|
||||
//! the trailing-slice cost).
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args as ClapArgs;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::verbs::print_json;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Issue or PR number.
|
||||
number: u64,
|
||||
/// Page size (Forgejo caps at 50). Returns the first `N` events.
|
||||
#[arg(long, default_value_t = 50)]
|
||||
limit: u64,
|
||||
}
|
||||
|
||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||
let repo = client.repo();
|
||||
let v = client.get_json(&format!(
|
||||
"/repos/{repo}/issues/{}/timeline?limit={}",
|
||||
args.number, args.limit
|
||||
))?;
|
||||
if client.json_mode() {
|
||||
return print_json(&v);
|
||||
}
|
||||
let Some(events) = v.as_array() else {
|
||||
return print_json(&v);
|
||||
};
|
||||
for ev in events {
|
||||
print_event(ev);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render one timeline event as a single `**actor @ ts**: summary`
|
||||
/// line. Comment rows inline their full body; structured event types
|
||||
/// (label, assignees, close, etc.) get a one-line human summary
|
||||
/// derived from the per-type fields the API populates. Unknown /
|
||||
/// future types fall through to a `[<type>]` placeholder so a forge
|
||||
/// schema bump doesn't panic the verb — operator still sees that the
|
||||
/// event existed, with timestamp + actor.
|
||||
///
|
||||
/// Pure function (no I/O) so the tests below can pin the formatted
|
||||
/// output for every supported event type without re-implementing the
|
||||
/// per-arm dispatch. `print_event` is the only caller that adds the
|
||||
/// terminating newline.
|
||||
fn format_event(ev: &Value) -> String {
|
||||
let event_type = ev.get("type").and_then(Value::as_str).unwrap_or("?");
|
||||
let user = ev
|
||||
.get("user")
|
||||
.and_then(|u| u.get("login"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("?");
|
||||
let ts = ev.get("created_at").and_then(Value::as_str).unwrap_or("?");
|
||||
let summary = match event_type {
|
||||
"comment" => {
|
||||
// Comments get the full body inlined — matches `comments`
|
||||
// verb shape so the operator sees the same line they'd
|
||||
// get from the head-of-thread listing.
|
||||
ev.get("body")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned()
|
||||
}
|
||||
"label" => {
|
||||
// Forgejo encodes label add/remove via `body = "1"` (added)
|
||||
// or `body = "0"` (removed). Quirky but stable.
|
||||
let action = match ev.get("body").and_then(Value::as_str).unwrap_or("") {
|
||||
"1" => "added",
|
||||
"0" => "removed",
|
||||
_ => "changed",
|
||||
};
|
||||
let label = ev
|
||||
.get("label")
|
||||
.and_then(|l| l.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("?");
|
||||
format!("{action} label `{label}`")
|
||||
}
|
||||
"assignees" => {
|
||||
let assignee = ev
|
||||
.get("assignee")
|
||||
.and_then(|a| a.get("login"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("?");
|
||||
let removed = ev
|
||||
.get("removed_assignee")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
if removed {
|
||||
format!("unassigned @{assignee}")
|
||||
} else {
|
||||
format!("assigned @{assignee}")
|
||||
}
|
||||
}
|
||||
"review_request" => {
|
||||
let reviewer = ev
|
||||
.get("assignee")
|
||||
.and_then(|a| a.get("login"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("?");
|
||||
let removed = ev
|
||||
.get("removed_assignee")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
if removed {
|
||||
format!("removed review request from @{reviewer}")
|
||||
} else {
|
||||
format!("requested review from @{reviewer}")
|
||||
}
|
||||
}
|
||||
"close" => "closed".to_owned(),
|
||||
"reopen" => "reopened".to_owned(),
|
||||
"merge" => "merged".to_owned(),
|
||||
"milestone" => {
|
||||
let title = ev
|
||||
.get("milestone")
|
||||
.and_then(|x| x.get("title"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("?");
|
||||
format!("added to milestone `{title}`")
|
||||
}
|
||||
"demilestone" => {
|
||||
let title = ev
|
||||
.get("old_milestone")
|
||||
.and_then(|x| x.get("title"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("?");
|
||||
format!("removed from milestone `{title}`")
|
||||
}
|
||||
"pull_push" => {
|
||||
// Body is JSON: `{"is_force_push":bool,"commit_ids":[...]}`.
|
||||
// Defensive parse — fall through to a no-detail summary if
|
||||
// the shape ever drifts.
|
||||
let body_str = ev.get("body").and_then(Value::as_str).unwrap_or("");
|
||||
let parsed: Option<Value> = serde_json::from_str(body_str).ok();
|
||||
let n = parsed
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("commit_ids"))
|
||||
.and_then(Value::as_array)
|
||||
.map_or(0, Vec::len);
|
||||
let force = parsed
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("is_force_push"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
if force {
|
||||
format!("force-pushed {n} commit(s)")
|
||||
} else {
|
||||
format!("pushed {n} commit(s)")
|
||||
}
|
||||
}
|
||||
"commit_ref" => {
|
||||
let sha = ev.get("ref_commit_sha").and_then(Value::as_str).unwrap_or("");
|
||||
let short: String = sha.chars().take(7).collect();
|
||||
if short.is_empty() {
|
||||
"referenced from a commit".to_owned()
|
||||
} else {
|
||||
format!("referenced from commit {short}")
|
||||
}
|
||||
}
|
||||
"comment_ref" | "issue_ref" => "referenced from another issue/PR".to_owned(),
|
||||
"changed_target_branch" => "changed target branch".to_owned(),
|
||||
"review" => "submitted a review".to_owned(),
|
||||
"lock" => "locked the conversation".to_owned(),
|
||||
"unlock" => "unlocked the conversation".to_owned(),
|
||||
// Future / unknown types: surface the raw label so we don't
|
||||
// pretend nothing happened. Operator sees `[deploy_status]` or
|
||||
// whatever new event a forge bump invents.
|
||||
other => format!("[{other}]"),
|
||||
};
|
||||
format!("**{user} @ {ts}**: {summary}")
|
||||
}
|
||||
|
||||
/// Print one event followed by a blank line spacer. Thin wrapper
|
||||
/// around `format_event` so the tests can pin per-arm output without
|
||||
/// duplicating the dispatch.
|
||||
fn print_event(ev: &Value) {
|
||||
println!("{}", format_event(ev));
|
||||
println!();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Tests call `format_event` directly so any new event-type arm
|
||||
//! added in `print_event`'s dispatch is automatically covered by
|
||||
//! the rendering path (no parallel test-side dispatch to keep in
|
||||
//! sync). Argus on PR #798 🟡: "extract a `format_event(ev) ->
|
||||
//! String` helper and test that function directly instead of
|
||||
//! duplicating the logic" — addressed.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn comment_renders_body_inline() {
|
||||
let ev = serde_json::json!({
|
||||
"type": "comment",
|
||||
"user": { "login": "iris" },
|
||||
"created_at": "2026-05-31T12:00:00Z",
|
||||
"body": "looks good to me",
|
||||
});
|
||||
assert_eq!(format_event(&ev), "**iris @ 2026-05-31T12:00:00Z**: looks good to me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_added_renders_action_and_name() {
|
||||
let ev = serde_json::json!({
|
||||
"type": "label",
|
||||
"user": { "login": "triage" },
|
||||
"created_at": "2026-05-31T12:00:00Z",
|
||||
"body": "1",
|
||||
"label": { "name": "area:harness" },
|
||||
});
|
||||
assert_eq!(
|
||||
format_event(&ev),
|
||||
"**triage @ 2026-05-31T12:00:00Z**: added label `area:harness`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_removed_renders_removed_action() {
|
||||
let ev = serde_json::json!({
|
||||
"type": "label",
|
||||
"user": { "login": "mara" },
|
||||
"created_at": "2026-05-31T12:00:00Z",
|
||||
"body": "0",
|
||||
"label": { "name": "needs-review" },
|
||||
});
|
||||
assert_eq!(
|
||||
format_event(&ev),
|
||||
"**mara @ 2026-05-31T12:00:00Z**: removed label `needs-review`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_event_renders_one_word_summary() {
|
||||
let ev = serde_json::json!({
|
||||
"type": "close",
|
||||
"user": { "login": "mara" },
|
||||
"created_at": "2026-05-31T12:00:00Z",
|
||||
});
|
||||
assert_eq!(format_event(&ev), "**mara @ 2026-05-31T12:00:00Z**: closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignees_added_and_removed() {
|
||||
let added = serde_json::json!({
|
||||
"type": "assignees",
|
||||
"user": { "login": "triage" },
|
||||
"created_at": "2026-05-31T12:00:00Z",
|
||||
"assignee": { "login": "damocles" },
|
||||
"removed_assignee": false,
|
||||
});
|
||||
assert_eq!(
|
||||
format_event(&added),
|
||||
"**triage @ 2026-05-31T12:00:00Z**: assigned @damocles"
|
||||
);
|
||||
let removed = serde_json::json!({
|
||||
"type": "assignees",
|
||||
"user": { "login": "triage" },
|
||||
"created_at": "2026-05-31T12:00:00Z",
|
||||
"assignee": { "login": "damocles" },
|
||||
"removed_assignee": true,
|
||||
});
|
||||
assert_eq!(
|
||||
format_event(&removed),
|
||||
"**triage @ 2026-05-31T12:00:00Z**: unassigned @damocles"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_push_counts_commits_and_marks_force() {
|
||||
let normal = serde_json::json!({
|
||||
"type": "pull_push",
|
||||
"user": { "login": "damocles" },
|
||||
"created_at": "2026-05-31T12:00:00Z",
|
||||
"body": r#"{"is_force_push":false,"commit_ids":["a","b","c"]}"#,
|
||||
});
|
||||
assert_eq!(
|
||||
format_event(&normal),
|
||||
"**damocles @ 2026-05-31T12:00:00Z**: pushed 3 commit(s)"
|
||||
);
|
||||
let forced = serde_json::json!({
|
||||
"type": "pull_push",
|
||||
"user": { "login": "damocles" },
|
||||
"created_at": "2026-05-31T12:00:00Z",
|
||||
"body": r#"{"is_force_push":true,"commit_ids":["a"]}"#,
|
||||
});
|
||||
assert_eq!(
|
||||
format_event(&forced),
|
||||
"**damocles @ 2026-05-31T12:00:00Z**: force-pushed 1 commit(s)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_ref_truncates_sha_to_seven() {
|
||||
let ev = serde_json::json!({
|
||||
"type": "commit_ref",
|
||||
"user": { "login": "damocles" },
|
||||
"created_at": "2026-05-31T12:00:00Z",
|
||||
"ref_commit_sha": "abcdef0123456789abcdef0123456789abcdef01",
|
||||
});
|
||||
assert_eq!(
|
||||
format_event(&ev),
|
||||
"**damocles @ 2026-05-31T12:00:00Z**: referenced from commit abcdef0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_event_type_renders_bracketed_placeholder() {
|
||||
// Future-proofing: a forge schema bump that adds a new event
|
||||
// type shouldn't silently swallow the row.
|
||||
let ev = serde_json::json!({
|
||||
"type": "deploy_status",
|
||||
"user": { "login": "ci-bot" },
|
||||
"created_at": "2026-05-31T12:00:00Z",
|
||||
});
|
||||
assert_eq!(
|
||||
format_event(&ev),
|
||||
"**ci-bot @ 2026-05-31T12:00:00Z**: [deploy_status]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_user_falls_back_to_placeholder() {
|
||||
// Defensive: forge has been known to omit `user` on bot events.
|
||||
let ev = serde_json::json!({
|
||||
"type": "close",
|
||||
"created_at": "2026-05-31T12:00:00Z",
|
||||
});
|
||||
assert_eq!(format_event(&ev), "**? @ 2026-05-31T12:00:00Z**: closed");
|
||||
}
|
||||
}
|
||||
|
|
@ -241,26 +241,20 @@ pub struct InboxRow {
|
|||
pub in_reply_to: Option<i64>,
|
||||
}
|
||||
|
||||
/// One delivered message in a `Recv` response. The unified
|
||||
/// `Recv { max }` always returns a `Vec<DeliveredMessage>` — single
|
||||
/// pop = a one-element vec, batch = up to `max` elements, idle =
|
||||
/// empty. Each row carries the broker's id + redelivered flag so the
|
||||
/// harness can drive `AckTurn` and surface the "may already be
|
||||
/// handled" hint per-row.
|
||||
/// One delivered message in a `Recv` response.
|
||||
/// See `docs/conventions.md::Broker delivery + ack cycle` for the
|
||||
/// full delivery/ack/requeue story.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeliveredMessage {
|
||||
pub from: String,
|
||||
pub body: String,
|
||||
/// Broker row id, mirrored from the `Delivery` struct. Opaque to
|
||||
/// claude but tracked by the harness so the broker's in-memory
|
||||
/// unacked list can be drained on `AckTurn`. Marked `default` for
|
||||
/// wire backwards-compat — pre-feature peers parse to 0.
|
||||
/// Broker row id, tracked by the harness for `AckTurn`. Opaque to
|
||||
/// claude. `default` for wire backwards-compat.
|
||||
#[serde(default)]
|
||||
pub id: i64,
|
||||
/// `true` when this row was previously popped, never acked, and
|
||||
/// resurfaced by `RequeueInflight`. The format helper prepends the
|
||||
/// "may already be handled" hint to the rendered body so claude
|
||||
/// sees the warning per-message in the batch.
|
||||
/// `true` if this row was resurfaced by `RequeueInflight` (previously
|
||||
/// popped, never acked). Formatter prepends a "may already be handled"
|
||||
/// hint when set.
|
||||
#[serde(default)]
|
||||
pub redelivered: bool,
|
||||
/// Row-id of the message this is a reply to, if any.
|
||||
|
|
@ -371,22 +365,12 @@ pub enum AgentRequest {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
in_reply_to: Option<i64>,
|
||||
},
|
||||
/// Pop pending messages from this agent's inbox. Always returns
|
||||
/// a list (`Messages { messages }`) — empty when nothing's
|
||||
/// pending. `max` caps the batch size (default 1 = single-message
|
||||
/// behaviour, server-side cap 32). `wait_seconds` long-polls for
|
||||
/// the first message; once one arrives (or one is already
|
||||
/// pending), the call drains up to `max` in total before
|
||||
/// returning. Same delivery + ack bookkeeping per row as before:
|
||||
/// `delivered_at = NOW`, tracked on the per-recipient
|
||||
/// `unacked_ids` list (the next `AckTurn` closes them out), and
|
||||
/// each row carries `redelivered = true` if `RequeueInflight`
|
||||
/// resurfaced it.
|
||||
/// Pop pending messages from this agent's inbox.
|
||||
/// Delivery + ack cycle: see
|
||||
/// `docs/conventions.md::Broker delivery + ack cycle`.
|
||||
Recv {
|
||||
#[serde(default)]
|
||||
wait_seconds: Option<u64>,
|
||||
/// Maximum number of messages to pop. None = 1 (single).
|
||||
/// Server-side cap is 32; values above clamp silently.
|
||||
#[serde(default)]
|
||||
max: Option<u32>,
|
||||
},
|
||||
|
|
@ -498,27 +482,14 @@ pub enum AgentRequest {
|
|||
/// row. The manager surface uses the same wire variant but
|
||||
/// accepts any id.
|
||||
CancelLooseEnd { kind: CancelLooseEndKind, id: i64 },
|
||||
/// Mark every message popped by this agent since the last `AckTurn`
|
||||
/// as fully handled. Fired by the harness after `TurnOutcome::Ok`
|
||||
/// — claude doesn't see this surface, it's harness↔broker only.
|
||||
/// On `TurnOutcome::Failed` the harness intentionally skips this
|
||||
/// call, so the unacked rows stay in-flight in the DB and get
|
||||
/// requeued by the next `RequeueInflight` on harness boot. Tracks
|
||||
/// the popped-id list in-memory on the broker side; no payload
|
||||
/// needed (the broker knows which ids it handed to this
|
||||
/// recipient).
|
||||
/// Mark every message popped since the last `AckTurn` as handled.
|
||||
/// Harness↔broker pairing fired after `TurnOutcome::Ok`. See
|
||||
/// `docs/conventions.md::Broker delivery + ack cycle`.
|
||||
AckTurn,
|
||||
/// Requeue every message the broker handed to this agent that
|
||||
/// never got acked. Fired by the harness exactly once at boot,
|
||||
/// before entering the serve loop — catches the
|
||||
/// crashed-mid-turn / OOM-killed / container-restarted cases
|
||||
/// where a previous harness session popped messages but never
|
||||
/// drove them to a clean turn-end. Resets `delivered_at` on each
|
||||
/// row back to NULL (so the next `Recv` pops it) and remembers
|
||||
/// the id in a per-recipient in-memory set so the next `Recv`
|
||||
/// can tag the message with `redelivered: true` (the harness
|
||||
/// then prepends a "may already be handled" hint to the wake
|
||||
/// prompt). Idempotent + cheap when there's nothing in flight.
|
||||
/// Requeue every popped-but-unacked message back into the inbox.
|
||||
/// Harness fires this once at boot to recover from
|
||||
/// crashed-mid-turn sessions. See
|
||||
/// `docs/conventions.md::Broker delivery + ack cycle`.
|
||||
RequeueInflight,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,38 +4,24 @@
|
|||
librsvg,
|
||||
}:
|
||||
|
||||
# Static assets the rust workspace reads at runtime: the project's
|
||||
# branding SVG/PNG family + the claude system-prompt template +
|
||||
# claude-settings JSON. Lives as its own derivation so a tweak to
|
||||
# branding/agent-configs.svg or hive-ag3nt/prompts/system.md doesn't
|
||||
# invalidate the rust derivation's cargo cache (closes #555 follow-up
|
||||
# to #538 — naersk previously paired with `src = ./.;` invalidating
|
||||
# every rust build on any branding/prompt edit; crane inherited that
|
||||
# coupling and this split breaks it cleanly).
|
||||
# Branding SVG/PNG family + claude prompts, split out from the rust
|
||||
# workspace so a tweak here doesn't invalidate the rust cargo cache.
|
||||
# Rationale + agent-configs PNG rendering: docs/gotchas.md::Split asset
|
||||
# derivations away from the rust workspace.
|
||||
#
|
||||
# Output layout:
|
||||
#
|
||||
# $out/share/hyperhive/branding/{hyperhive.svg, hyperhive.png,
|
||||
# agent-configs.svg, agent-configs.png}
|
||||
# $out/share/hyperhive/branding/{hyperhive,agent-configs}.{svg,png}
|
||||
# $out/share/hyperhive/prompts/{system.md, claude-settings.json}
|
||||
#
|
||||
# The agent-configs PNG is rendered at build time from the SVG via
|
||||
# rsvg-convert — same shape as the old `hive-c0re/build.rs` rasteriser,
|
||||
# just hoisted into nix so the librsvg dependency stays *here* instead
|
||||
# of in the rust derivation's nativeBuildInputs.
|
||||
|
||||
stdenv.mkDerivation {
|
||||
pname = "hyperhive-assets";
|
||||
version = "0.1.0";
|
||||
# `src` is intentionally narrow — only branding/ + the hive-ag3nt/prompts/
|
||||
# subdir, NOT the whole tree. Keeps the input hash decoupled from
|
||||
# rust source / docs / nix module edits.
|
||||
# Narrow `srcs` (branding/ + hive-ag3nt/prompts/) is what decouples
|
||||
# this derivation's input hash from the rest of the tree.
|
||||
srcs = [
|
||||
../branding
|
||||
../hive-ag3nt/prompts
|
||||
];
|
||||
# `unpackPhase` would normally extract each src to its own dir; we
|
||||
# just want them side-by-side, so hand-roll a flat copy.
|
||||
unpackPhase = ''
|
||||
runHook preUnpack
|
||||
cp -r ${../branding} branding
|
||||
|
|
@ -46,10 +32,8 @@ stdenv.mkDerivation {
|
|||
|
||||
nativeBuildInputs = [ librsvg ];
|
||||
|
||||
# No real build step — just render the agent-configs PNG alongside
|
||||
# its SVG. 300×300 matches branding/hyperhive.png, which is the size
|
||||
# Forgejo's avatar endpoint accepts without resampling on upload (the
|
||||
# same constraint hive-c0re/build.rs encoded).
|
||||
# 300×300 matches branding/hyperhive.png — the size Forgejo's avatar
|
||||
# endpoint accepts without resampling on upload.
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
rsvg-convert --width 300 --height 300 \
|
||||
|
|
|
|||
|
|
@ -4,34 +4,15 @@
|
|||
self,
|
||||
nixosSystem,
|
||||
}:
|
||||
# Options documentation for hyperhive's NixOS module surfaces.
|
||||
# Closes #616. HTML output added per mara on internal-requests #8.
|
||||
#
|
||||
# Three rendering layers:
|
||||
# CommonMark — `pkgs.nixosOptionsDoc.optionsCommonMark`. Source of
|
||||
# truth; kept as `.md` files in the bundle.
|
||||
# HTML — `pkgs.cmark-gfm` over the CommonMark output, wrapped
|
||||
# in a minimal inline-CSS template. Primary surface; the
|
||||
# bundle's `index.html` / `host.html` / `agent.html` are
|
||||
# what the operator's nginx serves from
|
||||
# `hyperhive.darkest.space/options/`.
|
||||
#
|
||||
# Three output trees consumed by `flake.nix`:
|
||||
# docs-host — operator-facing host-module options
|
||||
# (`services.hive-c0re.*`, `hyperhive.{domain,forge,matrix}.*`)
|
||||
# docs-agent — per-agent harness options
|
||||
# (`hyperhive.{model,allowedRecipients,extraMcpServers,…}`)
|
||||
# docs — bundled static site (index + host + agent, .html + .md)
|
||||
#
|
||||
# All asset paths inside the rendered HTML are relative (e.g.
|
||||
# `./host.html`) so the bundle can be mounted at any URL prefix
|
||||
# without rewriting; styles are inline so there's no second-fetch
|
||||
# request for the operator's browser.
|
||||
# Nix options reference: `pkgs.nixosOptionsDoc` over two evaluated
|
||||
# module trees, rendered as CommonMark + HTML + bundled static site
|
||||
# the operator's nginx serves from `/options/`. Full pipeline +
|
||||
# subtree-pick / output-tree rationale: docs/gotchas.md::Nix options
|
||||
# reference.
|
||||
let
|
||||
# Evaluate the host module under a stub NixOS system. Stubs satisfy
|
||||
# the few hard-required options (filesystems, stateVersion) without
|
||||
# actually enabling the hive — we only want the option *declarations*
|
||||
# to evaluate, not the config.
|
||||
# Stub host system: every hyperhive subsystem `mkForce false` so
|
||||
# heavy build inputs (matrix container, forge, etc.) stay out of
|
||||
# the eval — only option *declarations* matter for the doc walk.
|
||||
hostEval = nixosSystem {
|
||||
system = pkgs.stdenv.hostPlatform.system;
|
||||
modules = [
|
||||
|
|
@ -46,10 +27,6 @@ let
|
|||
};
|
||||
boot.loader.grub.enable = false;
|
||||
system.stateVersion = "25.11";
|
||||
# Force-disable every hyperhive subsystem so config evaluation
|
||||
# doesn't pull in heavy build inputs (matrix container, forge,
|
||||
# etc.). Options are still fully declared either way — that's
|
||||
# what nixosOptionsDoc traverses.
|
||||
services.hyperhive.enable = lib.mkForce false;
|
||||
services.hyperhive.forge.enable = lib.mkForce false;
|
||||
services.hyperhive.matrix.enable = lib.mkForce false;
|
||||
|
|
@ -59,14 +36,12 @@ let
|
|||
];
|
||||
};
|
||||
|
||||
# Agent options live in the already-evaluated `agent-base` container
|
||||
# config. Reusing it avoids re-evaluating the harness module against
|
||||
# a fresh stub — the options tree is identical to what a real agent
|
||||
# container sees.
|
||||
# Reuse the already-evaluated agent-base config — its options tree is
|
||||
# identical to what a real agent container sees, no second eval needed.
|
||||
agentEval = self.nixosConfigurations.agent-base;
|
||||
|
||||
# Strip the nix-store prefix from option declaration paths and rewrite
|
||||
# them as forge URLs so the rendered docs link back to the source.
|
||||
# Rewrite option declaration paths from nix-store absolute paths to
|
||||
# forge URLs so rendered docs link back to source.
|
||||
forgeRoot = "https://forge.darkest.space/hyperhive/hyperhive/src/branch/main";
|
||||
storePrefix = toString self + "/";
|
||||
transformOptions =
|
||||
|
|
@ -90,10 +65,10 @@ let
|
|||
) opt.declarations;
|
||||
};
|
||||
|
||||
# Filter an evaluated `options` tree down to a set of top-level
|
||||
# subtrees we care about. Anything outside the listed roots is
|
||||
# dropped — keeps the rendered docs focused on hyperhive's surface
|
||||
# instead of NixOS's 10k+ default options.
|
||||
# Filter to a set of top-level subtree roots — keeps the rendered docs
|
||||
# focused on hyperhive's surface instead of NixOS's 10k+ default
|
||||
# options. Root choice matters: see docs/gotchas.md::Nix options
|
||||
# reference for the post-#615 services.hyperhive consolidation history.
|
||||
pickSubtrees =
|
||||
options: roots:
|
||||
let
|
||||
|
|
@ -106,12 +81,6 @@ let
|
|||
in
|
||||
lib.foldl' lib.recursiveUpdate { } (map pick roots);
|
||||
|
||||
# Post-#615, host options live entirely under `services.hyperhive.*`
|
||||
# (closes #630). Pre-#615 had a mix of `hyperhive.*` (forge, matrix,
|
||||
# domain) and `services.hive-c0re.*` — picking against those roots
|
||||
# silently produced an empty options tree on current main, so the
|
||||
# rendered host page was just the template chrome with no `<h2>`
|
||||
# option headers underneath.
|
||||
hostOptions = pickSubtrees hostEval.options [
|
||||
[
|
||||
"services"
|
||||
|
|
@ -133,8 +102,7 @@ let
|
|||
inherit transformOptions;
|
||||
};
|
||||
|
||||
# Plain-markdown page (with a short header). Source of truth; the
|
||||
# HTML version is rendered from this.
|
||||
# CommonMark .md = source of truth; HTML is rendered from this.
|
||||
mkMarkdownPage =
|
||||
name: title: doc:
|
||||
pkgs.runCommand "hyperhive-${name}.md" { } ''
|
||||
|
|
@ -149,16 +117,11 @@ let
|
|||
} > $out
|
||||
'';
|
||||
|
||||
# Inline-stylesheet, loaded as plain text from `./style.css` so it's
|
||||
# editable with normal CSS tooling (#625). Inlined into every page
|
||||
# so the bundle doesn't depend on a second HTTP fetch — keeps the
|
||||
# `/options/` mount trivial for nginx (no MIME guessing for separate
|
||||
# .css files, no cache-busting needed when this updates).
|
||||
# Loaded as text so it's editable with normal CSS tooling and
|
||||
# inlined into every page (no second-fetch dependency).
|
||||
styleCSS = builtins.readFile ./style.css;
|
||||
|
||||
# HTML page: CommonMark → cmark-gfm → minimal template with inline
|
||||
# CSS + relative-only links. cmark-gfm rather than plain cmark so
|
||||
# any future tables / autolinks Just Work without revisiting.
|
||||
# CommonMark → cmark-gfm → minimal template, inline CSS, relative links.
|
||||
mkHtmlPage =
|
||||
name: title: doc:
|
||||
pkgs.runCommand "hyperhive-${name}.html" { nativeBuildInputs = [ pkgs.cmark-gfm ]; } ''
|
||||
|
|
@ -192,9 +155,7 @@ let
|
|||
} > $out
|
||||
'';
|
||||
|
||||
# Landing page — same template shape as the option pages but
|
||||
# hand-authored content (short intro + cross-links). Kept tight; the
|
||||
# detail lives on the two option pages.
|
||||
# Landing page — same template shape, hand-authored intro + cross-links.
|
||||
indexHTML = pkgs.runCommand "hyperhive-docs-index.html" { } ''
|
||||
{
|
||||
echo '<!doctype html>'
|
||||
|
|
@ -240,14 +201,11 @@ let
|
|||
agentMD = mkMarkdownPage "docs-agent" "hyperhive — per-agent options" agentDoc;
|
||||
in
|
||||
{
|
||||
# Individual page outputs (HTML is the primary surface; the .md
|
||||
# source is one `nix build` step away if needed).
|
||||
host = hostHTML;
|
||||
agent = agentHTML;
|
||||
|
||||
# Bundled static site for nginx to serve at `/options/`. Asset paths
|
||||
# are all relative, no root-absolute references, so the prefix can
|
||||
# change without rebuild.
|
||||
# Bundled static site nginx serves at `/options/`. Asset paths are
|
||||
# all relative so the prefix can change without rebuild.
|
||||
bundle = pkgs.runCommand "hyperhive-options-docs" { } ''
|
||||
mkdir -p $out
|
||||
cp ${indexHTML} $out/index.html
|
||||
|
|
|
|||
|
|
@ -238,28 +238,9 @@ in
|
|||
managerToplevel
|
||||
];
|
||||
|
||||
# Per-container web UIs share the host's network namespace and need
|
||||
# their ports reachable when there's no gateway in front. Every
|
||||
# container — including the manager (#753 dropped the pre-#753
|
||||
# "manager pinned at 8000" special case) — hashes into
|
||||
# 8100..8999 via `lifecycle::agent_web_port`'s FNV-1a, so a single
|
||||
# range opening covers all of them.
|
||||
#
|
||||
# The dashboard port (`cfg.dashboardPort`, default 7000) is *not*
|
||||
# listed here — since #652 the dashboard binds `127.0.0.1` only,
|
||||
# so opening the firewall hole would be a no-op. Remote dashboard
|
||||
# access flows through hive-gateway (default-on); operators who
|
||||
# opt out of the gateway lose external dashboard reach by design —
|
||||
# the surface is privileged (approve / deny / destroy) and must
|
||||
# not be exposed without a real reverse proxy in front.
|
||||
#
|
||||
# When `services.hyperhive.gateway.enable = true` (the default), the
|
||||
# gateway nginx is the sole external entry point and proxies to
|
||||
# `127.0.0.1:7000` etc. internally — leaving the per-agent ports
|
||||
# open in the host firewall would defeat the gateway's "single
|
||||
# front door" story (closes #621). Operators who opt out of the
|
||||
# gateway still get those direct ports opened so the legacy
|
||||
# `http://<host>:<port>/` flow works.
|
||||
# Open the per-agent web-port range when the gateway is *off* —
|
||||
# otherwise the gateway nginx is the sole external entry point.
|
||||
# See `docs/gateway.md::Firewall posture (host-level)`.
|
||||
networking.firewall = lib.mkIf (!config.services.hyperhive.gateway.enable) {
|
||||
allowedTCPPortRanges = [
|
||||
{
|
||||
|
|
@ -307,20 +288,11 @@ in
|
|||
HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName;
|
||||
}
|
||||
// lib.optionalAttrs config.services.hyperhive.forge.enable {
|
||||
# Agents poll this URL for Forgejo notifications + run all
|
||||
# `hive-forge` calls against it. Pinned to `127.0.0.1` for
|
||||
# the in-cluster path: every agent container shares the
|
||||
# host's network namespace so loopback reaches the forge
|
||||
# container directly, no DNS lookup needed (closes #761).
|
||||
#
|
||||
# Post-#754 `cfg.domain` defaults to `forge.<hive-domain>`
|
||||
# for the external gateway vhost. Using that value here
|
||||
# would route every in-cluster call through DNS for an
|
||||
# external hostname agents can't resolve from inside their
|
||||
# nspawn — every `hive-forge` invocation would fail with
|
||||
# "Name or service not known". The external gateway URL
|
||||
# is for operator browsers + cross-host clients; internal
|
||||
# callers stay on loopback.
|
||||
# Loopback for in-cluster calls (agents share host netns;
|
||||
# external `forge.<hive>` sub-domain isn't DNS-resolvable
|
||||
# from inside nspawn). See
|
||||
# `docs/gateway.md::HIVE_FORGE_URL: loopback for in-cluster,
|
||||
# sub-domain for the operator`.
|
||||
HIVE_FORGE_URL = "http://127.0.0.1:${toString config.services.hyperhive.forge.httpPort}";
|
||||
}
|
||||
// lib.optionalAttrs config.services.hyperhive.matrix.gui.enable {
|
||||
|
|
|
|||
|
|
@ -30,21 +30,10 @@ let
|
|||
effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl;
|
||||
in
|
||||
{
|
||||
# Private Forgejo for hyperhive agents, wrapped in a nixos-container
|
||||
# so it doesn't fight any `services.forgejo` the operator already
|
||||
# runs on the host. The container shares the host network namespace
|
||||
# (`privateNetwork = false`) so agents reach the forge at
|
||||
# `http://localhost:<httpPort>` without any extra plumbing —
|
||||
# nixos-container is just here for state + systemd-unit isolation,
|
||||
# not network isolation.
|
||||
#
|
||||
# Container name is `hive-forge` (not `h-*`), so hive-c0re's
|
||||
# lifecycle scanner ignores it; the operator manages it via the
|
||||
# standard `nixos-container` CLI.
|
||||
#
|
||||
# State lives at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/`
|
||||
# and survives container restart / host reboot. To wipe, destroy the
|
||||
# container.
|
||||
# Private Forgejo in a `hive-forge` nixos-container, shared host
|
||||
# netns so agents reach it on loopback. State at
|
||||
# `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives
|
||||
# restart. See `docs/gateway.md::hive-forge container shape`.
|
||||
|
||||
options.services.hyperhive.forge = {
|
||||
enable = lib.mkOption {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,56 @@ let
|
|||
{ }
|
||||
else
|
||||
builtins.fromJSON (builtins.readFile cfg.agentPortsFile);
|
||||
|
||||
# Static error pages for `/agent/<name>/` mishaps (#755). Mara's
|
||||
# call: useful pages instead of nginx's default 404/502 for routes
|
||||
# we've already special-cased. See `docs/gateway.md::Per-agent
|
||||
# error pages` for the design rationale + page-vs-status semantics.
|
||||
agentErrorPagesDir = pkgs.runCommand "hyperhive-agent-error-pages" { } ''
|
||||
mkdir -p $out
|
||||
cat > $out/not-found.html <<'EOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>agent not found ◆ hyperhive</title>
|
||||
<style>
|
||||
body { background: #1e1e2e; color: #cdd6f4; font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 4rem 1rem; text-align: center; }
|
||||
h1 { color: #cba6f7; font-size: 1.5rem; margin: 0 0 0.5rem; }
|
||||
p { max-width: 32rem; margin: 0.5rem auto; color: #a6adc8; }
|
||||
code { background: #313244; color: #f5c2e7; padding: 0.1rem 0.35rem; border-radius: 0.2rem; }
|
||||
a { color: #89b4fa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>◆ agent not found</h1>
|
||||
<p>No agent matches the requested <code>/agent/<name>/</code> path on this hive.</p>
|
||||
<p>Operator: check the agent name in <a href="/">the dashboard</a> — the gateway picks up new agents on the next <code>nixos-rebuild switch</code>.</p>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
cat > $out/unreachable.html <<'EOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>agent unreachable ◆ hyperhive</title>
|
||||
<style>
|
||||
body { background: #1e1e2e; color: #cdd6f4; font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 4rem 1rem; text-align: center; }
|
||||
h1 { color: #f9e2af; font-size: 1.5rem; margin: 0 0 0.5rem; }
|
||||
p { max-width: 32rem; margin: 0.5rem auto; color: #a6adc8; }
|
||||
code { background: #313244; color: #f5c2e7; padding: 0.1rem 0.35rem; border-radius: 0.2rem; }
|
||||
a { color: #89b4fa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>◆ agent unreachable</h1>
|
||||
<p>The agent's harness web server isn't responding. Container restarting, or the agent crashed.</p>
|
||||
<p>Operator: <a href="/">dashboard</a> → check the container status / journal; the page will recover on retry once the harness is back up.</p>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
'';
|
||||
in
|
||||
{
|
||||
# Single nginx in front of every hyperhive web surface — dashboard,
|
||||
|
|
@ -268,8 +318,11 @@ in
|
|||
# per entry in `agentPortsTable`. Trailing-slash pair
|
||||
# strips the prefix; `X-Forwarded-Prefix` lets the
|
||||
# harness build absolute URLs when relative isn't
|
||||
# enough. See `docs/gateway.md` for the vhost map
|
||||
# + tuning rationale.
|
||||
# enough. `proxy_intercept_errors` + `error_page` rewrite
|
||||
# upstream 502/503/504 (container down / restarting) to
|
||||
# the static `unreachable.html` instead of nginx's
|
||||
# default Bad Gateway page (#755). See
|
||||
# `docs/gateway.md` for the vhost map + tuning.
|
||||
lib.mapAttrs' (name: port: {
|
||||
name = "/agent/${name}/";
|
||||
value = {
|
||||
|
|
@ -279,9 +332,44 @@ in
|
|||
proxy_set_header X-Forwarded-Prefix /agent/${name};
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 1d;
|
||||
proxy_intercept_errors on;
|
||||
error_page 502 503 504 = /__hive_agent_unreachable;
|
||||
'';
|
||||
};
|
||||
}) agentPortsTable
|
||||
//
|
||||
# `/agent/` catch-all (#755): hits when an operator
|
||||
# requests `/agent/<unknown>/...` — a name not in
|
||||
# `agentPortsTable`. Without this it falls through to
|
||||
# `/` (c0re dashboard upstream) which returns 404
|
||||
# with no useful context. Custom 404 page instead.
|
||||
{
|
||||
"/agent/" = {
|
||||
extraConfig = ''
|
||||
error_page 404 = /__hive_agent_not_found;
|
||||
return 404;
|
||||
'';
|
||||
};
|
||||
# Internal static-file locations the error_page
|
||||
# directives above point at. `internal` keeps
|
||||
# operators from hitting the file directly (only
|
||||
# nginx's error-handling can reach it); `alias`
|
||||
# serves the exact file regardless of request URI.
|
||||
"= /__hive_agent_not_found" = {
|
||||
extraConfig = ''
|
||||
internal;
|
||||
alias ${agentErrorPagesDir}/not-found.html;
|
||||
default_type text/html;
|
||||
'';
|
||||
};
|
||||
"= /__hive_agent_unreachable" = {
|
||||
extraConfig = ''
|
||||
internal;
|
||||
alias ${agentErrorPagesDir}/unreachable.html;
|
||||
default_type text/html;
|
||||
'';
|
||||
};
|
||||
}
|
||||
// {
|
||||
# Everything else proxies to hive-c0re. Upgrade
|
||||
# headers stay set so SSE (`/dashboard/stream`,
|
||||
|
|
|
|||
|
|
@ -9,26 +9,16 @@ let
|
|||
hyperhiveDomain = config.services.hyperhive.domain;
|
||||
effectiveServerName = if cfg.serverName != null then cfg.serverName else hyperhiveDomain;
|
||||
|
||||
# Three files are missing from `pkgs.fluffychat-web` because
|
||||
# `flutter341.buildFlutterApplication` doesn't run the dart
|
||||
# web-worker compile pass + doesn't run the native_imaging emscripten
|
||||
# build (#685). `fluffychat-web-imaging` below builds the latter from
|
||||
# source via `pkgs.emscripten`; the worker compile is inline in
|
||||
# `fluffychat-web-fixed.postInstall`. Drop both when nixpkgs's
|
||||
# flutter builder grows worker + emcc support upstream.
|
||||
# fluffychat-web build fixes: nixpkgs's `flutter341.buildFlutterApplication`
|
||||
# skips the dart web-worker compile + the emscripten native_imaging
|
||||
# build. Two derivations below cover both. Full rationale (why
|
||||
# passthru.pubspecLock.dependencySources, why `dontConfigure`, why
|
||||
# `make -C js`, why build-CWD-relative dart path): docs/matrix.md::
|
||||
# fluffychat-web build fixes.
|
||||
|
||||
# `Imaging.{js,wasm}` built from `native_imaging`'s C source via
|
||||
# emscripten. Source comes from
|
||||
# `pkgs.fluffychat-web.passthru.pubspecLock.dependencySources` so
|
||||
# there's no parallel hash pin — version auto-syncs with nixpkgs
|
||||
# bumps. Build closure +~3.6 GiB (emscripten LLVM); runtime closure
|
||||
# is just the two output files.
|
||||
fluffychat-web-imaging = pkgs.stdenv.mkDerivation {
|
||||
pname = "fluffychat-web-imaging";
|
||||
version = pkgs.fluffychat-web.passthru.pubspecLock.dependencyVersions.native_imaging;
|
||||
|
||||
# The pub-cache derivation that fluffychat-web's flutter build uses.
|
||||
# Already in the build closure; no `fetchurl` or own hash pin.
|
||||
src = pkgs.fluffychat-web.passthru.pubspecLock.dependencySources.native_imaging;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
|
|
@ -38,21 +28,18 @@ let
|
|||
jq
|
||||
];
|
||||
|
||||
# cmake config runs inside `js/Makefile` (via `emcmake cmake`) —
|
||||
# skip the default `configurePhase` which would try to invoke
|
||||
# cmake against the package root and fail (no CMakeLists at top).
|
||||
# cmake runs inside js/Makefile via `emcmake cmake`; the default
|
||||
# configurePhase would invoke cmake at the package root (no
|
||||
# CMakeLists) and fail.
|
||||
dontConfigure = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
# emscripten needs HOME + a writable cache dir for its sysroot
|
||||
# build (libc, libc++, etc. compiled to wasm on demand).
|
||||
# emscripten on-demand sysroot build needs writable HOME + cache.
|
||||
export HOME=$TMPDIR
|
||||
export EM_CACHE=$TMPDIR/.emscriptencache
|
||||
mkdir -p $EM_CACHE
|
||||
# `make -C js` keeps the build phase pwd at the source root so
|
||||
# installPhase doesn't have to know about the cd (argus 🟡 on
|
||||
# PR #697 v2 — robust against future reorders / `dontBuild`).
|
||||
# `make -C js` keeps pwd at source root for the installPhase.
|
||||
make -C js Imaging.js Imaging.wasm
|
||||
runHook postBuild
|
||||
'';
|
||||
|
|
@ -66,71 +53,37 @@ let
|
|||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Imaging.js + Imaging.wasm built from the native_imaging dart package for fluffychat-web (#685)";
|
||||
description = "Imaging.js + Imaging.wasm built from the native_imaging dart package for fluffychat-web";
|
||||
homepage = "https://pub.dev/packages/native_imaging";
|
||||
license = licenses.agpl3Plus;
|
||||
};
|
||||
};
|
||||
|
||||
# `pkgs.fluffychat-web` with #685's three missing files patched in
|
||||
# via postInstall. Mount point is `matrix.<hive>/` (#772); upstream
|
||||
# `--base-href "/"` is correct at sub-domain root, no override.
|
||||
fluffychat-web-fixed = pkgs.fluffychat-web.overrideAttrs (old: {
|
||||
# `dart` from the flutter341 closure (already pulled, no
|
||||
# incremental closure cost) so we can compile the web-worker
|
||||
# entry point that buildFlutterApplication skips.
|
||||
# dart from the flutter341 closure (already pulled, no incremental
|
||||
# cost) to compile the web-worker entry point.
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.flutter341.dart ];
|
||||
|
||||
postInstall =
|
||||
(old.postInstall or "")
|
||||
+ ''
|
||||
# `web/...` is relative to build CWD so dart's package_config
|
||||
# walk-up hits buildFlutterApplication's pub-get output (#685
|
||||
# / #733 fixup — `$src/web/...` would walk up to a read-only
|
||||
# store path with no `.dart_tool/`).
|
||||
${pkgs.flutter341.dart}/bin/dart compile js \
|
||||
-o $out/native_executor.js \
|
||||
web/native_executor.dart
|
||||
postInstall = (old.postInstall or "") + ''
|
||||
# `web/...` is BUILD-CWD-relative (not `$src/...`) so dart's
|
||||
# package_config walk-up hits buildFlutterApplication's
|
||||
# pub-get output `.dart_tool/`.
|
||||
${pkgs.flutter341.dart}/bin/dart compile js \
|
||||
-o $out/native_executor.js \
|
||||
web/native_executor.dart
|
||||
|
||||
install -m 644 ${fluffychat-web-imaging}/Imaging.js $out/Imaging.js
|
||||
install -m 644 ${fluffychat-web-imaging}/Imaging.wasm $out/Imaging.wasm
|
||||
'';
|
||||
install -m 644 ${fluffychat-web-imaging}/Imaging.js $out/Imaging.js
|
||||
install -m 644 ${fluffychat-web-imaging}/Imaging.wasm $out/Imaging.wasm
|
||||
'';
|
||||
});
|
||||
in
|
||||
{
|
||||
# Private Matrix homeserver (matrix-tuwunel — the official conduwuit
|
||||
# successor) for hyperhive agents, wrapped in a nixos-container so it
|
||||
# doesn't fight any existing `services.matrix-*` the operator may
|
||||
# already run on the host. Same shape as `nix/modules/hive-forge.nix`:
|
||||
# shared host netns (`privateNetwork = false`) so agents reach it at
|
||||
# `http://localhost:<httpPort>` (or via the configured server_name
|
||||
# for federation), nixos-container only here for state + systemd-unit
|
||||
# isolation.
|
||||
#
|
||||
# Container name `hive-matrix` (not `h-*`) so the lifecycle scanner
|
||||
# ignores it; operator manages via the standard `nixos-container` CLI.
|
||||
#
|
||||
# Persistent state at `/var/lib/nixos-containers/hive-matrix/var/lib/
|
||||
# matrix-tuwunel/` (survives container restart / host reboot). To
|
||||
# wipe, destroy the container.
|
||||
#
|
||||
# Initial rollout (#548): federation enabled (needed for multi-hive
|
||||
# swarms; trusted_servers starts empty so no actual federation traffic
|
||||
# leaves until peers are explicitly listed), registration enabled via
|
||||
# a `registration_token_file` known only to hive-c0re (so agents can't
|
||||
# self-register without going through the coordinator), e2ee disabled
|
||||
# per operator call (tracked for follow-up at #551).
|
||||
#
|
||||
# Provisioning model (matches `nix/modules/hive-forge.nix` shape):
|
||||
# hive-c0re generates a 32-byte random `registration_token` on first
|
||||
# boot, writes it to `/var/lib/hyperhive/matrix-register-token` (mode
|
||||
# 0600, root-only), and bind-mounts that file read-only into the
|
||||
# tuwunel container at the same path so tuwunel can read it via
|
||||
# `registration_token_file`. hive-c0re then uses the token to register
|
||||
# each agent account via the matrix-spec UIAA registration flow, and
|
||||
# persists the returned `access_token` to `<agent-state>/matrix-token`
|
||||
# so the agent's matrix MCP client can authenticate without ever
|
||||
# seeing the shared registration token.
|
||||
# Private matrix-tuwunel homeserver wrapped in a nixos-container,
|
||||
# optional fluffychat-web client at matrix.<hive>/. Container shape,
|
||||
# serverName vs gatewayHost split, provisioning flow (registration
|
||||
# token + LoadCredential), assertion rationale, initial rollout
|
||||
# settings: docs/matrix.md. Vhost map + discovery flow + tuning
|
||||
# knobs: docs/gateway.md.
|
||||
|
||||
options.services.hyperhive.matrix = {
|
||||
enable = lib.mkOption {
|
||||
|
|
@ -322,12 +275,10 @@ in
|
|||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# mara on #548: "there is no default, but it is required. add
|
||||
# assertion." — fail eval with a helpful message rather than
|
||||
# spawning a homeserver with a bogus server_name we can never
|
||||
# change later. `services.hyperhive.domain` is host-wide; matrix derives
|
||||
# the server_name from it (or from `cfg.serverName` if the
|
||||
# operator wants to override).
|
||||
# serverName must exist (mara on #548 — irrevocably embedded in
|
||||
# user/room IDs); gatewayHost may not be "" (argus 🟡 on #764 —
|
||||
# same footgun as forge.domain). docs/matrix.md::Assertion
|
||||
# rationale.
|
||||
assertions = [
|
||||
{
|
||||
assertion = hyperhiveDomain != null || cfg.serverName != null;
|
||||
|
|
@ -344,11 +295,6 @@ in
|
|||
'';
|
||||
}
|
||||
{
|
||||
# Same footgun as forge.domain (#754): empty string renders
|
||||
# `.<hive>` shaped garbage in both nginx server_name (treated
|
||||
# as wildcard catch-all, surprising) and /etc/hosts (invalid
|
||||
# entry). Argus 🟡 on #764 — fail loud here rather than ship
|
||||
# the surprising behaviour.
|
||||
assertion = cfg.gatewayHost == null || cfg.gatewayHost != "";
|
||||
message = ''
|
||||
services.hyperhive.matrix.gatewayHost = "" is rejected. The
|
||||
|
|
@ -361,22 +307,10 @@ in
|
|||
}
|
||||
];
|
||||
|
||||
# Generate the registration token at system activation time, BEFORE
|
||||
# the hive-matrix container would otherwise start with an empty
|
||||
# bind-mount target (argus nit on #565: nspawn creates an empty
|
||||
# file when the host path is missing, tuwunel reads it as
|
||||
# `registration_token_file=""` and rejects every registration
|
||||
# until the next restart). Idempotent: only writes when the file
|
||||
# doesn't exist. 32-byte hex = 64 chars, same shape hive-c0re's
|
||||
# `matrix::ensure_register_token` would produce.
|
||||
#
|
||||
# Ownership: plain `root:root 0600` — tuwunel inside the container
|
||||
# runs as a hardened dynamic user (#644) and reads the token via
|
||||
# systemd's `LoadCredential=` mechanism (see container config
|
||||
# below), so it never needs direct read access on the host-side
|
||||
# file. No `chown :tuwunel` / `chmod 0640` / GID-pin gymnastics
|
||||
# required (per iris on #644 8043, dropping the shape #649
|
||||
# shipped with).
|
||||
# Activation-time token generation (argus #565: the bind-mount
|
||||
# would otherwise hand tuwunel an empty file on first boot and
|
||||
# break every registration until restart). Idempotent;
|
||||
# docs/matrix.md::Provisioning flow.
|
||||
system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] ''
|
||||
tokenFile=${lib.escapeShellArg (toString cfg.registrationTokenFile)}
|
||||
if [ ! -s "$tokenFile" ]; then
|
||||
|
|
@ -385,25 +319,17 @@ in
|
|||
echo >> "$tokenFile"
|
||||
echo "hive-matrix: generated registration token at $tokenFile"
|
||||
fi
|
||||
# Always re-apply 0600 (idempotent on already-correct files;
|
||||
# also normalises any 0640 / world-readable carry-over from
|
||||
# pre-LoadCredential deployments).
|
||||
# Re-apply 0600 (normalises any pre-LoadCredential carry-over).
|
||||
chmod 0600 "$tokenFile"
|
||||
'';
|
||||
|
||||
containers.hive-matrix = {
|
||||
autoStart = true;
|
||||
ephemeral = false;
|
||||
# Share host netns — tuwunel's listeners look exactly like
|
||||
# host-side services, no port-forward plumbing, and agent
|
||||
# containers (also host netns) reach it via plain `localhost`.
|
||||
# Shared host netns — agents reach tuwunel at localhost:<port>.
|
||||
privateNetwork = false;
|
||||
# Read-only bind of the host-managed registration token so
|
||||
# tuwunel can resolve `registration_token_file` to a real
|
||||
# file inside the container. The activation script above
|
||||
# ensures the host path exists with a valid 64-char hex token
|
||||
# before any container starts, so the bind always finds real
|
||||
# content (no first-boot empty-file race; argus #565 nit).
|
||||
# Read-only bind of the host-managed registration token; tuwunel
|
||||
# reads it via systemd LoadCredential below (not directly).
|
||||
bindMounts.${cfg.registrationTokenFile} = {
|
||||
hostPath = cfg.registrationTokenFile;
|
||||
isReadOnly = true;
|
||||
|
|
@ -417,44 +343,28 @@ in
|
|||
package = cfg.package;
|
||||
settings.global = {
|
||||
server_name = effectiveServerName;
|
||||
# `address` is `listOf nonEmptyStr` upstream (multi-bind
|
||||
# support). Single-host bind goes through as a one-element list.
|
||||
# `address` + `port` are upstream `listOf` — wrap singles.
|
||||
address = [ "0.0.0.0" ];
|
||||
# `port` is `listOf port` upstream. Same shape.
|
||||
port = [ cfg.httpPort ];
|
||||
max_request_size = cfg.maxRequestSize;
|
||||
# Federation enabled at the protocol level so swarms
|
||||
# can be wired up later by extending `trustedServers`
|
||||
# without a homeserver restart. Empty trusted_servers
|
||||
# keeps it effectively closed until peers are listed.
|
||||
# Federation enabled at the protocol level; empty
|
||||
# trustedServers keeps it effectively closed.
|
||||
allow_federation = true;
|
||||
trusted_servers = cfg.trustedServers;
|
||||
# Token-gated registration: hive-c0re holds the token,
|
||||
# agents never see it. allow_registration must be true
|
||||
# for the token flow to engage; the absent
|
||||
# Token-gated registration. The absent
|
||||
# `yes_i_am_very_very_sure_…_open_registration_…` flag
|
||||
# keeps the server closed to anyone without the token.
|
||||
allow_registration = true;
|
||||
# Read the registration token via systemd's
|
||||
# `LoadCredential=` mechanism (wired below) instead of
|
||||
# the bind-mount path directly. systemd copies the host-
|
||||
# owned 0600 root:root file into a per-service
|
||||
# credentials dir owned by tuwunel's dynamic user with
|
||||
# mode 0400 — keeps `DynamicUser=true` + `PrivateUsers=true`
|
||||
# intact, no host-side `chown :tuwunel` / GID-pin
|
||||
# gymnastics required (#644 / iris on 8043).
|
||||
# LoadCredential below copies the host file into a
|
||||
# 0400 dynamic-user-owned path; tuwunel reads from there.
|
||||
registration_token_file = "/run/credentials/tuwunel.service/registration_token";
|
||||
# E2EE disabled in initial rollout per operator call
|
||||
# (#548) — re-enabling tracked at #551.
|
||||
# E2EE disabled in initial rollout (#548); re-enable at #551.
|
||||
allow_encryption = false;
|
||||
};
|
||||
};
|
||||
# `LoadCredential=<id>:<host-path>` makes systemd copy the
|
||||
# bind-mounted host file into `/run/credentials/tuwunel.service/<id>`
|
||||
# owned by the service's (dynamic) user with mode 0400 at
|
||||
# service start. The hardcoded path in `registration_token_file`
|
||||
# above is the systemd-stable credentials dir; see
|
||||
# `man systemd.exec` → LoadCredential.
|
||||
# Keeps DynamicUser=true + PrivateUsers=true intact — no
|
||||
# host-side chown :tuwunel / GID-pin gymnastics needed (#644 /
|
||||
# iris on 8043). See `man systemd.exec` → LoadCredential.
|
||||
systemd.services.tuwunel.serviceConfig.LoadCredential = [
|
||||
"registration_token:${toString cfg.registrationTokenFile}"
|
||||
];
|
||||
|
|
|
|||
|
|
@ -75,6 +75,40 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.web.useUnixSocket = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
example = true;
|
||||
description = ''
|
||||
When `true`, set `HIVE_WEB_SOCKET=/run/hive-agent/${userName}/web.sock`
|
||||
on the harness service env, which makes `web_ui::serve` bind a
|
||||
`UnixListener` at that path instead of the legacy TCP listener
|
||||
on `HIVE_PORT`. Closes the third hop of the #784 rollout: PR
|
||||
#800 added the harness-side opt-in, #809 / #813 added the c0re
|
||||
bind-mount + JSON-map plumbing, this is the per-agent flip
|
||||
that activates the unix-domain path.
|
||||
|
||||
Default `false` so an agent's web UI keeps binding TCP until
|
||||
the per-agent flip is explicit. Rollout shape:
|
||||
|
||||
1. flip one canary agent (atlas volunteered) to `true` via its
|
||||
`agent.nix` once #813 lands;
|
||||
2. validate the gateway's `proxy_pass http://unix:.../web.sock`
|
||||
end-to-end against that canary (atlas's step 3);
|
||||
3. flip remaining agents per-agent as the gateway side soaks;
|
||||
4. eventually drop this option once every agent's on unix +
|
||||
atlas's gateway is the only path — step 4 of #784 drops the
|
||||
harness's TCP fallback at the same time.
|
||||
|
||||
Sub-agent-only by design: the manager's UI serves at `/` via
|
||||
the c0re dashboard upstream, not via `/agent/<name>/`, so this
|
||||
option has no effect when `hyperhive.role = "manager"` (the
|
||||
env var is set unconditionally for clarity, but the manager's
|
||||
web UI doesn't route through the gateway's per-agent unix
|
||||
upstream — its bind socket would just sit unused).
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.role = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"agent"
|
||||
|
|
@ -537,6 +571,64 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
# Internal accumulator for shell snippets that should land in
|
||||
# `/etc/hyperhive/bash-env.sh`. Per-feature hooks set this via
|
||||
# `lib.mkIf` gated on their own option; the lines type merges
|
||||
# all contributions across modules into one file. Loaded via
|
||||
# `$BASH_ENV` for non-interactive shells (claude's `Bash` tool
|
||||
# runs `bash -c`) and via `programs.bash.interactiveShellInit`
|
||||
# for interactive shells. Generic by design (mara on #779) so
|
||||
# future hooks don't need to either rename this file or invent
|
||||
# a parallel dispatcher.
|
||||
options.hyperhive._bashEnvFragments = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
default = "";
|
||||
internal = true;
|
||||
description = ''
|
||||
Shell snippets concatenated into `/etc/hyperhive/bash-env.sh`.
|
||||
Feature hooks contribute via `lib.mkIf` gated on their own
|
||||
option. When empty, the file isn't created, `BASH_ENV` stays
|
||||
unset, and the interactive bashrc hook is omitted — zero cost
|
||||
when no feature is on. Internal — set indirectly via the
|
||||
per-feature options that own the gate (e.g.
|
||||
`hyperhive.cargo.shortMessages`).
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.cargo.shortMessages = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
example = false;
|
||||
description = ''
|
||||
Auto-inject `--message-format short` on cargo compile
|
||||
subcommands (`build`, `check`, `clippy`, `test`, `run`,
|
||||
`doc`, `bench`, `install`, `rustc`, `fix`) when claude (or
|
||||
anything else) invokes `cargo` inside this container.
|
||||
Saves tokens + context — the verbose default output floods
|
||||
the response window with per-crate progress lines that
|
||||
carry no signal beyond the warning/error summary (#777).
|
||||
|
||||
Implementation: contributes a `cargo` shell function to
|
||||
`/etc/hyperhive/bash-env.sh` (see `hyperhive._bashEnvFragments`).
|
||||
Loaded via `BASH_ENV` for non-interactive shells (`bash -c` —
|
||||
what the claude `Bash` tool runs) and sourced from
|
||||
`programs.bash.interactiveShellInit` for interactive shells.
|
||||
The function:
|
||||
|
||||
- handles the `+toolchain` selector prefix (`cargo +nightly
|
||||
build` works);
|
||||
- passes through cleanly when the caller already specified
|
||||
`--message-format` (any form);
|
||||
- leaves non-compile subcommands (`new`, `add`, `search`,
|
||||
third-party `cargo-*` subcommands) untouched so they
|
||||
don't error on the unknown flag.
|
||||
|
||||
Set to `false` for agents that need full cargo output (e.g.
|
||||
tooling that parses `--message-format json` programmatically
|
||||
and doesn't pass the flag explicitly).
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.autoCompact = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
|
|
@ -656,46 +748,20 @@ in
|
|||
}
|
||||
];
|
||||
|
||||
# First-boot migration from the legacy root-run shape (#658).
|
||||
# Runs on every activation; marker-guarded so the move only
|
||||
# happens once. The bind mount that hive-c0re sets up has
|
||||
# already moved from `/root/.claude` to `${homeDir}/.claude`
|
||||
# by the time we get here (per `lifecycle::CONTAINER_CLAUDE_MOUNT`
|
||||
# — the host-side path stays the same, the container-side
|
||||
# mount target shifts), so the bulk of the data is already at
|
||||
# the new location. This script just:
|
||||
#
|
||||
# - ensures `${homeDir}` exists with correct ownership (covers
|
||||
# the very first boot before useradd's `createHome` has
|
||||
# anything to chown);
|
||||
# - migrates any leftover `/root/.claude` content that an
|
||||
# operator might have populated before #658 deployed (the
|
||||
# bind mount didn't exist in that lifecycle, so claude
|
||||
# would have written into the root user's empty home —
|
||||
# nothing important typically, but safer to move than to
|
||||
# strand);
|
||||
# - chowns the bind-mounted state dir (`/agents/*/state`) so
|
||||
# the agent user can read/write it.
|
||||
# Post-#658 first-boot migration to the per-agent unix user —
|
||||
# creates the home dir, chowns the bind-mounted state +
|
||||
# `~/.claude/`, and (marker-guarded) moves any leftover
|
||||
# `/root/.claude` content from the pre-#658 root-run shape. See
|
||||
# `docs/persistence.md::First-boot agent-user migration` for the
|
||||
# step-by-step rationale; this script implements it.
|
||||
system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] ''
|
||||
homeDir=${lib.escapeShellArg homeDir}
|
||||
userName=${lib.escapeShellArg userName}
|
||||
# Always ensure the home dir exists with the right ownership —
|
||||
# useradd's createHome handles the very first creation but
|
||||
# doesn't re-chown if a rebuild changes the user name (rare
|
||||
# but possible if the meta-flake's per-agent name evolves).
|
||||
mkdir -p "$homeDir"
|
||||
chown "$userName:$userName" "$homeDir"
|
||||
# One-time migration of pre-#658 /root/.claude content into the
|
||||
# new home. Marker-guarded so the move only runs once per
|
||||
# container lifetime — subsequent activations skip the legacy
|
||||
# path even if claude were to repopulate /root/.claude for any
|
||||
# reason.
|
||||
marker=/var/lib/hive-agent-user-migrated
|
||||
if [ ! -e "$marker" ] && [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then
|
||||
mkdir -p "$homeDir/.claude"
|
||||
# `mv -n` (no-clobber) so any pre-existing files at the
|
||||
# destination (e.g. from the bind mount) win — we never
|
||||
# blow over data already at the new location.
|
||||
if cp -an /root/.claude/. "$homeDir/.claude/" 2>/dev/null; then
|
||||
rm -rf /root/.claude
|
||||
echo "hive-agent-user-migrate: moved /root/.claude → $homeDir/.claude"
|
||||
|
|
@ -703,25 +769,10 @@ in
|
|||
fi
|
||||
mkdir -p "$(dirname "$marker")"
|
||||
: > "$marker"
|
||||
# Chown the bind-mounted state dir so the agent user can
|
||||
# read/write it. `/agents/*/state` is the canonical mount
|
||||
# point set by hive-c0re's `set_nspawn_flags`. Wildcard
|
||||
# because each container only sees its own
|
||||
# `/agents/<name>/state` (one match); -h to avoid following
|
||||
# any symlinks the agent might have planted in there.
|
||||
for stateDir in /agents/*/state; do
|
||||
[ -d "$stateDir" ] || continue
|
||||
chown -hR "$userName:$userName" "$stateDir" 2>/dev/null || true
|
||||
done
|
||||
# Same treatment for the bind-mounted `~/.claude/` dir. Pre-#658
|
||||
# the harness ran as root and `claude` wrote `.credentials.json`
|
||||
# there 0600 root:root; post-#658 the harness reads
|
||||
# `~/.claude/` as the agent user to decide Online vs
|
||||
# NeedsLogin (`login::has_session`), and the host-side bind
|
||||
# source is still root-owned 0700 from those legacy writes.
|
||||
# Chown recursively so the existing credentials are readable
|
||||
# under the new identity instead of getting silently treated
|
||||
# as "no session" and re-prompting login every boot.
|
||||
if [ -d "$homeDir/.claude" ]; then
|
||||
chown -hR "$userName:$userName" "$homeDir/.claude" 2>/dev/null || true
|
||||
fi
|
||||
|
|
@ -753,6 +804,55 @@ in
|
|||
source = config.hyperhive.icon;
|
||||
};
|
||||
|
||||
# Cargo `--message-format short` injector (#777). Contributes a
|
||||
# `cargo` shell function to `hyperhive._bashEnvFragments`; the
|
||||
# bash-env infrastructure below packages that into a single file
|
||||
# sourced by both non-interactive and interactive shells.
|
||||
# `command cargo …` falls back to the un-wrapped binary in PATH
|
||||
# (the rust toolchain's cargo — either from `environment.systemPackages`
|
||||
# or from whatever `nix develop` shell the agent's working in).
|
||||
hyperhive._bashEnvFragments = lib.mkIf config.hyperhive.cargo.shortMessages ''
|
||||
# Auto-injects --message-format short on cargo compile
|
||||
# subcommands so per-crate progress lines don't flood
|
||||
# claude's context (#777). Bypassed when the caller
|
||||
# already passes --message-format (any form).
|
||||
cargo() {
|
||||
# Strip leading +toolchain selectors (cargo +nightly …).
|
||||
local pre=()
|
||||
while [ "''${1:0:1}" = "+" ] && [ -n "''${1:-}" ]; do
|
||||
pre+=("$1")
|
||||
shift
|
||||
done
|
||||
case "''${1:-}" in
|
||||
build|check|clippy|test|run|doc|bench|install|rustc|fix)
|
||||
local sub="$1"
|
||||
shift
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--message-format|--message-format=*)
|
||||
command cargo "''${pre[@]}" "$sub" "$@"
|
||||
return $?
|
||||
;;
|
||||
esac
|
||||
done
|
||||
command cargo "''${pre[@]}" "$sub" --message-format short "$@"
|
||||
;;
|
||||
*)
|
||||
command cargo "''${pre[@]}" "$@"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
'';
|
||||
|
||||
# Single bash-env file with all configured shell fragments.
|
||||
# Wiring is gated on at least one fragment being active so a
|
||||
# fully feature-disabled agent has neither the file nor the
|
||||
# `BASH_ENV` / interactive sourcing — zero cost in that case.
|
||||
environment.etc."hyperhive/bash-env.sh" = lib.mkIf (config.hyperhive._bashEnvFragments != "") {
|
||||
text = config.hyperhive._bashEnvFragments;
|
||||
};
|
||||
|
||||
environment.etc."hyperhive/bash-allow.json".text =
|
||||
builtins.toJSON config.hyperhive.allowedBashPatterns;
|
||||
|
||||
|
|
@ -814,8 +914,28 @@ in
|
|||
}
|
||||
// lib.optionalAttrs (config.hyperhive.forge.skipNotifyReasons != [ ]) {
|
||||
HIVE_FORGE_NOTIFY_SKIP_REASONS = lib.concatStringsSep "," config.hyperhive.forge.skipNotifyReasons;
|
||||
}
|
||||
// lib.optionalAttrs (config.hyperhive._bashEnvFragments != "") {
|
||||
# Non-interactive bash invocations (claude's `Bash` tool runs
|
||||
# `bash -c`) source $BASH_ENV at startup — drops every active
|
||||
# feature hook's snippet into scope without touching
|
||||
# `/etc/profile` (login-only). Interactive shells source the
|
||||
# same file via the `interactiveShellInit` hook below so
|
||||
# behaviour matches across both modes (#777).
|
||||
BASH_ENV = "/etc/hyperhive/bash-env.sh";
|
||||
};
|
||||
|
||||
# Interactive shells don't honour BASH_ENV — wire the same file
|
||||
# in via the bashrc hook so operator SSH sessions get the same
|
||||
# hook surface as claude's non-interactive calls. Gated on at
|
||||
# least one fragment being active so we don't write a no-op
|
||||
# source line into `/etc/bashrc` on fully-feature-disabled agents.
|
||||
programs.bash.interactiveShellInit = lib.mkIf (config.hyperhive._bashEnvFragments != "") ''
|
||||
if [ -r /etc/hyperhive/bash-env.sh ]; then
|
||||
. /etc/hyperhive/bash-env.sh
|
||||
fi
|
||||
'';
|
||||
|
||||
boot.isNspawnContainer = true;
|
||||
|
||||
# Every agent gets flakes + the modern `nix` CLI out of the box.
|
||||
|
|
@ -829,15 +949,11 @@ in
|
|||
"flakes"
|
||||
];
|
||||
|
||||
# Containers bind-mount the host's nix-daemon socket. The host daemon
|
||||
# may be configured with remote builders or strict sandbox settings
|
||||
# (sandbox-fallback = false) that make local `nix build` invocations
|
||||
# fail inside the container. Enable sandbox-fallback so builds that
|
||||
# can't set up the sandbox (no user-namespaces in nspawn) fall back
|
||||
# to unsandboxed local builds rather than failing outright.
|
||||
# mkForce overrides the nixpkgs nix module which sets this to false
|
||||
# at normal priority -- without it agents get a conflicting definition
|
||||
# error on rebuild. Security implications: see docs/security.md.
|
||||
# `lib.mkForce` overrides nixpkgs's normal-priority `false` so
|
||||
# in-container `nix build` invocations fall back to unsandboxed
|
||||
# local builds rather than failing on the missing user-namespace.
|
||||
# See `docs/gotchas.md::Containerized nix-daemon needs
|
||||
# sandbox-fallback = true` + `docs/security.md` for the rationale.
|
||||
nix.settings.sandbox-fallback = lib.mkForce true;
|
||||
|
||||
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
||||
|
|
@ -1005,14 +1121,12 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
# Long-running matrix-sdk Client + sync per agent (#548 phase 3).
|
||||
# Holds the unix socket the stdio `hive-matrix-mcp` bridge talks
|
||||
# to, and emits hyperhive wake signals on incoming room events
|
||||
# via `/run/hive/mcp.sock`. Conditional on `hyperhive.matrix.enable`
|
||||
# AND token-file presence (the daemon binary itself exits 0 on
|
||||
# missing token, but the path watcher below restarts it the
|
||||
# moment the token lands — same first-boot-ordering pattern as
|
||||
# matrix-avatar-sync.path / #571).
|
||||
# Long-running matrix-sdk client + sync per agent. Holds the unix
|
||||
# socket the stdio `hive-matrix-mcp` bridge connects to + emits
|
||||
# hyperhive wake signals on incoming room events via
|
||||
# `/run/hive/mcp.sock`. See
|
||||
# `docs/persistence.md::Matrix per-agent daemon + token-arrival
|
||||
# trigger` for the socket-path / first-boot-ordering rationale.
|
||||
systemd.services.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable {
|
||||
description = "long-running matrix-sdk Client + MCP daemon socket";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
|
@ -1020,12 +1134,6 @@ in
|
|||
wants = [ "network-online.target" ];
|
||||
environment = {
|
||||
HIVE_MATRIX_URL = config.hyperhive.matrix.url;
|
||||
# Socket path lives inside the systemd-managed runtime dir
|
||||
# (`RuntimeDirectory = "hive-matrix"` → `/run/hive-matrix/`,
|
||||
# owned by the agent user) so the daemon can bind it without
|
||||
# needing root over `/run/` itself (#658). The stdio bridge
|
||||
# picks up the same path via its own `HIVE_MATRIX_SOCKET` env
|
||||
# in `extraMcpServers.matrix` below.
|
||||
HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket";
|
||||
RUST_LOG = "info";
|
||||
};
|
||||
|
|
@ -1033,26 +1141,17 @@ in
|
|||
ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
# Run as the per-agent unix user (#658). The runtime dir
|
||||
# (`/run/hive-matrix/`) is owned by that user via
|
||||
# `RuntimeDirectory`; claude (also as that user) can
|
||||
# connect to the socket inside it when the stdio bridge
|
||||
# spawns per turn.
|
||||
User = userName;
|
||||
Group = userName;
|
||||
RuntimeDirectory = "hive-matrix";
|
||||
};
|
||||
};
|
||||
|
||||
# Path-trigger sibling so hive-matrix-daemon fires the moment
|
||||
# `<state>/matrix-token` appears (#548 phase 3, mirrors the
|
||||
# matrix-avatar-sync.path pattern from #571). On clean boot
|
||||
# hive-c0re provisions the token AFTER agent containers come up;
|
||||
# without the trigger the daemon would exit 0 quietly and the
|
||||
# MCP would have no backend until next restart. With the watcher
|
||||
# the daemon comes alive in the same boot cycle as provisioning.
|
||||
# The glob matches every agent (manager sees its own state at
|
||||
# `/agents/hm1nd/state/` via the `/agents` bind).
|
||||
# Re-fire the daemon when the matrix token appears (hive-c0re
|
||||
# provisions it after agent containers come up). Without this
|
||||
# the daemon would exit 0 silently on first boot and the MCP
|
||||
# would have no backend until next restart. See
|
||||
# `docs/persistence.md` (same section as above).
|
||||
systemd.paths.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable {
|
||||
description = "trigger hive-matrix-daemon when matrix-token appears";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
|
@ -1217,12 +1316,10 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
# Manager-only forge defaults (#671): skip the
|
||||
# subscription/participation firehose so the manager's inbox
|
||||
# only carries direct mentions, reviews, and assignments. Sub-
|
||||
# agents keep the noisier defaults (`keepSubscriptions = true`,
|
||||
# `skipNotifyReasons = [ ]`). `mkDefault` so any agent that
|
||||
# wants to invert it can.
|
||||
# Manager-only forge defaults: subscription/participation
|
||||
# firehose stays off so the manager's inbox isn't drowned in
|
||||
# noise. Full rationale + sub-agent contrast:
|
||||
# docs/agent-hierarchy.md::Manager-only defaults.
|
||||
hyperhive.forge = lib.mkIf (config.hyperhive.role == "manager") {
|
||||
keepSubscriptions = lib.mkDefault false;
|
||||
skipNotifyReasons = lib.mkDefault [
|
||||
|
|
@ -1231,91 +1328,49 @@ in
|
|||
];
|
||||
};
|
||||
|
||||
# Harness systemd unit. Role-driven so the same `harness-base.nix`
|
||||
# covers both `nixosConfigurations.agent-base` (`hive-ag3nt serve`)
|
||||
# and `nixosConfigurations.manager` (`hive-m1nd serve`) without a
|
||||
# second template file (#671). Per-agent HIVE_PORT / HIVE_LABEL
|
||||
# come from the meta-flake's generated `applied/<name>/flake.nix`;
|
||||
# the manager has hardcoded fallbacks here so `nixosConfigurations.manager`
|
||||
# still builds standalone.
|
||||
# Role-driven harness systemd unit: one binary, two unit names
|
||||
# for log/ExecStartPre stability. Unit shape (PATH wrapper-dir
|
||||
# trick, env vars, RuntimeDirectory, User=, standalone-eval
|
||||
# fallbacks): docs/agent-hierarchy.md::Harness systemd unit
|
||||
# shape (per-role). PATH /bin auto-append behaviour:
|
||||
# docs/gotchas.md::systemd.services.*.path appends /bin to
|
||||
# every entry.
|
||||
systemd.services.${if config.hyperhive.role == "manager" then "hive-m1nd" else "hive-ag3nt"} =
|
||||
let
|
||||
isManager = config.hyperhive.role == "manager";
|
||||
# Post-#598 there is exactly one harness binary (`hive`), and
|
||||
# it picks its surface from `HIVE_ROLE` at startup. We still
|
||||
# name the systemd unit `hive-ag3nt` / `hive-m1nd` so dashboard
|
||||
# log queries + ExecStartPre paths + ancestor PR diffs keep
|
||||
# working without a unit rename cascade.
|
||||
binary = "hive";
|
||||
in
|
||||
{
|
||||
description = "${binary}${lib.optionalString isManager " manager"} harness";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
# systemd units get a minimal PATH by default and don't inherit
|
||||
# `environment.systemPackages`. Pointing at `/run/current-system/sw`
|
||||
# gives the harness (and any tools claude shells out to via Bash)
|
||||
# access to everything declared in `systemPackages` — including
|
||||
# anything an agent adds to its own `agent.nix` — without having
|
||||
# to touch the service definition.
|
||||
#
|
||||
# `/run/wrappers/bin` prepended so the `security.wrappers`
|
||||
# setuid shims (notably `sudo`) resolve before the bare
|
||||
# nix-store binaries in `/run/current-system/sw/bin`.
|
||||
# Post-#658 the harness runs as the per-agent user — without
|
||||
# the wrapper dir on PATH, `sudo` resolves to the un-setuid
|
||||
# nix-store binary and refuses with "must be owned by uid 0
|
||||
# and have the setuid bit set" even when
|
||||
# `hyperhive.user.passwordlessSudo = true` is configured
|
||||
# (#672 fixup pulled forward into this PR to avoid the
|
||||
# regression argus flagged on #676).
|
||||
#
|
||||
# `systemd.services.<name>.path` appends `/bin` to each entry,
|
||||
# so the bare prefixes here resolve to `/run/wrappers/bin` +
|
||||
# `/run/current-system/sw/bin` inside the unit's PATH. Passing
|
||||
# the trailing `/bin` ourselves (the natural-looking spelling)
|
||||
# would yield `/run/wrappers/bin/bin` + `/run/current-system/sw/bin/bin`,
|
||||
# neither of which exists — that's how #672 originally landed
|
||||
# broken: every agent had a PATH pointing at non-existent dirs
|
||||
# and `which sudo` kept falling back to the un-setuid binary.
|
||||
# `/run/wrappers` before `/run/current-system/sw` so setuid
|
||||
# `sudo` resolves first. Passing the bare prefixes (no trailing
|
||||
# `/bin`) is intentional — see docs pointer above.
|
||||
path = [
|
||||
"/run/wrappers"
|
||||
"/run/current-system/sw"
|
||||
];
|
||||
environment = {
|
||||
SHELL = "${pkgs.bashInteractive}/bin/bash";
|
||||
# `HOME` defaults to `/` for systemd services without a User=
|
||||
# set. With #658 the harness runs as the agent user — set HOME
|
||||
# explicitly so claude (which the harness spawns) finds its
|
||||
# `~/.claude/` session dir at the bind-mounted location.
|
||||
HOME = homeDir;
|
||||
# Path to the merged agent static dist. The harness serves this
|
||||
# via `tower_http::ServeDir` for any request it doesn't route to
|
||||
# an API endpoint. `mergedDist` is the agent-default dist with
|
||||
# `hyperhive.frontend.extraFiles` layered on top.
|
||||
HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}";
|
||||
# Static runtime assets (branding + claude prompts). Set on the
|
||||
# unit directly — `environment.variables` only populates
|
||||
# /etc/profile, which systemd services don't inherit.
|
||||
HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive";
|
||||
# Post-#598: the unified `hive` binary picks its surface from
|
||||
# this env var at startup. Default (`"agent"`) matches the
|
||||
# binary's standalone fallback when this is unset.
|
||||
HIVE_ROLE = config.hyperhive.role;
|
||||
}
|
||||
// lib.optionalAttrs config.hyperhive.web.useUnixSocket {
|
||||
# Per-agent unix-socket flip for the web UI (#784 phase 2
|
||||
# step 2c). When set, the harness's `web_ui::serve` binds
|
||||
# a `UnixListener` at this path instead of TCP. Path
|
||||
# matches `hive_c0re::agent_sockets::socket_path_for(name)`
|
||||
# so the lifecycle bind-mount (#813) and the gateway's
|
||||
# upstream config all derive from the same canonical
|
||||
# `/run/hive-agent/<name>/web.sock` shape — no triangulation.
|
||||
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
|
||||
}
|
||||
// lib.optionalAttrs isManager {
|
||||
# Standalone-eval fallbacks for `nixosConfigurations.manager`.
|
||||
# meta.rs overrides both via the per-agent generated
|
||||
# `applied/hm1nd/flake.nix` (see `lifecycle::setup_applied`);
|
||||
# the values here keep the container sensible if anyone
|
||||
# evaluates the standalone config.
|
||||
#
|
||||
# `HIVE_PORT` = FNV-1a("hm1nd") % 900 + 8100 = 8875 per
|
||||
# `lifecycle::agent_web_port` (#753 dropped the
|
||||
# pre-#753 "manager pinned at 8000" special case). Hardcoded
|
||||
# here because the standalone-eval path doesn't go through
|
||||
# `meta::render_flake`; real deploys pick up the rust-computed
|
||||
# value via meta and never touch this fallback.
|
||||
# Standalone-eval fallbacks; meta.rs overrides at deploy time.
|
||||
# HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100.
|
||||
HIVE_PORT = "8875";
|
||||
HIVE_LABEL = "hm1nd";
|
||||
};
|
||||
|
|
@ -1323,20 +1378,11 @@ in
|
|||
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 2;
|
||||
# `/run/hive-config/` is a per-service runtime dir owned by
|
||||
# the agent user (`User=` below), auto-cleared by systemd on
|
||||
# stop. The harness writes its regenerated
|
||||
# claude-{mcp-config,settings,system-prompt} files there
|
||||
# (see `paths::config_dir`). Kept separate from `/run/hive`
|
||||
# — that bind comes in root-owned from the host and holds
|
||||
# hive-c0re's `mcp.sock` we only connect to (#658 fixup).
|
||||
# Per-service runtime dir owned by `User=` below; the harness
|
||||
# writes its regenerated claude-{mcp-config,settings,system-prompt}
|
||||
# files here (`paths::config_dir`). Separate from /run/hive,
|
||||
# which holds hive-c0re's mcp.sock.
|
||||
RuntimeDirectory = "hive-config";
|
||||
# Run the harness as the per-agent user (#658). claude itself
|
||||
# spawned by the harness then runs as that user too — drops
|
||||
# root inside the container while sudo (`NOPASSWD: ALL` by
|
||||
# default, see `hyperhive.user.passwordlessSudo`) keeps the
|
||||
# previous root-by-default surface available explicitly for
|
||||
# tools that need it.
|
||||
User = userName;
|
||||
Group = userName;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,66 +5,41 @@
|
|||
...
|
||||
}:
|
||||
{
|
||||
# Optional Weston (the reference Wayland compositor) with the VNC
|
||||
# backend, surfaced as a per-agent hyperhive option. An agent turns
|
||||
# it on from its own `agent.nix`:
|
||||
# Optional Weston (Wayland compositor) with the VNC backend,
|
||||
# surfaced as a per-agent `hyperhive.gui.enable` option. Imported
|
||||
# from harness-base.nix so every sub-agent + the manager sees the
|
||||
# option; only those that flip it on get the service.
|
||||
#
|
||||
# hyperhive.gui.enable = true;
|
||||
#
|
||||
# Imported by `harness-base.nix`, so every sub-agent + the manager
|
||||
# has the option available; only those that flip it on get the
|
||||
# service. This is a flat per-agent option (evaluated inside that
|
||||
# agent's own container build) — NOT a `hyperhive.agents.<name>.*`
|
||||
# registry, which can't work: each agent is its own
|
||||
# nixosConfiguration and has no cross-agent view.
|
||||
#
|
||||
# VNC port selection: a deterministic FNV-1a hash of the agent name
|
||||
# (derived from the container hostname at runtime) maps into the
|
||||
# range [15900, 16799], mirroring lifecycle::agent_web_port. The
|
||||
# computed port is written to `/etc/hyperhive/gui.json` at service
|
||||
# start; the harness (issue #51) reads that file to know where to
|
||||
# relay WebSocket connections.
|
||||
#
|
||||
# Note: weston's VNC backend does not expose a CLI bind-address flag
|
||||
# (unlike the RDP backend's `--address`), so VNC listens on all
|
||||
# interfaces. The harness WebSocket relay (issue #51) connects only
|
||||
# via 127.0.0.1, and the host firewall should block external access
|
||||
# to the VNC port range. A future weston.ini `[vnc] address=` can
|
||||
# restrict this once upstream supports it.
|
||||
# Port allocation, weston bind-address quirk, PAM service name, the
|
||||
# Type=simple choice, idle-time=0: all in
|
||||
# docs/gotchas.md::Weston VNC compositor.
|
||||
# Harness-side WebSocket relay shape: docs/web-ui.md::Per-agent
|
||||
# endpoints (`/screen` + `/screen/ws`).
|
||||
|
||||
options.hyperhive.gui.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Run Weston with the VNC backend as a systemd service, for
|
||||
in-browser GUI access via the harness WebSocket relay (see
|
||||
issue #51). Renders in software (pixman) — no GPU, DRM,
|
||||
or VT access, so no extra container capabilities are needed.
|
||||
in-browser GUI access via the harness `/screen/ws` WebSocket
|
||||
relay. Renders in software (pixman) — no GPU, DRM, or VT
|
||||
access, so no extra container capabilities are needed.
|
||||
|
||||
The VNC port is deterministic: FNV-1a hash of the agent name
|
||||
(taken from the container hostname) mapped into [15900, 16799].
|
||||
The port and auth mode are written to `/etc/hyperhive/gui.json`
|
||||
at service start so the harness can relay connections.
|
||||
|
||||
The unit is deliberately built so enabling it can NEVER abort
|
||||
the agent's `nixos-container update`: `Type = "simple"` (so
|
||||
`switch-to-configuration` doesn't block on weston readiness)
|
||||
and the ExecStart script always tries to exec weston after
|
||||
setup — a misconfigured weston degrades to a restart loop
|
||||
visible in `journalctl`, it does not block the rebuild. (Same
|
||||
reasoning as the `tea-login` unit in `harness-base.nix`.)
|
||||
The VNC port is a deterministic FNV-1a hash of the agent name
|
||||
mapped into `[15900, 16799]`, written to
|
||||
`/etc/hyperhive/gui.json` at service start so the harness can
|
||||
relay connections without a separate config flag. The unit is
|
||||
`Type = "simple"` so a misconfigured weston degrades to a
|
||||
restart loop instead of blocking `nixos-container update`.
|
||||
'';
|
||||
};
|
||||
|
||||
config = lib.mkIf config.hyperhive.gui.enable {
|
||||
# neatvnc 0.9 always calls the PAM auth callback (weston_authenticate_user)
|
||||
# for Apple-DH (type 30), regardless of weston.ini auth-method=none.
|
||||
# pam_permit.so makes the PAM service accept any credentials so the
|
||||
# browser's empty Apple-DH credentials always pass.
|
||||
#
|
||||
# The service name is "weston-remote-access" — that is the literal string
|
||||
# passed to pam_start() inside libweston (libweston/auth.c). Using "weston"
|
||||
# instead silently falls back to the system default and rejects auth.
|
||||
# neatvnc ≥ 0.9 always calls the PAM auth callback for Apple-DH
|
||||
# (type 30), regardless of weston.ini auth-method=none.
|
||||
# pam_permit.so accepts the browser's empty Apple-DH credentials.
|
||||
# Service name MUST be the literal `weston-remote-access` — that's
|
||||
# the string libweston passes to pam_start() in libweston/auth.c.
|
||||
security.pam.services."weston-remote-access".text = ''
|
||||
auth sufficient pam_permit.so
|
||||
account sufficient pam_permit.so
|
||||
|
|
@ -76,29 +51,21 @@
|
|||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
# `simple`, not `notify`: switch-to-configuration must not
|
||||
# wait on weston signalling readiness (same reasoning as the
|
||||
# `tea-login` unit in harness-base.nix).
|
||||
Type = "simple";
|
||||
# Creates /var/lib/weston (0700 root) at start.
|
||||
StateDirectory = "weston";
|
||||
Environment = "XDG_RUNTIME_DIR=/run/user/0";
|
||||
# Wrapper script: computes the deterministic VNC port, writes
|
||||
# /etc/hyperhive/gui.json for the harness (issue #51), then
|
||||
# execs weston. Using `exec` keeps the PID stable so systemd
|
||||
# tracks the weston process correctly under Type=simple.
|
||||
# Any failure before the exec triggers Restart=on-failure
|
||||
# (graceful degradation) rather than blocking the rebuild.
|
||||
# /etc/hyperhive/gui.json for the harness, then execs weston.
|
||||
# `exec` keeps the PID stable so systemd tracks the weston
|
||||
# process correctly under Type=simple.
|
||||
ExecStart = pkgs.writeShellScript "weston-vnc" ''
|
||||
mkdir -p /run/user/0 && chmod 700 /run/user/0 || true
|
||||
|
||||
# --- Compute deterministic VNC port via FNV-1a ---
|
||||
# Agent name = container hostname with leading "h-" stripped,
|
||||
# mirroring lifecycle::agent_web_port in hive-c0re/src/lifecycle.rs.
|
||||
# Agent name = container hostname with leading `h-` stripped.
|
||||
# Read from /etc/hostname (always present in NixOS containers)
|
||||
# to avoid a dependency on the `hostname` binary (which lives in
|
||||
# pkgs.inetutils, not pkgs.coreutils).
|
||||
# VNC_PORT_BASE=15900, VNC_PORT_RANGE=900 → [15900, 16799].
|
||||
# to avoid depending on `hostname` (lives in pkgs.inetutils,
|
||||
# not pkgs.coreutils).
|
||||
RAW_HOST=$(${pkgs.coreutils}/bin/cat /etc/hostname)
|
||||
AGENT_NAME=$(${pkgs.coreutils}/bin/printf '%s' "$RAW_HOST" \
|
||||
| ${pkgs.gnused}/bin/sed 's/^h-//')
|
||||
|
|
@ -111,30 +78,15 @@
|
|||
done
|
||||
VNC_PORT=$((15900 + hash % 900))
|
||||
|
||||
# --- Write gui.json marker ---
|
||||
# The harness reads this at startup (issue #51) to know the
|
||||
# VNC port and auth mode for the WebSocket relay.
|
||||
# Marker file the harness reads at startup.
|
||||
${pkgs.coreutils}/bin/mkdir -p /etc/hyperhive
|
||||
${pkgs.coreutils}/bin/printf '{"vnc_port":%d,"auth":"none"}\n' \
|
||||
"$VNC_PORT" > /etc/hyperhive/gui.json || true
|
||||
|
||||
# neatvnc ≥ 0.9 advertises RSA-AES and Apple-DH security types
|
||||
# when auth is compiled in. The browser client handles Apple-DH
|
||||
# (type 30) with empty credentials.
|
||||
#
|
||||
# weston.ini [vnc] auth-method=none: weston uses an always-accept
|
||||
# auth callback instead of PAM. Without this, weston defaults to
|
||||
# PAM authentication which rejects empty credentials (SecurityResult=1).
|
||||
#
|
||||
# --disable-transport-layer-security prevents the VeNCrypt TLS
|
||||
# wrapper; plain auth types (incl. type 30) are advertised directly.
|
||||
# [core] idle-time=0 disables weston's idle timeout (default
|
||||
# 300s). Without it the VNC desktop fades to black after 5 min
|
||||
# idle and desktop-shell shows its click-to-unlock lock screen
|
||||
# — useless for an agent desktop viewed over /screen (issue
|
||||
# #180). idle-time=0 → the idle timer is updated with a 0ms
|
||||
# delay, which wl_event_source_timer_update treats as "disarm",
|
||||
# so the compositor never goes idle and never locks.
|
||||
# --disable-transport-layer-security: skips the VeNCrypt TLS
|
||||
# wrapper so plain auth types (incl. Apple-DH type 30) are
|
||||
# advertised directly. [core] idle-time=0 disables the
|
||||
# compositor's 300s idle/lock screen.
|
||||
WESTON_INI=$(${pkgs.coreutils}/bin/mktemp /tmp/weston-XXXXXX.ini)
|
||||
${pkgs.coreutils}/bin/printf '[core]\nidle-time=0\n\n[vnc]\nauth-method=none\n' > "$WESTON_INI"
|
||||
|
||||
|
|
@ -150,8 +102,8 @@
|
|||
};
|
||||
};
|
||||
|
||||
# weston on the agent's interactive PATH too, so claude can run
|
||||
# Wayland clients / `weston-info` against the compositor.
|
||||
# weston on the agent's interactive PATH so claude can run Wayland
|
||||
# clients / weston-info against the compositor.
|
||||
environment.systemPackages = [ pkgs.weston ];
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue