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
|
boundary.md operator/agent trust model rationale
|
||||||
agent-hierarchy.md tree-shape topology design + manager-privilege audit (#361)
|
agent-hierarchy.md tree-shape topology design + manager-privilege audit (#361)
|
||||||
damocles-migration.md future migration plan for damocles → hyperhive
|
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
|
## Reading paths
|
||||||
|
|
@ -316,6 +320,11 @@ read them à la carte.
|
||||||
pattern."** → [`docs/conventions.md`](docs/conventions.md).
|
pattern."** → [`docs/conventions.md`](docs/conventions.md).
|
||||||
- **"Why does the nspawn flag look like that?"** →
|
- **"Why does the nspawn flag look like that?"** →
|
||||||
[`docs/gotchas.md`](docs/gotchas.md).
|
[`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
|
## Conventions & process
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Agent hierarchy & privileges
|
# Agent hierarchy & privileges
|
||||||
|
|
||||||
Design + audit doc for milestone #6 (the
|
Design + audit doc for the agent-privileges + tree-shape milestone
|
||||||
[issue](http://localhost:3000/hyperhive/hyperhive/issues/361) tree).
|
(the [issue tree](http://localhost:3000/hyperhive/hyperhive/issues/361)).
|
||||||
The implementation lands in pieces; this doc tracks what's done, what's
|
The implementation lands in pieces; this doc tracks what's done, what's
|
||||||
planned, and what currently special-cases the manager.
|
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.
|
`null` = root-level agent. Today only the manager qualifies by default.
|
||||||
Other agents land under `"manager"` on first sync. Re-parenting is
|
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
|
- CLI: `hive-c0re set-parent <child> --parent <new>` (or `--root` to
|
||||||
promote). Exactly one of `--parent` / `--root` is required.
|
promote). Exactly one of `--parent` / `--root` is required.
|
||||||
|
|
@ -34,11 +34,11 @@ validation rules to a pure `apply_set_parent` helper. Refuses:
|
||||||
- self-parenting,
|
- self-parenting,
|
||||||
- cycles (32-hop ancestor walk, mirroring `is_descendant_of`).
|
- cycles (32-hop ancestor walk, mirroring `is_descendant_of`).
|
||||||
|
|
||||||
Post-#743 the manager is reparentable like any other agent — the
|
The manager is reparentable like any other agent — there's no
|
||||||
"structurally root" carve-out was historical paranoia; the manager's
|
"structurally root" carve-out; the manager's privileges live on its
|
||||||
privileges live on its MCP socket, not its tree position, and the
|
MCP socket, not its tree position, and the cycle walk above catches
|
||||||
cycle walk above catches the only real safety concern (moving the
|
the only real safety concern (moving the manager under one of its
|
||||||
manager under one of its own descendants).
|
own descendants).
|
||||||
|
|
||||||
Idempotent no-op fast path skips the disk write when the parent is
|
Idempotent no-op fast path skips the disk write when the parent is
|
||||||
already what's requested. After a successful write the surfaces call
|
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
|
viewers see the tree repaint without polling
|
||||||
(`ContainerView.parent` is sourced from `topology.json`).
|
(`ContainerView.parent` is sourced from `topology.json`).
|
||||||
|
|
||||||
**Today's caveat (#361 follow-up):** the move is purely a JSON edit.
|
**Today's caveat:** the move is purely a JSON edit. Only the
|
||||||
Only the top-level manager (`hm1nd`) gets `/var/lib/hyperhive/agents`
|
top-level manager (`hm1nd`) gets `/var/lib/hyperhive/agents`
|
||||||
bind-mounted at `/agents` in its container, so sub-agents don't yet
|
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
|
see their would-be children's state. Once sub-manager bind mounts
|
||||||
land alongside #361 enforcement, `set_parent` grows a companion
|
land alongside cap enforcement, `set_parent` grows a companion
|
||||||
umount-old / mount-new / restart-cascade step (tracked via the
|
umount-old / mount-new / restart-cascade step.
|
||||||
cross-ref comment on #361).
|
|
||||||
|
|
||||||
### Why meta, not per-agent `agent.nix`
|
### Why meta, not per-agent `agent.nix`
|
||||||
|
|
||||||
|
|
@ -78,7 +77,7 @@ where system-level facts live.
|
||||||
so the harness / claude prompts can see it.
|
so the harness / claude prompts can see it.
|
||||||
4. **Surface**: `container_view::build_all` reads `topology.json` and
|
4. **Surface**: `container_view::build_all` reads `topology.json` and
|
||||||
populates `ContainerView.parent: Option<String>` on every rescan.
|
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
|
## Target topology semantics
|
||||||
|
|
||||||
|
|
@ -150,7 +149,7 @@ Tree-shape version:
|
||||||
- `request_update_meta_inputs` is the only path that actually writes
|
- `request_update_meta_inputs` is the only path that actually writes
|
||||||
`flake.lock`, gated by the cap; everyone else stays RO.
|
`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.
|
`lifecycle.rs` no longer binds `/state` for the manager.
|
||||||
`HYPERHIVE_STATE_DIR` is now injected uniformly via
|
`HYPERHIVE_STATE_DIR` is now injected uniformly via
|
||||||
|
|
@ -165,8 +164,8 @@ read.
|
||||||
|
|
||||||
- `prompts/system.md` with `<!-- role:agent -->` / `<!-- role:manager -->`
|
- `prompts/system.md` with `<!-- role:agent -->` / `<!-- role:manager -->`
|
||||||
marker blocks, assembled by `hive_ag3nt::prompt::render` based on
|
marker blocks, assembled by `hive_ag3nt::prompt::render` based on
|
||||||
flavor (closes #519). **Per-agent cap list** of what the agent can
|
flavor. **Per-agent cap list** of what the agent can do — already
|
||||||
do — already a single parametrised prompt; once #513 lands the
|
a single parametrised prompt; once per-agent cap groups land the
|
||||||
marker grammar grows `cap:<group>` blocks the renderer reads from
|
marker grammar grows `cap:<group>` blocks the renderer reads from
|
||||||
the per-agent ToolGroup set.
|
the per-agent ToolGroup set.
|
||||||
- `mcp.rs::Flavor::{Agent, Manager}` controls which MCP tools claude
|
- `mcp.rs::Flavor::{Agent, Manager}` controls which MCP tools claude
|
||||||
|
|
@ -182,8 +181,7 @@ read.
|
||||||
descendants'.
|
descendants'.
|
||||||
- `operator_questions.rs` + `broker.rs`: "manager can cancel any
|
- `operator_questions.rs` + `broker.rs`: "manager can cancel any
|
||||||
question" override on the owner check. **Topology** — agents can
|
question" override on the owner check. **Topology** — agents can
|
||||||
moderate threads of their descendants. (per mara's
|
moderate threads of their descendants.
|
||||||
https://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3344)
|
|
||||||
- `reminder_scheduler.rs`: same override pattern for reminder cancel.
|
- `reminder_scheduler.rs`: same override pattern for reminder cancel.
|
||||||
**Topology** — descendants only.
|
**Topology** — descendants only.
|
||||||
- `actions.rs`: `destroy` refuses to act on `MANAGER_NAME` (no
|
- `actions.rs`: `destroy` refuses to act on `MANAGER_NAME` (no
|
||||||
|
|
@ -196,9 +194,9 @@ read.
|
||||||
|
|
||||||
### G — sub-agents inside the same container
|
### G — sub-agents inside the same container
|
||||||
|
|
||||||
Future work mentioned in #361: when enabled for an agent, it can spawn
|
Future work: when enabled for an agent, it can spawn temporary
|
||||||
temporary "sub-agents" that run inside its own container. Lighter than
|
"sub-agents" that run inside its own container. Lighter than a full
|
||||||
a full nspawn agent. Open questions, not yet wired:
|
nspawn agent. Open questions, not yet wired:
|
||||||
|
|
||||||
- Inherit caps from parent, or take an explicit narrower set?
|
- Inherit caps from parent, or take an explicit narrower set?
|
||||||
- Survive container restart, or always ephemeral?
|
- 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?
|
- Filesystem: share parent's `/state` RW, or a sub-dir?
|
||||||
- Identity: distinct broker recipient name, or address the parent?
|
- 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
|
## Cross-references
|
||||||
|
|
||||||
- Milestone: [#361 "Agent privileges and sub-agents"](http://localhost:3000/hyperhive/hyperhive/issues/361)
|
- Milestone: ["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)
|
- Dashboard render: ["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)
|
- Audit table source: [milestone comment](http://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3335)
|
||||||
- Operator/agent trust boundary (orthogonal axis): [`boundary.md`](boundary.md)
|
- Operator/agent trust boundary (orthogonal axis): [`boundary.md`](boundary.md)
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ happens after a decision lands.
|
||||||
applied.
|
applied.
|
||||||
3a. **Flake validation (ApplyCommit only):** after the proposal tag
|
3a. **Flake validation (ApplyCommit only):** after the proposal tag
|
||||||
is planted, hive-c0re reads `proposal/<id>:flake.lock` and
|
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
|
pending approval is created for the operator — the row is
|
||||||
marked failed and surfaces on the dashboard with the
|
marked failed and surfaces on the dashboard with the
|
||||||
validation message:
|
validation message:
|
||||||
|
|
@ -66,7 +66,7 @@ happens after a decision lands.
|
||||||
### Withdrawing a pending approval
|
### Withdrawing a pending approval
|
||||||
|
|
||||||
The manager can call `cancel_loose_end(kind: "approval", id)` to
|
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
|
The row transitions to `ApprovalStatus::Cancelled` (distinct from
|
||||||
`Denied`/`Failed`), the dashboard pulls the card out of the
|
`Denied`/`Failed`), the dashboard pulls the card out of the
|
||||||
pending pane, and `ApprovalResolved { status: "cancelled" }` fires
|
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
|
rejected and failed trees stay browsable forever — `git log
|
||||||
--tags` in the applied repo is the audit trail.
|
--tags` in the applied repo is the audit trail.
|
||||||
|
|
||||||
### Dispatch via `rebuild_queue` (#441)
|
### Dispatch via `rebuild_queue`
|
||||||
|
|
||||||
Long-running approval work — `ApplyCommit`, `UpdateMetaInputs`,
|
Long-running approval work — `ApplyCommit`, `UpdateMetaInputs`,
|
||||||
`Spawn` — no longer runs inline inside `actions::approve`. Instead
|
`Spawn` — no longer runs inline inside `actions::approve`. Instead
|
||||||
|
|
@ -344,7 +344,7 @@ Differences from sub-agents:
|
||||||
(vs `agent-base`).
|
(vs `agent-base`).
|
||||||
- Container name is `hm1nd` (no `h-` prefix).
|
- Container name is `hm1nd` (no `h-` prefix).
|
||||||
- Web UI port via `lifecycle::agent_web_port("hm1nd")` — same
|
- 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`
|
- `set_nspawn_flags` adds two extra binds: `/var/lib/hyperhive/agents`
|
||||||
→ `/agents` (RW) so the manager can edit per-agent proposed repos,
|
→ `/agents` (RW) so the manager can edit per-agent proposed repos,
|
||||||
and `/var/lib/hyperhive/applied` → `/applied` (RO) so the manager
|
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`
|
such transient was cleared in the last 30s (`RECENT_TRANSIENT_GRACE`
|
||||||
tombstone, three `POLL_INTERVAL`s — closes the race where a
|
tombstone, three `POLL_INTERVAL`s — closes the race where a
|
||||||
lifecycle op finishes between two crash-watch polls and the
|
lifecycle op finishes between two crash-watch polls and the
|
||||||
container shows briefly as "stopped without transient" before the
|
container shows briefly as "stopped without transient" before
|
||||||
next start, #425). Manager can `start` it again or escalate.
|
the next start). Manager can `start` it again or escalate.
|
||||||
- `NeedsLogin { agent }` — sub-agent has no claude session yet.
|
- `NeedsLogin { agent }` — sub-agent has no claude session yet.
|
||||||
Manager can't act directly (interactive OAuth); typically flags
|
Manager can't act directly (interactive OAuth); typically flags
|
||||||
the operator.
|
the operator.
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ exist because something already went wrong without them.
|
||||||
- The manager is `hm1nd` (no `h-` prefix, fixed name).
|
- The manager is `hm1nd` (no `h-` prefix, fixed name).
|
||||||
- `MAX_AGENT_NAME` in `lifecycle.rs` enforces the cap.
|
- `MAX_AGENT_NAME` in `lifecycle.rs` enforces the cap.
|
||||||
- Per-agent web UI port = `WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE`
|
- 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).
|
`cfg.dashboardPort` (default 7000).
|
||||||
|
|
||||||
## Identity = socket
|
## 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`).
|
(`agent_server::handle_send` fans out via `Coordinator::broadcast_send`).
|
||||||
- `operator` — the human at the dashboard. Messages accumulate in the
|
- `operator` — the human at the dashboard. Messages accumulate in the
|
||||||
inbox view; no agent ever `recv`'s them.
|
inbox view; no agent ever `recv`'s them.
|
||||||
- `<parent>` — the sender's parent per `topology.json` (`#692`).
|
- `<parent>` — the sender's parent per `topology.json`. Rewritten at
|
||||||
Rewritten at send time by `topology::resolve_recipient`: looks up
|
send time by `topology::resolve_recipient`: looks up
|
||||||
`parent_of(sender)` and falls back to `operator` when the sender is
|
`parent_of(sender)` and falls back to `operator` when the sender is
|
||||||
a root agent (or absent from topology entirely). Lets agents address
|
a root agent (or absent from topology entirely). Lets agents address
|
||||||
their parent without learning the label, so runtime reparenting
|
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
|
When the resolver rewrites `<parent>`, the broker stores the
|
||||||
*resolved* label as the message's recipient — the dashboard and
|
*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
|
— change them in one place. The dashboard event vocabulary lives
|
||||||
in `hive-c0re::dashboard_events::DashboardEvent`.
|
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
|
## Async forms
|
||||||
|
|
||||||
Dashboard + per-agent mutating forms carry `data-async`; a delegated
|
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>/`.
|
- #772 / #775 — fluffychat hops from `<hive>/matrix/` to `matrix.<hive>/`.
|
||||||
|
|
||||||
Next-up tracked separately: #14 (container netns isolation), TLS (#594).
|
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
|
## Claude credentials are per-agent
|
||||||
|
|
||||||
`/var/lib/hyperhive/agents/<name>/claude/` bind-mounts to
|
`/var/lib/hyperhive/agents/<name>/claude/` bind-mounts to
|
||||||
`/home/<name>/.claude` (RW; was `/root/.claude` pre-#658 when every
|
`/home/<name>/.claude` (RW). Sharing one dir across agents is NOT viable —
|
||||||
harness ran as root). Sharing one dir across agents is NOT viable —
|
|
||||||
OAuth refresh tokens rotate, so any sibling refresh invalidates all
|
OAuth refresh tokens rotate, so any sibling refresh invalidates all
|
||||||
the others. Login flow runs from the per-agent web UI; creds persist
|
the others. Login flow runs from the per-agent web UI; creds persist
|
||||||
across `destroy`/recreate (`--purge` wipes them).
|
across `destroy`/recreate (`--purge` wipes them).
|
||||||
|
|
@ -96,8 +95,8 @@ across `destroy`/recreate (`--purge` wipes them).
|
||||||
## Persistent notes dir per agent
|
## Persistent notes dir per agent
|
||||||
|
|
||||||
`/var/lib/hyperhive/agents/<name>/state/` bind-mounts to
|
`/var/lib/hyperhive/agents/<name>/state/` bind-mounts to
|
||||||
`/agents/<name>/state` (RW; uniform for sub-agents + manager
|
`/agents/<name>/state` (RW; uniform for sub-agents + manager).
|
||||||
post-#604, was `/state` pre-#604). The harness exposes the same path
|
The harness exposes the same path
|
||||||
via `$HYPERHIVE_STATE_DIR`. System prompts tell agents to keep
|
via `$HYPERHIVE_STATE_DIR`. System prompts tell agents to keep
|
||||||
durable knowledge here (`notes.md`, anything else). The harness also
|
durable knowledge here (`notes.md`, anything else). The harness also
|
||||||
writes its events log here (`hyperhive-events.sqlite`).
|
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
|
agent (different hash → different port) and rebuilding. No state
|
||||||
file, no probing, no port-allocation drift — the value is
|
file, no probing, no port-allocation drift — the value is
|
||||||
reproducible from just the name. Every agent — including the
|
reproducible from just the name. Every agent — including the
|
||||||
manager — hashes into 8100..8999 via the same FNV-1a since #753;
|
manager — hashes into 8100..8999 via the same FNV-1a; dashboard
|
||||||
dashboard at `cfg.dashboardPort` (default 7000).
|
at `cfg.dashboardPort` (default 7000).
|
||||||
|
|
||||||
## Restart races on TCP bind
|
## 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/
|
The naive nginx pattern for a path-prefix SPA (`try_files $uri $uri/
|
||||||
/matrix/index.html`) silently swallows asset 404s — a missing JS file
|
/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
|
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;
|
page renders blank with no visible error. Extension allowlists (tried
|
||||||
#686 filed the follow-up edge-case, addressed in PR #729).
|
as an alternative) have the same maintenance problem: any new file
|
||||||
Extension allowlists (tried in #686 → PR #729) have the same maintenance
|
extension the SPA ships breaks silently.
|
||||||
problem: any new file extension the SPA ships breaks silently.
|
|
||||||
|
|
||||||
The correct pattern (landed in PR #729, `hive-gateway.nix`) keys the
|
The pattern that works (`hive-gateway.nix`) keys the fallback on the
|
||||||
fallback on the HTTP `Accept` header:
|
HTTP `Accept` header:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
# Outside the server block (appendHttpConfig):
|
# 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
|
nix build /var/lib/hyperhive/meta#nixosConfigurations.argus.config.system.build.toplevel
|
||||||
```
|
```
|
||||||
|
|
||||||
`lifecycle::prebuild_toplevel` hit this in #721 (fixed in #738) by
|
`lifecycle::prebuild_toplevel` hit this once by constructing the attr
|
||||||
constructing the attr path as `{flake_ref}.config…` — which produced
|
path as `{flake_ref}.config…` — which produced `meta#argus.config…`
|
||||||
`meta#argus.config…` instead of `meta#nixosConfigurations.argus.config…`.
|
instead of `meta#nixosConfigurations.argus.config…`. The fix:
|
||||||
The fix: `split_once('#')` to separate flake path from name, then
|
`split_once('#')` to separate flake path from name, then template
|
||||||
template `{path}#nixosConfigurations.{name}.config.system.build.toplevel`.
|
`{path}#nixosConfigurations.{name}.config.system.build.toplevel`.
|
||||||
|
|
||||||
## `hive-forge`: prefer over raw curl pipelines
|
## `hive-forge`: prefer over raw curl pipelines
|
||||||
|
|
||||||
Every agent container has `hive-forge` in PATH (installed via
|
Every agent container has `hive-forge` in PATH (installed via
|
||||||
`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary
|
`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary).
|
||||||
since #280). Use it instead of ad-hoc curl pipelines:
|
Use it instead of ad-hoc curl pipelines:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hive-forge view 42 # title + body + comments
|
hive-forge view 42 # title + body + comments
|
||||||
hive-forge comments 42 # list all comments (human-readable)
|
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 "..." # post comment (inline body)
|
||||||
hive-forge comment 42 --body-file - <<EOF # ...or pipe a HEREDOC
|
hive-forge comment 42 --body-file - <<EOF # ...or pipe a HEREDOC
|
||||||
multi-line body
|
multi-line body
|
||||||
|
|
@ -212,7 +210,7 @@ hive-forge assign 42 damocles
|
||||||
hive-forge close 42
|
hive-forge close 42
|
||||||
hive-forge labels 42 add feature
|
hive-forge labels 42 add feature
|
||||||
hive-forge pr 42 # PR metadata as JSON
|
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 # unified diff (lockfile hunks collapsed by default)
|
||||||
hive-forge diff 42 --full # include unfiltered lockfile hunks
|
hive-forge diff 42 --full # include unfiltered lockfile hunks
|
||||||
hive-forge branches deployed/ # filter branches by pattern
|
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
|
Credentials come from `$HYPERHIVE_STATE_DIR/forge-token`; default
|
||||||
repo from `$HIVE_FORGE_REPO`, overridden per-invocation by the
|
repo from `$HIVE_FORGE_REPO`, overridden per-invocation by the
|
||||||
global `-r/--repo` flag.
|
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,
|
Q&A (`HelperEvent::QuestionAsked` pushed into target's inbox,
|
||||||
answered via `Answer` request). Migrated via `ALTER TABLE ADD
|
answered via `Answer` request). Migrated via `ALTER TABLE ADD
|
||||||
COLUMN` against `pragma_table_info`.
|
COLUMN` against `pragma_table_info`.
|
||||||
- `scheduled_prompts` — recurring + one-shot prompt queue
|
- `scheduled_prompts` — recurring + one-shot prompt queue.
|
||||||
(closes #444). `owner / body / interval_seconds (NULL = one-shot) /
|
`owner / body / interval_seconds (NULL = one-shot) /
|
||||||
next_fire_at_unix / created_at_unix / source ("operator" or
|
next_fire_at_unix / created_at_unix / source ("operator" or
|
||||||
"approval:<id>") / cancelled_at_unix / description`. `owner`
|
"approval:<id>") / cancelled_at_unix / description`. `owner`
|
||||||
drives cancel-permission checks (operator vs the submitting
|
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
|
`turn_end`; writes are best-effort, a sqlite hiccup logs + lets
|
||||||
the turn loop continue.
|
the turn loop continue.
|
||||||
|
|
||||||
No host-side vacuum yet — tracked as forge issue
|
No host-side vacuum yet — tracked separately. Target retention
|
||||||
[#10](http://localhost:3000/hyperhive/hyperhive/issues/10)
|
~90 days, age-only sweep like events_vacuum.
|
||||||
(target retention ~90 days, age-only sweep like events_vacuum).
|
|
||||||
|
|
||||||
### `/state/hyperhive-rate-limited` (per agent)
|
### `/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
|
precise changes from the manager; RW into the manager via the
|
||||||
`/agents` tree bind.
|
`/agents` tree bind.
|
||||||
- `claude/` — claude OAuth credentials, bind-mounted RW to
|
- `claude/` — claude OAuth credentials, bind-mounted RW to
|
||||||
`/home/<name>/.claude` inside the container (post-#658 — was
|
`/home/<name>/.claude` inside the container.
|
||||||
`/root/.claude` pre-#658 when every harness ran as root).
|
|
||||||
- `state/` — durable notes, the events.sqlite db, and the
|
- `state/` — durable notes, the events.sqlite db, and the
|
||||||
turn-stats sqlite db. Bind-mounted to `/agents/<name>/state`
|
turn-stats sqlite db. Bind-mounted to `/agents/<name>/state`
|
||||||
inside the container (uniform for sub-agents + manager
|
inside the container (uniform for sub-agents + manager).
|
||||||
post-#604). The `$HYPERHIVE_STATE_DIR` env var exposes the
|
The `$HYPERHIVE_STATE_DIR` env var exposes the same path to
|
||||||
same path to in-container scripts.
|
in-container scripts.
|
||||||
|
|
||||||
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
|
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
|
||||||
applied repo. Tracks `flake.nix` (module-only boilerplate; never
|
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
|
task before rebinding — idempotent so a hive-c0re restart followed
|
||||||
by `rebuild alice` recreates the agent's socket without a clean
|
by `rebuild alice` recreates the agent's socket without a clean
|
||||||
reinstall.
|
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
|
# Security model
|
||||||
|
|
||||||
## Nix builds and credential isolation (issue #240)
|
## Nix builds and credential isolation
|
||||||
|
|
||||||
### Background
|
### Background
|
||||||
|
|
||||||
|
|
@ -19,12 +19,11 @@ any file in the container that the nixbld user can read.
|
||||||
**What is NOT exposed**:
|
**What is NOT exposed**:
|
||||||
|
|
||||||
- `/home/<name>/.claude/` — mode `0700`, owned by the per-agent
|
- `/home/<name>/.claude/` — mode `0700`, owned by the per-agent
|
||||||
user `<name>` (post-#658 — was `/root/.claude` owned by root
|
user `<name>`. nixbld users cannot read it.
|
||||||
pre-#658). nixbld users cannot read it.
|
|
||||||
- `$HYPERHIVE_STATE_DIR/forge-token` (= `/agents/<name>/state/forge-token`)
|
- `$HYPERHIVE_STATE_DIR/forge-token` (= `/agents/<name>/state/forge-token`)
|
||||||
— written at mode `0600` by `hive-c0re/src/forge.rs` and chowned to the
|
— 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).
|
per-agent uid:gid by `lifecycle::chown_to_agent`. nixbld users
|
||||||
nixbld users cannot read it.
|
cannot read it.
|
||||||
|
|
||||||
**Policy**: all credential files written to agent state directories MUST be mode
|
**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.
|
`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` (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>` | `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` `<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` (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>` | `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 |
|
| `.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
|
This keeps `mcp__matrix__send_message` and similar from
|
||||||
dumping raw JSON.
|
dumping raw JSON.
|
||||||
|
|
||||||
## Inline ask-operator answer (#666)
|
## Inline ask-operator answer
|
||||||
|
|
||||||
When an agent calls `mcp__hyperhive__ask` with `to == "operator"`
|
When an agent calls `mcp__hyperhive__ask` with `to == "operator"`
|
||||||
(default), the rich tool-use renderer mounts an empty
|
(default), the rich tool-use renderer mounts an empty
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ claude has access to in return.
|
||||||
## The loop
|
## The loop
|
||||||
|
|
||||||
Each agent harness (`hive serve`, with role picked from `$HIVE_ROLE`
|
Each agent harness (`hive serve`, with role picked from `$HIVE_ROLE`
|
||||||
— `"agent"` for sub-agents, `"manager"` for the manager; post-#598
|
— `"agent"` for sub-agents, `"manager"` for the manager — one
|
||||||
this is one binary not two) runs:
|
binary, not two) runs:
|
||||||
|
|
||||||
1. Long-poll `Recv` on its socket. The host-side broker
|
1. Long-poll `Recv` on its socket. The host-side broker
|
||||||
(`broker.rs::recv_blocking_batch`) returns immediately if there's
|
(`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
|
(`Bus::emit_status("rate_limited")`), sleeps
|
||||||
`HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), then retries.
|
`HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), then retries.
|
||||||
The dashboard and per-agent page show a `⊘ rate limited` badge
|
The dashboard and per-agent page show a `⊘ rate limited` badge
|
||||||
while the harness is parked. **Auth-failed detection** (closes
|
while the harness is parked. **Auth-failed detection**: both
|
||||||
#419): both stdout and stderr pumps also match
|
stdout and stderr pumps also match
|
||||||
`AUTH_FAIL_MARKERS` (`"authentication_failed"`, `401`, etc.).
|
`AUTH_FAIL_MARKERS` (`"authentication_failed"`, `401`, etc.).
|
||||||
On match the harness writes `{state_dir}/hyperhive-needs-login`,
|
On match the harness writes `{state_dir}/hyperhive-needs-login`,
|
||||||
emits `needs_login_idle` status, requeues the inflight message
|
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 same path used at boot. The operator re-authenticates via
|
||||||
the per-agent web UI login flow; on success the sentinel is
|
the per-agent web UI login flow; on success the sentinel is
|
||||||
cleared and the queued message drives the next turn normally.
|
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)
|
snapshots the `~/.claude/` dir (newest file mtime + file count)
|
||||||
at entry and only resumes when that snapshot advances — not
|
at entry and only resumes when that snapshot advances — not
|
||||||
just when credentials exist on disk. This prevents a silent
|
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
|
- `claude-system-prompt.md` — rendered from
|
||||||
`hive-ag3nt/prompts/system.md` by `hive_ag3nt::prompt::render`:
|
`hive-ag3nt/prompts/system.md` by `hive_ag3nt::prompt::render`:
|
||||||
HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`,
|
HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`,
|
||||||
same for `role:manager`) gate the role-specific blocks (closes
|
same for `role:manager`) gate the role-specific blocks; everything
|
||||||
#519); everything else is shared. Five placeholders are then
|
else is shared. Five placeholders are then
|
||||||
substituted: `{label}` (short agent name), `{qualified_label}`
|
substituted: `{label}` (short agent name), `{qualified_label}`
|
||||||
(hive-qualified `name@domain` form), `{operator_pronouns}`,
|
(hive-qualified `name@domain` form), `{operator_pronouns}`,
|
||||||
`{hive_identity}` (e.g. `` on hive `pr1ma` ``; empty when
|
`{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`
|
`agent.nix`, commits the changes, and calls `request_apply_commit`
|
||||||
with the commit sha — the first ApplyCommit on a freshly-init'd
|
with the commit sha — the first ApplyCommit on a freshly-init'd
|
||||||
config creates the container. Fails if a proposed repo for this
|
config creates the container. Fails if a proposed repo for this
|
||||||
name already exists. (The pre-#442 path through a separate
|
name already exists. (The operator can also direct-spawn an empty
|
||||||
manager-side `request_spawn` was removed; operator can still
|
agent from the dashboard's `◆ R3QU3ST SP4WN` button, which routes
|
||||||
direct-spawn an empty agent from the dashboard's `◆ R3QU3ST SP4WN`
|
via `HostRequest::RequestSpawn`.)
|
||||||
button which routes via `HostRequest::RequestSpawn`.)
|
|
||||||
- `kill(name)` — graceful stop. No approval required.
|
- `kill(name)` — graceful stop. No approval required.
|
||||||
- `start(name)` — start a stopped sub-agent. No approval.
|
- `start(name)` — start a stopped sub-agent. No approval.
|
||||||
- `restart(name)` — stop + start. No approval.
|
- `restart(name)` — stop + start. No approval.
|
||||||
|
|
@ -398,7 +397,7 @@ meta's.
|
||||||
approval (use `remind` for unapproved self-wake). Long downtime
|
approval (use `remind` for unapproved self-wake). Long downtime
|
||||||
fires once per recurring row on resume (catch-up clamp).
|
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?)` —
|
- `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`
|
change; absent fields are left alone. `targets_add` / `targets_remove`
|
||||||
mutate the recipient list in the same transaction; re-adding a
|
mutate the recipient list in the same transaction; re-adding a
|
||||||
previously-cancelled target drops its tombstone + history (fresh
|
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)
|
Two web surfaces share the same skeleton: the dashboard (port 7000)
|
||||||
and the per-agent UIs (every container — including the manager —
|
and the per-agent UIs (every container — including the manager —
|
||||||
hashes into :8100-8999 via `lifecycle::agent_web_port`'s FNV-1a,
|
hashes into :8100-8999 via `lifecycle::agent_web_port`'s FNV-1a).
|
||||||
since #753). Both are SPAs — `GET /` returns a static shell,
|
Both are SPAs — `GET /` returns a static shell, `/api/state`
|
||||||
`/api/state` returns JSON, JS renders. No full-page reloads.
|
returns JSON, JS renders. No full-page reloads.
|
||||||
|
|
||||||
## Shape (shared by both)
|
## 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
|
based, no `innerHTML` — XSS-safe); markdown bodies get the
|
||||||
same treatment via `marked`'s autolink (npm dep, replacing the
|
same treatment via `marked`'s autolink (npm dep, replacing the
|
||||||
vendored UMD bundle), with the rendered `<a>`s rewritten to
|
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
|
- `GET /api/state` → JSON snapshot the JS app renders into the
|
||||||
DOM. Includes a top-level `seq` (the dashboard event channel's
|
DOM. Includes a top-level `seq` (the dashboard event channel's
|
||||||
high-water mark at the moment the snapshot was assembled);
|
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
|
~200 broker messages wrapped in `{ seq, events }`) on the
|
||||||
dashboard and `GET /events/history` (last 2000 `LiveEvent`s
|
dashboard and `GET /events/history` (last 2000 `LiveEvent`s
|
||||||
also wrapped in `{ seq, events }`) on the agent.
|
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
|
`SharedWorker` (`stream-worker.js`) to hold one upstream
|
||||||
`EventSource` per URL. All same-origin tabs share this worker
|
`EventSource` per URL. All same-origin tabs share this worker
|
||||||
— a second dashboard tab joins the existing connection rather
|
— 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
|
page re-subscribes (gets a synthetic `open` event immediately
|
||||||
if the upstream is already connected). Falls back gracefully
|
if the upstream is already connected). Falls back gracefully
|
||||||
when `SharedWorker` is unavailable (e.g. some private-mode
|
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
|
"idle" SharedWorkers under memory pressure with no client-side
|
||||||
signal — the port silently goes no-op. The worker now pings
|
signal — the port silently goes no-op. The worker now pings
|
||||||
every connected port every 30s; the client bumps a
|
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
|
bfcache-restore uses). Recovery is per-tab; pings are
|
||||||
invisible on the healthy path.
|
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
|
The JS app handles all `form[data-async]` submissions via a delegated
|
||||||
listener: read `data-confirm`, swap the button to a spinner, POST
|
listener: read `data-confirm`, swap the button to a spinner, POST
|
||||||
`application/x-www-form-urlencoded`, re-enable the button on success
|
`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,
|
the button, a backdrop click, or `Escape`. `Panel.open(title,
|
||||||
node)` swaps the body; the JS builders for file previews,
|
node)` swaps the body; the JS builders for file previews,
|
||||||
approval diffs, and journald logs all render into it. **The
|
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
|
the left edge captures pointer events, resizes the drawer in
|
||||||
real-time (pointer capture keeps dragging even if the cursor
|
real-time (pointer capture keeps dragging even if the cursor
|
||||||
outpaces the handle), and persists the chosen width to
|
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
|
`services.hyperhive.matrix.gui.package`) served by the hive-gateway
|
||||||
nginx container at `/matrix/` when
|
nginx container at `/matrix/` when
|
||||||
`services.hyperhive.matrix.gui.enable` is on (defaults to
|
`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
|
`HIVE_MATRIX_GUI_ENABLED` env var → `state.matrix_gui_enabled` in
|
||||||
`/api/state`; the gateway does the actual static serving.
|
`/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
|
The unified nginx-front re-root to
|
||||||
`https://matrix.${hyperhive.domain}` + `.well-known/matrix/client`
|
`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`)
|
### FL0W page (`/flow.html`)
|
||||||
|
|
||||||
|
|
@ -383,7 +461,14 @@ newest-first.
|
||||||
**MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`.
|
**MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`.
|
||||||
Cold load backfills the last ~200 messages from `/dashboard/history`;
|
Cold load backfills the last ~200 messages from `/dashboard/history`;
|
||||||
live frames arrive on `/dashboard/stream`. Each row is one broker
|
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
|
bottom auto-scroll + "↓ N new" pill. Below the stream sits a
|
||||||
terminal-style compose box: `@name` picks the recipient (sticky via
|
terminal-style compose box: `@name` picks the recipient (sticky via
|
||||||
localStorage; auto-complete from the live container list, Tab/Enter
|
localStorage; auto-complete from the live container list, Tab/Enter
|
||||||
|
|
@ -482,7 +567,7 @@ fetch entirely.
|
||||||
`ContainerView.context_window_tokens`; the badge goes yellow
|
`ContainerView.context_window_tokens`; the badge goes yellow
|
||||||
≥ 50% and red ≥ 75% of that window (the harness compaction
|
≥ 50% and red ≥ 75% of that window (the harness compaction
|
||||||
watermarks). When the window can't be resolved the badge falls
|
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
|
- Line 2: status badges only (no per-card action buttons — actions
|
||||||
moved to the **selection bar**, see below).
|
moved to the **selection bar**, see below).
|
||||||
- Line 3: drill-in triggers —
|
- Line 3: drill-in triggers —
|
||||||
|
|
@ -492,7 +577,7 @@ fetch entirely.
|
||||||
A unit dropdown (harness service / full machine journal) and
|
A unit dropdown (harness service / full machine journal) and
|
||||||
a refresh button live in the panel. The panel uses a column-flex
|
a refresh button live in the panel. The panel uses a column-flex
|
||||||
layout so the `<pre>` log surface fills the full remaining panel
|
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.
|
panel body.
|
||||||
- Plain navigation links (config repo, forge profile,
|
- Plain navigation links (config repo, forge profile,
|
||||||
`dashboardLinks` extras) now live in the icon-only nav strip
|
`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
|
siblings below) or `└` (last sibling at this depth — vertical
|
||||||
stops at the row's icon midline).
|
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
|
### Selection bar
|
||||||
|
|
||||||
Per-card action buttons (`R3ST4RT` / `ST0P` / `ST4RT` / `R3BU1LD` /
|
Per-card action buttons (`R3ST4RT` / `ST0P` / `ST4RT` / `R3BU1LD` /
|
||||||
|
|
@ -650,13 +755,13 @@ not ours.
|
||||||
the sentinel `[cancelled]`. Same code path as a real answer.
|
the sentinel `[cancelled]`. Same code path as a real answer.
|
||||||
- `POST /request-spawn` — queue a Spawn approval.
|
- `POST /request-spawn` — queue a Spawn approval.
|
||||||
- `POST /update-all` — rebuild every stale container.
|
- `POST /update-all` — rebuild every stale container.
|
||||||
- `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry
|
- `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry.
|
||||||
(#447). Refuses `Running` / terminal-state entries (in-flight
|
Refuses `Running` / terminal-state entries (in-flight
|
||||||
rebuilds can't be safely interrupted). Always 200; body is
|
rebuilds can't be safely interrupted). Always 200; body is
|
||||||
`{"cancelled": true}` on a successful flip or
|
`{"cancelled": true}` on a successful flip or
|
||||||
`{"cancelled": false}` when the entry was not in `Queued` state.
|
`{"cancelled": false}` when the entry was not in `Queued` state.
|
||||||
- `POST /api/agent/{name}/mark-all-read` — ack all pending broker
|
- `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
|
not yet delivered and sets `acked_at = now`. Returns
|
||||||
`{ "marked": N }`. Agent name validated against
|
`{ "marked": N }`. Agent name validated against
|
||||||
`[a-z0-9_-]`, 1-63 chars; 400 on bad input.
|
`[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? }`.
|
`{ targets, body, first_fire_at_unix, interval_seconds?, description? }`.
|
||||||
Agent-initiated schedules go through the approval queue instead
|
Agent-initiated schedules go through the approval queue instead
|
||||||
(manager MCP `request_schedule_prompt`).
|
(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?,
|
`{ body?, description?, interval_seconds?, next_fire_at_unix?,
|
||||||
targets_add?, targets_remove? }`.
|
targets_add?, targets_remove? }`.
|
||||||
Missing key = "leave alone"; explicit `null` on
|
Missing key = "leave alone"; explicit `null` on
|
||||||
|
|
@ -723,7 +828,7 @@ not ours.
|
||||||
`{ targets?: ["name", …] }` cancels just those recipients;
|
`{ targets?: ["name", …] }` cancels just those recipients;
|
||||||
absent or empty body cancels the whole schedule.
|
absent or empty body cancels the whole schedule.
|
||||||
- `POST /api/schedules/{id}/fire-now` — out-of-band manual
|
- `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`
|
every active target. Recurring schedules: `next_fire_at_unix`
|
||||||
is untouched; the regular cadence continues. One-shots: the
|
is untouched; the regular cadence continues. One-shots: the
|
||||||
schedule is consumed (cancelled) after the manual fan-out.
|
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
|
**Fixed-overlay header** (`<header class="agent-header">`): frosted
|
||||||
glass — `backdrop-filter: blur` lets scrolled terminal rows show
|
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
|
- **Agent icon** (`<img class="agent-icon">`): fixed-size square
|
||||||
identity anchor (5em, `width: 5em; aspect-ratio: 1;
|
identity anchor — `width: 5em; height: 5em` with explicit pixel
|
||||||
align-self: flex-start` — capped so a tall state-row doesn't
|
sizing so the `<img>`'s intrinsic (large) dimensions don't push
|
||||||
inflate the icon, #411). Falls back to the dimmed hyperhive mark
|
the parent flex container open via `align-items: stretch`-driven
|
||||||
on load error.
|
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.
|
- **Main column** (`.agent-header-main`): two rows.
|
||||||
- Row 1 (`.agent-header-title-row`): title (`<h2 id="title">`) +
|
- Row 1 (`.agent-header-title-row`): title (`<h2 id="title">`) +
|
||||||
meta-nav (`<nav id="meta-links">`). Meta-nav renders
|
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,
|
(POST confirm → `POST /api/logout`; SIGINTs any in-flight turn,
|
||||||
wipes OAuth credential files, flips the agent to `needs_login`
|
wipes OAuth credential files, flips the agent to `needs_login`
|
||||||
— session history preserved). All destructive actions require
|
— session history preserved). All destructive actions require
|
||||||
one extra click to acknowledge (#394 — rare ops shouldn't live
|
one extra click to acknowledge — rare ops shouldn't live in the
|
||||||
in the primary state strip).
|
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
|
`/api/state` is fetched once on cold load (+ while
|
||||||
`status === 'needs_login_in_progress'`); all other updates arrive via
|
`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
|
(`name@domain` form when `HYPERHIVE_HIVE_DOMAIN` is set, otherwise
|
||||||
just `name`). The frontend uses `qualified_label` to set the browser
|
just `name`). The frontend uses `qualified_label` to set the browser
|
||||||
tab title so two tabs from different hives are distinguishable; the
|
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
|
**Main content** (`<main class="agent-main">`): fills the viewport
|
||||||
and scrolls behind the fixed header + footer.
|
and scrolls behind the fixed header + footer.
|
||||||
|
|
@ -1048,14 +1160,26 @@ shaped).
|
||||||
`wait_for_login` entry.
|
`wait_for_login` entry.
|
||||||
- `GET /events/history` — replay buffer for the terminal.
|
- `GET /events/history` — replay buffer for the terminal.
|
||||||
- `GET /screen` — VNC viewer page (minimal RFB-over-WebSocket
|
- `GET /screen` — VNC viewer page (minimal RFB-over-WebSocket
|
||||||
renderer). Only accessible when `hyperhive.gui.enable = true`
|
renderer — deliberately thin, just enough to display the
|
||||||
in the agent's `agent.nix`; the harness shows a 🖥 screen link
|
desktop + forward pointer + keyboard. A production-grade viewer
|
||||||
in the state row when `gui_vnc_port` is present. Toolbar:
|
would vendor noVNC; this file ships the minimal in-tree variant).
|
||||||
`⤢ fit` CSS-downscales the canvas to the window; `⤡ match size`
|
Only accessible when `hyperhive.gui.enable = true` in the agent's
|
||||||
sends an RFB `SetDesktopSize` request so the server (weston)
|
`agent.nix`; the harness shows a 🖥 screen link in the state row
|
||||||
changes its real output resolution to the window dimensions —
|
when `gui_vnc_port` is present. Toolbar: `⤢ fit` CSS-downscales
|
||||||
enabled once the server advertises the `ExtendedDesktopSize`
|
the canvas to the window via `relayoutCanvas()` setting explicit
|
||||||
pseudo-encoding (issue #133).
|
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
|
- `GET /screen/ws` — raw RFB byte relay: proxies WebSocket
|
||||||
frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
|
frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
|
||||||
Transparent to any RFB variant. VNC port comes from
|
Transparent to any RFB variant. VNC port comes from
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,16 @@
|
||||||
@import "@hive/shared/base.css";
|
@import "@hive/shared/base.css";
|
||||||
@import "@hive/shared/terminal.css";
|
@import "@hive/shared/terminal.css";
|
||||||
|
|
||||||
/* ─── full-screen vibec0re overhaul (issue #360) ──────────────────
|
/* ─── full-screen layout overrides ─────────────────────────────────
|
||||||
Layout shape: fixed-position frosted-glass header at top, fixed-
|
The agent page mounts a full-viewport terminal under a fixed
|
||||||
position composer at bottom, full-viewport terminal in between.
|
frosted-glass header + composer pair. See docs/web-ui.md::Per-agent
|
||||||
The terminal scrolls — its text passes BENEATH the floating
|
page for the layer / pill / side-panel structure; rules below
|
||||||
header/composer with backdrop-filter blur for the frosted look.
|
override the in-page defaults from @hive/shared. */
|
||||||
Inbox + loose-ends move into the side-panel flyout; header pills
|
|
||||||
surface their counts as the only chrome they get. */
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Bumped to 6em (#394) so the agent icon can be a full-height
|
/* 6em min-height accommodates the full-height agent icon (5em +
|
||||||
square identity anchor without crowding the two-row main column
|
padding) alongside the two-row main column (title + nav-links
|
||||||
(title + nav-links on top, state strip below). */
|
on top, state strip below). */
|
||||||
--agent-header-h: 6em;
|
--agent-header-h: 6em;
|
||||||
--agent-composer-h: 3.6em;
|
--agent-composer-h: 3.6em;
|
||||||
--agent-frost-bg: rgba(30, 30, 46, 0.72);
|
--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);
|
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. */
|
vertically against the full-height icon on the left. */
|
||||||
.agent-header-main {
|
.agent-header-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -140,12 +138,10 @@ h2, h3 {
|
||||||
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
|
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
|
||||||
}
|
}
|
||||||
.agent-icon {
|
.agent-icon {
|
||||||
/* Square identity anchor (#394 — mara's spec). Explicit em sizing
|
/* Square identity anchor — explicit 5em sizing + align-self.
|
||||||
so the <img>'s intrinsic (large) dimensions don't push the
|
See docs/web-ui.md::Per-agent page (Agent icon) for the
|
||||||
parent flex container open via `align-items: stretch`-driven
|
intrinsic-dim-pushes-parent-flex-open + sticks-to-top
|
||||||
height feedback. Width = header content area (header min-h 6em
|
rationale. */
|
||||||
- 2 × 0.5em padding ≈ 5em). Sticks to the top so a state-row
|
|
||||||
wrap doesn't drag the icon down with it. (#411) */
|
|
||||||
width: 5em;
|
width: 5em;
|
||||||
height: 5em;
|
height: 5em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
@ -156,9 +152,9 @@ h2, h3 {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Meta-nav links (stats / screen / forge / dashboard / extras) —
|
/* Meta-nav links (stats / screen / forge / dashboard / extras) —
|
||||||
no underline (#394 mara's spec); hover lights with cyan glow +
|
no underline; hover lights with cyan glow + subtle background
|
||||||
subtle background tint. Reads as a row of soft tabs rather than
|
tint. Reads as a row of soft tabs rather than default-styled
|
||||||
default-styled inline anchors. */
|
inline anchors. */
|
||||||
.agent-nav-link {
|
.agent-nav-link {
|
||||||
color: var(--cyan);
|
color: var(--cyan);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
@ -176,7 +172,7 @@ h2, h3 {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Overflow menu trigger — `⋯` round button on the right of the
|
/* 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 {
|
.overflow-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--purple-dim);
|
border: 1px solid var(--purple-dim);
|
||||||
|
|
@ -200,13 +196,11 @@ h2, h3 {
|
||||||
box-shadow: 0 0 10px -2px var(--purple);
|
box-shadow: 0 0 10px -2px var(--purple);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Overflow popover — rebuild + new-session (and the dashboard
|
/* Overflow popover — rebuild + new-session + logout (and the
|
||||||
back-link, prepended in app.js setHeader). Positioned in JS so
|
dashboard back-link, prepended in app.js setHeader). Positioned
|
||||||
the menu's top-right corner anchors under the trigger button.
|
in JS so the menu's top-right corner anchors under the trigger
|
||||||
`:not([hidden])` scoping (#411): the `[hidden]` HTML attribute
|
button. See docs/web-ui.md::Per-agent page (Overflow button)
|
||||||
sets `display: none` via the UA stylesheet, but author CSS's
|
for the `:not([hidden])` scoping rationale. */
|
||||||
`display: flex` would override that. Scope display rules so
|
|
||||||
they apply only when the menu is unhidden. */
|
|
||||||
.overflow-menu:not([hidden]) {
|
.overflow-menu:not([hidden]) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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-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-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
|
/* Orphaned rules — left here as a tombstone so a search for the
|
||||||
overflow menu in #394 (`.overflow-item-rebuild` covers it now).
|
class name finds them. Live consumers gone:
|
||||||
The dashboard has its own `.btn-rebuild` rule for the per-row
|
- `.btn-rebuild` was a per-agent header chip (covered now by
|
||||||
R3BU1LD form on the SW4RM tab; this one was specific to the
|
`.overflow-item-rebuild` in the overflow menu).
|
||||||
per-agent header.
|
- `.btn-send` was a green send-button variant — the dashboard's
|
||||||
`.btn-send` was a green send-button variant — orphaned since
|
compose form that used it is retired. */
|
||||||
the dashboard's compose form was retired; no live consumer left
|
|
||||||
in either the agent or dashboard tree. */
|
|
||||||
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
||||||
.sendform input {
|
.sendform input {
|
||||||
font-family: inherit; font-size: 1em;
|
font-family: inherit; font-size: 1em;
|
||||||
|
|
@ -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); }
|
.loginform input:focus { outline: 1px solid var(--purple); }
|
||||||
|
|
||||||
/* #568: show / hide toggle for the masked OAuth-code input. Quiet
|
/* Show / hide toggle for the masked OAuth-code input. Quiet by
|
||||||
by default (muted border + transparent bg), lights amber on
|
default (muted border + transparent bg), lights amber on hover
|
||||||
hover / when pressed (aria-pressed="true") so the operator
|
/ when pressed (`aria-pressed="true"`) so the operator sees at
|
||||||
sees at a glance whether the code is currently visible. */
|
a glance whether the code is currently visible. */
|
||||||
.loginform-reveal {
|
.loginform-reveal {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
|
|
@ -489,7 +481,7 @@ pre.diff {
|
||||||
/* Inbox / loose-ends rows: header (from / sep / ts) on one line,
|
/* Inbox / loose-ends rows: header (from / sep / ts) on one line,
|
||||||
body on its own line below — gives the body the full panel width
|
body on its own line below — gives the body the full panel width
|
||||||
instead of squeezing it into a fourth grid column that wrapped
|
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 {
|
.agent-inbox li {
|
||||||
padding: 0.4em 0;
|
padding: 0.4em 0;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -552,15 +544,16 @@ pre.diff {
|
||||||
.agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; }
|
.agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; }
|
||||||
.agent-inbox .answer-status { color: var(--muted); align-self: center; }
|
.agent-inbox .answer-status { color: var(--muted); align-self: center; }
|
||||||
|
|
||||||
/* #666: inline answer slot mounted under each `ask → operator` row
|
/* Inline answer slot mounted under each `ask → operator` row in
|
||||||
in the live terminal stream. Mirrors the side-panel `.answer-form`
|
the live terminal stream. Mirrors the side-panel `.answer-form`
|
||||||
look-and-feel — same textarea, same send button — so the operator
|
look-and-feel — same textarea, same send button — so the operator
|
||||||
doesn't have to context-switch between "answering in the panel" and
|
doesn't have to context-switch between "answering in the panel"
|
||||||
"answering inline". Empty slot collapses to nothing (no margin) so
|
and "answering inline". Empty slot collapses to nothing (no
|
||||||
pre-bind rows stay tidy; populated slot gets a thin top divider to
|
margin) so pre-bind rows stay tidy; populated slot gets a thin
|
||||||
separate the question body from the form. Resolved tag (the
|
top divider to separate the question body from the form. A
|
||||||
struck-through `[answered ✓]`) replaces the form once the
|
`[resolved]` tag (neutral, covers answered / cancelled /
|
||||||
question's been answered. */
|
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:empty { display: none; }
|
||||||
.live .ask-answer-inline-slot {
|
.live .ask-answer-inline-slot {
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
|
|
@ -613,10 +606,10 @@ pre.diff {
|
||||||
text-decoration-color: var(--muted);
|
text-decoration-color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* #559: "mark all read" header row sits above the recent-messages
|
/* "mark all read" header row sits above the recent-messages list
|
||||||
list in the inbox side-panel flyout. Same look as the answer-form
|
in the inbox side-panel flyout. Same look as the answer-form
|
||||||
button (mauve hover, bg-elev background) so they read as part of
|
button (mauve hover, bg-elev background) so they read as part
|
||||||
the same affordance family. */
|
of the same affordance family. */
|
||||||
.agent-inbox .inbox-mark-all-row {
|
.agent-inbox .inbox-mark-all-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.6em;
|
gap: 0.6em;
|
||||||
|
|
@ -697,8 +690,9 @@ pre.diff {
|
||||||
text-shadow: 0 0 6px rgba(243, 139, 168, 0.55); }
|
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-needs-login { color: var(--amber); border-color: var(--amber); }
|
||||||
.status-badge.status-offline { color: var(--muted); border-color: var(--muted); }
|
.status-badge.status-offline { color: var(--muted); border-color: var(--muted); }
|
||||||
/* Orphaned in #394 — `.btn-dashlink` chip beside the title moved
|
/* Orphaned tombstone — `.btn-dashlink` chip that lived beside the
|
||||||
into the overflow menu (`.overflow-item-dashboard` covers it). */
|
title was moved into the overflow menu (`.overflow-item-dashboard`
|
||||||
|
covers it now). */
|
||||||
.btn-cancel-turn {
|
.btn-cancel-turn {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
|
|
@ -716,8 +710,8 @@ pre.diff {
|
||||||
background: rgba(243, 139, 168, 0.1);
|
background: rgba(243, 139, 168, 0.1);
|
||||||
box-shadow: 0 0 10px -2px currentColor;
|
box-shadow: 0 0 10px -2px currentColor;
|
||||||
}
|
}
|
||||||
/* Orphaned in #394 — `.btn-new-session` round-pill moved into the
|
/* Orphaned tombstone — `.btn-new-session` round-pill moved into
|
||||||
overflow menu (`.overflow-item-new-session` covers it; the
|
the overflow menu (`.overflow-item-new-session` covers it; the
|
||||||
`:disabled` opacity treatment lives on the shared
|
`:disabled` opacity treatment lives on the shared
|
||||||
`.overflow-item:disabled` rule). */
|
`.overflow-item:disabled` rule). */
|
||||||
.state-badge {
|
.state-badge {
|
||||||
|
|
@ -793,7 +787,10 @@ pre.diff {
|
||||||
/* Tail pill (↓ N new): nudged up so it floats clear of the composer
|
/* Tail pill (↓ N new): nudged up so it floats clear of the composer
|
||||||
rather than colliding with the frosted bar. z-index bumped above
|
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
|
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 {
|
.agent-main .tail-pill {
|
||||||
bottom: calc(var(--agent-composer-h) + 0.6em);
|
bottom: calc(var(--agent-composer-h) + 0.6em);
|
||||||
z-index: 35;
|
z-index: 35;
|
||||||
|
|
@ -843,10 +840,9 @@ pre.diff {
|
||||||
/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */
|
/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */
|
||||||
|
|
||||||
/* ─── side panel (singleton drawer) ────────────────────────────────
|
/* ─── side panel (singleton drawer) ────────────────────────────────
|
||||||
Inbox + loose-ends details open here instead of expanding inline
|
Inbox + loose-ends details open here instead of expanding inline.
|
||||||
(issue #360). Copy of the dashboard's side-panel pattern —
|
Copy of the dashboard's side-panel pattern — candidate for
|
||||||
candidate for extraction into @hive/shared once both surfaces
|
extraction into @hive/shared once both surfaces stabilize. */
|
||||||
stabilize. */
|
|
||||||
.side-panel {
|
.side-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,7 @@ window.marked = marked;
|
||||||
|
|
||||||
// ─── side panel (singleton drawer for inbox + loose-ends flyouts) ──────
|
// ─── side panel (singleton drawer for inbox + loose-ends flyouts) ──────
|
||||||
// Shared shape with the dashboard's panel. Candidate for extraction
|
// Shared shape with the dashboard's panel. Candidate for extraction
|
||||||
// into @hive/shared in a follow-up — keeping the duplication for
|
// into @hive/shared once both surfaces stabilise.
|
||||||
// now to land #360 without simultaneously refactoring the dashboard.
|
|
||||||
const Panel = (() => {
|
const Panel = (() => {
|
||||||
const root = $('side-panel');
|
const root = $('side-panel');
|
||||||
const titleEl = $('side-panel-title');
|
const titleEl = $('side-panel-title');
|
||||||
|
|
@ -141,13 +140,12 @@ window.marked = marked;
|
||||||
// ─── state rendering ────────────────────────────────────────────────────
|
// ─── state rendering ────────────────────────────────────────────────────
|
||||||
function setHeader(label, qualifiedLabel, dashboardPort) {
|
function setHeader(label, qualifiedLabel, dashboardPort) {
|
||||||
const title = $('title');
|
const title = $('title');
|
||||||
// Title is now just the glowing identity glyph — DASHB04RD,
|
// Title is just the glowing identity glyph — DASHB04RD, R3BU1LD,
|
||||||
// R3BU1LD, NEW SESSION all live in the overflow `⋯` menu now
|
// NEW SESSION live in the overflow `⋯` menu. Glow + uppercase
|
||||||
// (#394). Glow + uppercase styling from h2 / .agent-header-title-row.
|
// styling from h2 / .agent-header-title-row. The glyphic title
|
||||||
// The glyphic title stays short (no @hive suffix) — the hive
|
// stays short (no @hive suffix) — the hive qualifier lives on
|
||||||
// qualifier lives on the second row's `qualified` chip + the
|
// the second row's `qualified` chip + the browser tab title so
|
||||||
// browser tab title so the cinematic header reads cleanly at a
|
// the cinematic header reads cleanly at a glance.
|
||||||
// glance (#589).
|
|
||||||
title.textContent = `◆ ${label} ◆`;
|
title.textContent = `◆ ${label} ◆`;
|
||||||
// Document title carries the qualified name so the browser's tab
|
// Document title carries the qualified name so the browser's tab
|
||||||
// bar disambiguates between same-named agents on different hives
|
// bar disambiguates between same-named agents on different hives
|
||||||
|
|
@ -160,20 +158,17 @@ window.marked = marked;
|
||||||
populateOverflowMenu(label, dashUrl);
|
populateOverflowMenu(label, dashUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overflow popover: dashboard back-link + rebuild + new-session.
|
// Overflow popover: dashboard back-link + rebuild + new-session +
|
||||||
// Per #394 mara's spec — rebuild + new-session both moved off the
|
// logout. Rare + destructive actions live here behind one extra
|
||||||
// header strip into the `⋯` menu (rare actions, both destructive
|
// click (the operator rebuilds from the host dashboard normally).
|
||||||
// enough to warrant one extra click; the operator rebuilds from
|
// See docs/web-ui.md::Per-agent page (Overflow button).
|
||||||
// the host dashboard normally). Dashboard link also slotted in so
|
|
||||||
// every "leave this page" action lives in one menu.
|
|
||||||
let overflowMenuPopulated = false;
|
let overflowMenuPopulated = false;
|
||||||
function populateOverflowMenu(label, dashUrl) {
|
function populateOverflowMenu(label, dashUrl) {
|
||||||
const menu = $('overflow-menu');
|
const menu = $('overflow-menu');
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
menu.replaceChildren();
|
menu.replaceChildren();
|
||||||
|
|
||||||
// ↑ dashboard — host dashboard back-link (was `.btn-dashlink`
|
// ↑ dashboard — host dashboard back-link.
|
||||||
// beside the title pre-#394).
|
|
||||||
menu.append(el('a', {
|
menu.append(el('a', {
|
||||||
class: 'overflow-item overflow-item-dashboard',
|
class: 'overflow-item overflow-item-dashboard',
|
||||||
href: dashUrl,
|
href: dashUrl,
|
||||||
|
|
@ -227,15 +222,14 @@ window.marked = marked;
|
||||||
});
|
});
|
||||||
menu.append(newSessBtn);
|
menu.append(newSessBtn);
|
||||||
|
|
||||||
// 🔓 logout (#576) — SIGINTs claude, wipes the credentials dir,
|
// 🔓 logout — SIGINTs claude, wipes the credentials dir, flips
|
||||||
// flips the harness LoginState to NeedsLogin. The turn loop's
|
// the harness LoginState to NeedsLogin. The turn loop's next
|
||||||
// next iteration parks in wait_for_login; a fresh `claude auth
|
// iteration parks in wait_for_login; a fresh `claude auth login`
|
||||||
// login` from the dashboard re-arms it (#542 mtime resumption).
|
// from the dashboard re-arms it. Operator has to re-paste OAuth
|
||||||
// Operator has to re-paste OAuth creds on the login screen after,
|
// creds on the login screen after, but the --continue session
|
||||||
// but the --continue session history is preserved (#584 narrowed
|
// history is preserved (the backend wipe is narrowed to just
|
||||||
// the backend wipe to just .credentials.json + mcp-needs-auth-
|
// .credentials.json + mcp-needs-auth-cache.json) — so the agent
|
||||||
// cache.json) — so the agent picks up where it left off on the
|
// picks up where it left off on the next turn after re-login.
|
||||||
// next turn after re-login.
|
|
||||||
const logoutBtn = el('button', {
|
const logoutBtn = el('button', {
|
||||||
type: 'button',
|
type: 'button',
|
||||||
class: 'overflow-item overflow-item-logout',
|
class: 'overflow-item overflow-item-logout',
|
||||||
|
|
@ -464,8 +458,9 @@ window.marked = marked;
|
||||||
if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err);
|
if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// First arg is the URL path (relative to document base, #14);
|
// First arg is the URL path (relative to document base — see
|
||||||
// second is the slash-command label rendered in the local note.
|
// 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 postCancelTurn = () => postSimple('api/cancel', '/cancel');
|
||||||
const postCompact = () => postSimple('api/compact', '/compact');
|
const postCompact = () => postSimple('api/compact', '/compact');
|
||||||
const postNewSession = () => postSimple('api/new-session', '/new-session');
|
const postNewSession = () => postSimple('api/new-session', '/new-session');
|
||||||
|
|
@ -485,7 +480,7 @@ window.marked = marked;
|
||||||
return true;
|
return true;
|
||||||
case '/clear':
|
case '/clear':
|
||||||
termAPI.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
|
// mount-point in the DOM — drop their slots so subsequent
|
||||||
// loose-ends reconciliation doesn't walk dead references.
|
// loose-ends reconciliation doesn't walk dead references.
|
||||||
pendingAskBinds.length = 0;
|
pendingAskBinds.length = 0;
|
||||||
|
|
@ -837,12 +832,11 @@ window.marked = marked;
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** #559: "mark all read" affordance for the agent's inbox flyout.
|
/** "mark all read" affordance for the agent's inbox flyout —
|
||||||
* Returns a DOM row containing a button + an inline status pill.
|
* see docs/web-ui.md::Per-agent page (Loose-ends flyout) for the
|
||||||
* POSTs to the host dashboard's `/api/agent/{name}/mark-all-read`
|
* cross-origin POST + count rendering. Returns a DOM row
|
||||||
* (damocles PR #566) and surfaces the `{ marked: N }` count back to
|
* containing a button + an inline status pill; re-runs
|
||||||
* the operator. Re-runs `onCleared` on success so the caller can
|
* `onCleared` on success so the caller can refresh its own state. */
|
||||||
* refresh whatever state it owns. */
|
|
||||||
function buildInboxMarkAllRow(label, onCleared) {
|
function buildInboxMarkAllRow(label, onCleared) {
|
||||||
const status = el('span', { class: 'inbox-mark-status' });
|
const status = el('span', { class: 'inbox-mark-status' });
|
||||||
const btn = el('button', {
|
const btn = el('button', {
|
||||||
|
|
@ -896,12 +890,12 @@ window.marked = marked;
|
||||||
'inbox empty.'));
|
'inbox empty.'));
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
// #559: "mark all read" header row drains the host broker's
|
// "mark all read" header row drains the host broker's pending +
|
||||||
// pending + delivered-unacked rows for this agent (damocles PR
|
// delivered-unacked rows for this agent. Visible rows here are
|
||||||
// #566). Visible rows here are the most-recent-N regardless of
|
// the most-recent-N regardless of ack state, so the list itself
|
||||||
// ack state, so the list itself doesn't visually empty on click —
|
// doesn't visually empty on click — the status pill confirms
|
||||||
// the status pill confirms the drain count, and the next
|
// the drain count, and the next turn_start's "unread" badge
|
||||||
// turn_start's "unread" badge will read zero.
|
// will read zero.
|
||||||
wrap.append(buildInboxMarkAllRow(currentLabel, () => {
|
wrap.append(buildInboxMarkAllRow(currentLabel, () => {
|
||||||
// Refresh state so any UI surface that DOES depend on
|
// Refresh state so any UI surface that DOES depend on
|
||||||
// delivery state (eg future per-status filters) picks up
|
// 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
|
// Track banner activity by reference-counting in-flight turns. A turn
|
||||||
// can begin while the previous turn_end is still in the pipeline (rare
|
// 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.
|
// but happens on tight wake cycles), so we count rather than toggle.
|
||||||
|
|
@ -1074,9 +1064,8 @@ window.marked = marked;
|
||||||
rel: 'noopener',
|
rel: 'noopener',
|
||||||
title: lnk.label || '',
|
title: lnk.label || '',
|
||||||
});
|
});
|
||||||
// Layout gap comes from `.agent-nav { gap }` (#394) — drop
|
// Layout gap comes from `.agent-nav { gap }`. The trailing
|
||||||
// the legacy per-link inline `marginLeft`. The trailing `→`
|
// `→` is the "leaves this page" affordance.
|
||||||
// is the "leaves this page" affordance.
|
|
||||||
a.append(((lnk.icon || '') + ' ' + (lnk.label || '')).trim() + ' →');
|
a.append(((lnk.icon || '') + ' ' + (lnk.label || '')).trim() + ' →');
|
||||||
metaLinks.append(a);
|
metaLinks.append(a);
|
||||||
});
|
});
|
||||||
|
|
@ -1154,7 +1143,7 @@ window.marked = marked;
|
||||||
marked.setOptions({ breaks: true, gfm: true });
|
marked.setOptions({ breaks: true, gfm: true });
|
||||||
div.innerHTML = marked.parse(src);
|
div.innerHTML = marked.parse(src);
|
||||||
// marked autolinks URLs but leaves them same-tab — open them
|
// 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) => {
|
div.querySelectorAll('a[href]').forEach((a) => {
|
||||||
a.target = '_blank';
|
a.target = '_blank';
|
||||||
a.rel = 'noopener noreferrer';
|
a.rel = 'noopener noreferrer';
|
||||||
|
|
@ -1306,15 +1295,10 @@ window.marked = marked;
|
||||||
const d = detailsOpenMd(api, 'tool-use',
|
const d = detailsOpenMd(api, 'tool-use',
|
||||||
'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
|
'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
|
||||||
q);
|
q);
|
||||||
// #666: when the ask targets the operator, mount an inline
|
// When the ask targets the operator, mount an inline answer
|
||||||
// answer slot in the live terminal so the operator doesn't
|
// slot in the live terminal — see docs/web-ui.md::Per-agent
|
||||||
// need to open the loose-ends side panel (or jump to Y3R C4LL
|
// page (Ask → operator inline-answer binding) for the slot
|
||||||
// on the dashboard) to respond. The slot starts empty and
|
// registry + reconciler + [resolved] semantics.
|
||||||
// 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.
|
|
||||||
if (to === 'operator') {
|
if (to === 'operator') {
|
||||||
const slot = el('div', { class: 'ask-answer-inline-slot' });
|
const slot = el('div', { class: 'ask-answer-inline-slot' });
|
||||||
// Stash the question text on the slot so the reconciler
|
// Stash the question text on the slot so the reconciler
|
||||||
|
|
@ -1356,12 +1340,13 @@ window.marked = marked;
|
||||||
: (c.content || '');
|
: (c.content || '');
|
||||||
const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null;
|
const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null;
|
||||||
const isMessageBearing = sourceName === 'mcp__hyperhive__recv';
|
const isMessageBearing = sourceName === 'mcp__hyperhive__recv';
|
||||||
// #666: when an ask's tool_result lands the broker has just
|
// When an ask's tool_result lands the broker has just
|
||||||
// persisted the question with its assigned id. Refresh loose
|
// persisted the question with its assigned id. Refresh
|
||||||
// ends so `reconcileAskBinds` finds the new entry and mounts
|
// loose-ends so reconcileAskBinds finds the new entry and
|
||||||
// the inline answer form under the rendered ask row. Skipped
|
// mounts the inline answer form under the rendered ask row.
|
||||||
// during history replay (the question's likely long-resolved;
|
// Skipped during history replay (the question's likely
|
||||||
// turn_end refresh on cold-load already covers reconciliation).
|
// long-resolved; turn_end refresh on cold-load covers
|
||||||
|
// reconciliation).
|
||||||
if (sourceName === 'mcp__hyperhive__ask' && !api.fromHistory) {
|
if (sourceName === 'mcp__hyperhive__ask' && !api.fromHistory) {
|
||||||
refreshLooseEnds();
|
refreshLooseEnds();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,10 @@
|
||||||
</head>
|
</head>
|
||||||
<body class="agent-shell">
|
<body class="agent-shell">
|
||||||
|
|
||||||
<!-- Fixed-overlay header (#394 redesign): two-row layout in the
|
<!-- Fixed-overlay header — see docs/web-ui.md::Per-agent page
|
||||||
main column — row 1 carries the title + meta-nav, row 2 carries
|
for the three-column layout (icon · main · pills). All asset
|
||||||
the live state strip. The agent icon eats the full header
|
/ API hrefs are relative — see docs/web-ui.md::Per-agent
|
||||||
height on the left as the identity anchor; flyout pills + an
|
relative paths for the document-baseURI resolution model. -->
|
||||||
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). -->
|
|
||||||
<header class="agent-header" id="agent-header">
|
<header class="agent-header" id="agent-header">
|
||||||
<img class="agent-icon" src="icon" alt="">
|
<img class="agent-icon" src="icon" alt="">
|
||||||
|
|
||||||
|
|
@ -44,9 +38,9 @@
|
||||||
|
|
||||||
<!-- Right cluster: flyout triggers + overflow menu. Pills stay
|
<!-- Right cluster: flyout triggers + overflow menu. Pills stay
|
||||||
hidden until their list is non-empty; the overflow `⋯` is
|
hidden until their list is non-empty; the overflow `⋯` is
|
||||||
always visible (rebuild + new-session live inside it per
|
always visible. Rebuild + new-session + logout live inside
|
||||||
#394 — both rare, both destructive, both deserve one extra
|
it — see docs/web-ui.md::Per-agent page (Overflow button)
|
||||||
click). -->
|
for the rare-destructive-extra-click rationale. -->
|
||||||
<div class="agent-header-pills">
|
<div class="agent-header-pills">
|
||||||
<button type="button" id="inbox-pill" class="header-pill header-pill-inbox" hidden
|
<button type="button" id="inbox-pill" class="header-pill header-pill-inbox" hidden
|
||||||
title="open inbox flyout">
|
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. */
|
fit the wrap) and clip any sub-pixel rounding overflow. */
|
||||||
#canvas-wrap.fit { align-items: center; overflow: hidden; }
|
#canvas-wrap.fit { align-items: center; overflow: hidden; }
|
||||||
canvas { display: block; cursor: default; }
|
canvas { display: block; cursor: default; }
|
||||||
/* In fit mode relayoutCanvas() sets the canvas display size explicitly.
|
/* Pin the canvas to exactly the size relayoutCanvas() sets. See
|
||||||
The canvas is a flex item, and flex items default to
|
docs/web-ui.md::Per-agent endpoints (GET /screen) for the
|
||||||
min-width/min-height: auto — which resolves to the canvas's intrinsic
|
flex-item min-width:auto clamp that made fit mode a silent
|
||||||
framebuffer resolution and clamps the JS-set size straight back up,
|
no-op before this pinning. */
|
||||||
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. */
|
|
||||||
#canvas-wrap.fit canvas { flex: none; min-width: 0; min-height: 0; }
|
#canvas-wrap.fit canvas { flex: none; min-width: 0; min-height: 0; }
|
||||||
#msg {
|
#msg {
|
||||||
position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
|
position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
|
||||||
|
|
@ -93,13 +90,11 @@ canvas { display: block; cursor: default; }
|
||||||
<div id="debug-log"></div>
|
<div id="debug-log"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Minimal RFB-over-WebSocket renderer.
|
// Minimal RFB-over-WebSocket renderer. Connects to `screen/ws` on
|
||||||
// Connects to /screen/ws on the same host; the harness relays raw
|
// the same host; the harness relays raw RFB bytes to the VNC server
|
||||||
// RFB bytes to the VNC server running inside the container.
|
// running inside the container. See docs/web-ui.md::Per-agent
|
||||||
//
|
// endpoints (GET /screen) for the design rationale + canvas-sizing
|
||||||
// This is a deliberately thin implementation — enough to display the
|
// implementation notes.
|
||||||
// desktop and forward pointer + keyboard events. For a production-grade
|
|
||||||
// viewer, replace with noVNC (issue #52 vendors the full bundle).
|
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
@ -123,18 +118,13 @@ canvas { display: block; cursor: default; }
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Fit-to-window toggle ---
|
// --- Fit-to-window toggle ---
|
||||||
// Scales the canvas down so the whole desktop is visible without
|
// See docs/web-ui.md::Per-agent endpoints (GET /screen) for the
|
||||||
// scrolling. The canvas's intrinsic resolution (width/height attrs)
|
// localStorage persistence + canvas-intrinsic-resolution +
|
||||||
// is untouched — only its CSS display size changes, set explicitly
|
// pointer-rescale model.
|
||||||
// by relayoutCanvas(). Pointer coordinates are rescaled in
|
|
||||||
// sendPointer to stay accurate. Persisted in localStorage; default
|
|
||||||
// is fit-on.
|
|
||||||
let fitMode = localStorage.getItem('screen-fit') !== 'off';
|
let fitMode = localStorage.getItem('screen-fit') !== 'off';
|
||||||
// Size the canvas. In fit mode, scale down (never up) to the wrap,
|
// Scale down (never up) to the wrap, preserving aspect ratio.
|
||||||
// preserving aspect ratio. Explicit px sizing rather than CSS
|
// Explicit px sizing — see GET /screen docs for the flex-item
|
||||||
// max-width/max-height: on a flex item those are overridden by the
|
// min-width:auto clamp this avoids.
|
||||||
// automatic minimum size, so fit mode was a silent no-op — the
|
|
||||||
// oversized canvas just got centred and clipped (issue #133).
|
|
||||||
function relayoutCanvas() {
|
function relayoutCanvas() {
|
||||||
if (fitMode && canvas.width && canvas.height
|
if (fitMode && canvas.width && canvas.height
|
||||||
&& canvasWrap.clientWidth && canvasWrap.clientHeight) {
|
&& canvasWrap.clientWidth && canvasWrap.clientHeight) {
|
||||||
|
|
@ -166,9 +156,9 @@ canvas { display: block; cursor: default; }
|
||||||
// --- Match-size: resize the remote desktop to this window ---
|
// --- Match-size: resize the remote desktop to this window ---
|
||||||
// Sends an RFB SetDesktopSize request so the VNC server (weston)
|
// Sends an RFB SetDesktopSize request so the VNC server (weston)
|
||||||
// changes its actual output resolution to match the browser
|
// changes its actual output resolution to match the browser
|
||||||
// viewport — sharper than fit-mode's CSS downscale. The button is
|
// viewport — sharper than fit-mode's CSS downscale. Gated on the
|
||||||
// enabled only once the server has advertised the ExtendedDesktopSize
|
// server's ExtendedDesktopSize advert; see docs/web-ui.md::Per-agent
|
||||||
// pseudo-encoding (a -308 rect). (issue #133)
|
// endpoints (GET /screen).
|
||||||
let extDesktopSupported = false;
|
let extDesktopSupported = false;
|
||||||
let screenId = 1; // captured from the server's ExtendedDesktopSize advert
|
let screenId = 1; // captured from the server's ExtendedDesktopSize advert
|
||||||
matchBtn.addEventListener('click', () => {
|
matchBtn.addEventListener('click', () => {
|
||||||
|
|
@ -206,10 +196,10 @@ canvas { display: block; cursor: default; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- WebSocket connection ---
|
// --- WebSocket connection ---
|
||||||
// Path-relative so the agent page mounted under a prefix
|
// Path-relative URL — `document.baseURI` resolves against the
|
||||||
// (e.g. /agent/<name>/screen via nginx, #14) still hits the right
|
// page's URL so the agent page mounted under a nginx prefix
|
||||||
// upstream. `document.baseURI` resolves against the page's URL;
|
// (e.g. /agent/<name>/screen) still hits the right upstream.
|
||||||
// swapping protocol on top gives ws(s)://host/<prefix>/screen/ws.
|
// Swap protocol on top gives ws(s)://host/<prefix>/screen/ws.
|
||||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const wsUrl = new URL('screen/ws', document.baseURI);
|
const wsUrl = new URL('screen/ws', document.baseURI);
|
||||||
wsUrl.protocol = proto + ':';
|
wsUrl.protocol = proto + ':';
|
||||||
|
|
@ -573,8 +563,8 @@ canvas { display: block; cursor: default; }
|
||||||
canvas.height = fbH;
|
canvas.height = fbH;
|
||||||
relayoutCanvas();
|
relayoutCanvas();
|
||||||
setStatus('connected', 'connected');
|
setStatus('connected', 'connected');
|
||||||
// Advertise Raw + the ExtendedDesktopSize pseudo-encoding so the
|
// Advertise Raw + the ExtendedDesktopSize pseudo-encoding so
|
||||||
// server reports (and accepts) desktop-size changes. (issue #133)
|
// the server reports (and accepts) desktop-size changes.
|
||||||
sendSetEncodings([0, -308]);
|
sendSetEncodings([0, -308]);
|
||||||
// Request full framebuffer update
|
// Request full framebuffer update
|
||||||
requestUpdate(0, 0, 0, fbW, fbH);
|
requestUpdate(0, 0, 0, fbW, fbH);
|
||||||
|
|
@ -618,7 +608,7 @@ canvas { display: block; cursor: default; }
|
||||||
} else if (enc === EXT_DESKTOP_SIZE_U32) {
|
} else if (enc === EXT_DESKTOP_SIZE_U32) {
|
||||||
// ExtendedDesktopSize: w,h carry the new desktop dimensions;
|
// ExtendedDesktopSize: w,h carry the new desktop dimensions;
|
||||||
// the rect body is nScreens(1) + pad(3) + nScreens×16. The
|
// 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();
|
const nScreens = peekByte();
|
||||||
if (nScreens < 0) { chunks.unshift(b); totalBytes += 12; return false; }
|
if (nScreens < 0) { chunks.unshift(b); totalBytes += 12; return false; }
|
||||||
const body = drainTo(4 + nScreens * 16);
|
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
|
// 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) {
|
function sendSetDesktopSize(w, h) {
|
||||||
const b = new Uint8Array(24);
|
const b = new Uint8Array(24);
|
||||||
b[0] = 251; b[1] = 0; // message-type + padding
|
b[0] = 251; b[1] = 0; // message-type + padding
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
// Shared dashboard helpers — extracted from the original monolithic
|
// Shared dashboard helpers used by both index.html (./tabs.js) and
|
||||||
// dashboard JS as step 1 of the #406 split. These bits are used by
|
// flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
|
||||||
// both the tab dashboard (index.html) and the flow page (flow.html):
|
// the OS-notification module, and the path-link / file-preview
|
||||||
// pure DOM helpers, the side-panel singleton, the OS-notification
|
// infrastructure for the side panel.
|
||||||
// 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).
|
|
||||||
|
|
||||||
import { linkify as termLinkify } from '@hive/shared/terminal.js';
|
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
|
// tied to its caller, so they don't generalise cleanly. We can lift
|
||||||
// them when a second consumer needs the same shape.
|
// them when a second consumer needs the same shape.
|
||||||
|
|
||||||
// ─── shared-worker SSE pipe (#448) ──────────────────────────────────────
|
// ─── shared-worker SSE pipe ─────────────────────────────────────────────
|
||||||
// Returns an EventSource-shaped object backed by a SharedWorker that
|
// Returns an EventSource-shaped facade backed by a SharedWorker that
|
||||||
// holds ONE upstream `new EventSource(url)` and fans events out to
|
// holds one upstream `new EventSource(url)` and fans events out to
|
||||||
// every connected tab. Replaces direct `new EventSource(url)` at the
|
// every connected tab. See docs/web-ui.md (SSE multiplexing paragraph)
|
||||||
// dashboard's two consumer sites (tabs.js inline + flow.js via
|
// for the design + Firefox throttling motivation; graceful fallback to
|
||||||
// terminal.js's `streamFactory` option) so N hyperhive tabs share
|
// direct EventSource on environments without SharedWorker.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// Graceful fallback to direct EventSource on environments without
|
// Consumer API: assign `onmessage` / `onopen` / `onerror`; `.close()`
|
||||||
// SharedWorker (some embedded browsers, some Safari versions). The
|
// drops the subscription (the worker closes the upstream when the last
|
||||||
// per-tab connection cost is the same as today — no regression.
|
// subscriber leaves).
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
const SHARED_WORKER_PATH = '/static/stream-worker.js';
|
const SHARED_WORKER_PATH = '/static/stream-worker.js';
|
||||||
const SHARED_WORKER_NAME = 'hyperhive-stream';
|
const SHARED_WORKER_NAME = 'hyperhive-stream';
|
||||||
|
|
||||||
// One SharedWorker port per page, reused by all openStream calls on
|
// One SharedWorker port per page, reused by all openStream calls on
|
||||||
// that page. Invalidated on `pagehide` so a bfcache restore picks up
|
// that page. Invalidated on `pagehide` so a bfcache restore picks up
|
||||||
// a fresh port (the cached one may have been collected if all other
|
// a fresh port — the cached port may be dead if all other tabs
|
||||||
// tabs closed while this page was frozen — argus nit on #453).
|
// closed while this page was frozen.
|
||||||
let _sharedPort = null;
|
let _sharedPort = null;
|
||||||
function makeSharedPort() {
|
function makeSharedPort() {
|
||||||
if (typeof SharedWorker === 'undefined') return null;
|
if (typeof SharedWorker === 'undefined') return null;
|
||||||
|
|
@ -99,30 +85,11 @@ function getSharedPort() {
|
||||||
return _sharedPort;
|
return _sharedPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
// #515: detect when the SharedWorker has been killed. Firefox aggressively
|
// SharedWorker death detection: pings from the worker bump the
|
||||||
// reclaims "idle" SharedWorkers under memory pressure (or just on tab
|
// activity clock; a visibility-gated watchdog polls and re-subscribes
|
||||||
// lifecycle quirks we don't fully understand), and there's no native
|
// on a fresh port if the page has been silent past the threshold.
|
||||||
// signal to the client when that happens — `postMessage` on a dead
|
// See docs/web-ui.md (Worker-death self-heal paragraph) for the
|
||||||
// port silently no-ops, and the page just stops receiving events. The
|
// timing rationale + Firefox reclaim symptom.
|
||||||
// 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.
|
|
||||||
const WORKER_DEAD_THRESHOLD_MS = 90_000;
|
const WORKER_DEAD_THRESHOLD_MS = 90_000;
|
||||||
const WORKER_WATCHDOG_INTERVAL_MS = 15_000;
|
const WORKER_WATCHDOG_INTERVAL_MS = 15_000;
|
||||||
let _lastWorkerActivityAt = 0;
|
let _lastWorkerActivityAt = 0;
|
||||||
|
|
@ -164,25 +131,19 @@ function rebindOnFreshPort() {
|
||||||
noteWorkerActivity();
|
noteWorkerActivity();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry of live subscriptions on this page. Keyed by url so a
|
// Registry of live subscriptions on this page. Keyed by url; entries
|
||||||
// second openStream call for the same URL (would only happen on a
|
// cache the route function so bfcache-restore re-bind can re-attach
|
||||||
// hypothetical multi-consumer page) attaches to the existing route
|
// it to the fresh port. Today's pages only call openStream once with
|
||||||
// rather than overlapping. Each entry caches the route function so
|
// one URL; the registry shape just keeps the bfcache path correct
|
||||||
// bfcache-restore re-bind can re-attach it to the fresh port.
|
// if that changes.
|
||||||
//
|
|
||||||
// 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).
|
|
||||||
const _activeSubs = new Map();
|
const _activeSubs = new Map();
|
||||||
|
|
||||||
// One-shot wiring of the page-wide lifecycle hooks: on bfcache
|
// One-shot wiring of page-wide lifecycle hooks. On bfcache freeze
|
||||||
// freeze (`pagehide { persisted: true }`) we unsubscribe so the
|
// we unsubscribe so the worker can close the upstream when the last
|
||||||
// worker can close the upstream when the last live subscriber
|
// live subscriber leaves; on bfcache restore we invalidate the cached
|
||||||
// leaves; on bfcache restore (`pageshow { persisted: true }`) we
|
// port (may be dead after the freeze) and re-attach every active
|
||||||
// invalidate the cached port (it may be dead if all other tabs
|
// subscription to a fresh port. Without this, the consumer's
|
||||||
// closed during the freeze) and re-attach every active subscription
|
// onmessage stays bound but no events flow after restore.
|
||||||
// to a fresh port. argus nit on #453: without this, the consumer's
|
|
||||||
// onmessage stays bound but no events flow after a bfcache restore.
|
|
||||||
let _lifecycleBound = false;
|
let _lifecycleBound = false;
|
||||||
function bindLifecycleOnce() {
|
function bindLifecycleOnce() {
|
||||||
if (_lifecycleBound) return;
|
if (_lifecycleBound) return;
|
||||||
|
|
@ -245,7 +206,7 @@ export function openStream(url) {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const route = (e) => {
|
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.
|
// before the URL filter, since heartbeat pings carry no URL.
|
||||||
noteWorkerActivity();
|
noteWorkerActivity();
|
||||||
const m = e.data;
|
const m = e.data;
|
||||||
|
|
@ -272,8 +233,8 @@ export function openStream(url) {
|
||||||
_activeSubs.set(url, { target, route });
|
_activeSubs.set(url, { target, route });
|
||||||
port.addEventListener('message', route);
|
port.addEventListener('message', route);
|
||||||
port.postMessage({ kind: 'subscribe', url });
|
port.postMessage({ kind: 'subscribe', url });
|
||||||
// #515: seed the activity clock so the watchdog has a baseline; it
|
// Seed the activity clock so the watchdog has a baseline (would
|
||||||
// would otherwise compare against 0 (epoch) and trigger immediately.
|
// otherwise compare against 0 and trigger immediately).
|
||||||
noteWorkerActivity();
|
noteWorkerActivity();
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
@ -330,13 +291,10 @@ export const Panel = (() => {
|
||||||
root.classList.remove('open');
|
root.classList.remove('open');
|
||||||
root.setAttribute('aria-hidden', 'true');
|
root.setAttribute('aria-hidden', 'true');
|
||||||
}
|
}
|
||||||
// #451: drag-to-resize the drawer's width. Listens on a thin
|
// Drag-to-resize the drawer's width. See docs/web-ui.md::Side panel
|
||||||
// hit-strip glued to the drawer's left edge; mousedown captures
|
// for the hit-strip + pointer-capture + localStorage persistence
|
||||||
// pointermove + pointerup on the document so the drag continues
|
// model; CSS clamps the stored value to min 320px / max 96vw and
|
||||||
// even if the cursor strays outside the 6px handle band. Width
|
// out-of-range stored values are dropped silently.
|
||||||
// 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.
|
|
||||||
const WIDTH_KEY = 'hyperhive:side-panel-width';
|
const WIDTH_KEY = 'hyperhive:side-panel-width';
|
||||||
const WIDTH_MIN = 320;
|
const WIDTH_MIN = 320;
|
||||||
function clampWidth(w) {
|
function clampWidth(w) {
|
||||||
|
|
@ -476,7 +434,7 @@ function mdNode(text) {
|
||||||
window.marked.setOptions({ breaks: true, gfm: true });
|
window.marked.setOptions({ breaks: true, gfm: true });
|
||||||
div.innerHTML = window.marked.parse(text);
|
div.innerHTML = window.marked.parse(text);
|
||||||
// marked autolinks URLs but leaves them same-tab — open externally
|
// marked autolinks URLs but leaves them same-tab — open externally
|
||||||
// so a click never navigates away from the dashboard. (issue #233)
|
// so a click never navigates away from the dashboard.
|
||||||
div.querySelectorAll('a[href]').forEach((a) => {
|
div.querySelectorAll('a[href]').forEach((a) => {
|
||||||
a.target = '_blank';
|
a.target = '_blank';
|
||||||
a.rel = 'noopener noreferrer';
|
a.rel = 'noopener noreferrer';
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,19 @@
|
||||||
@import "@hive/shared/base.css";
|
@import "@hive/shared/base.css";
|
||||||
@import "@hive/shared/terminal.css";
|
@import "@hive/shared/terminal.css";
|
||||||
|
|
||||||
/* ─── tabbed dashboard chrome (#369) ────────────────────────────────
|
/* ─── tabbed dashboard chrome ──────────────────────────────────────
|
||||||
Top-of-page sticky header with banner + tab strip. Tab routing is
|
Top-of-page sticky header with banner + tab strip. SSE stays
|
||||||
hash-based; tab panes are show/hide via the `.tab-pane-active`
|
alive across tab switches so count pills update live on inactive
|
||||||
class. SSE stays alive across tab switches so count pills update
|
tabs. See docs/web-ui.md::Chrome header + Tab strip for the
|
||||||
live on inactive tabs without losing pulse on what's happening
|
routing model. */
|
||||||
elsewhere. */
|
|
||||||
|
|
||||||
body.dashboard-shell {
|
body.dashboard-shell {
|
||||||
/* Full-width layout (#416 — mara: drop the 90em cap so wide screens
|
/* Full-width layout — no max-width cap so wide screens don't
|
||||||
don't waste real estate on empty side margins). `padding: 0 1.5em
|
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
|
1.5em` keeps a small gutter on the left/right so cards don't
|
||||||
the viewport edge; `.dashboard-chrome { margin: 0 -1.5em ... }`
|
kiss the viewport edge; `.dashboard-chrome { margin: 0 -1.5em
|
||||||
still pulls the chrome bar edge-to-edge through that gutter. */
|
... }` still pulls the chrome bar edge-to-edge through that
|
||||||
|
gutter. */
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 1.5em 1.5em;
|
padding: 0 1.5em 1.5em;
|
||||||
}
|
}
|
||||||
|
|
@ -189,16 +189,9 @@ a:hover {
|
||||||
background: rgba(24, 24, 37, 0.55);
|
background: rgba(24, 24, 37, 0.55);
|
||||||
transition: opacity 200ms ease, border-color 200ms ease;
|
transition: opacity 200ms ease, border-color 200ms ease;
|
||||||
}
|
}
|
||||||
/* Topology indent (#363). Each depth level shifts the row right by one
|
/* Topology indent ladder. See docs/web-ui.md::Topology tree (Indent
|
||||||
step; the .tree-prefix span (drawn by tabs.js::treePrefix) carries
|
+ lane geometry paragraph) for the 1.8em-per-depth-level
|
||||||
the ├─ / └─ glyph and any continuation lines that thread through
|
rationale + CSS-attr()-not-yet-portable caveat. */
|
||||||
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). */
|
|
||||||
.container-row[data-depth] { position: relative; }
|
.container-row[data-depth] { position: relative; }
|
||||||
.container-row[data-depth="1"] { margin-left: 1.8em; }
|
.container-row[data-depth="1"] { margin-left: 1.8em; }
|
||||||
.container-row[data-depth="2"] { margin-left: 3.6em; }
|
.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="4"] { margin-left: 7.2em; }
|
||||||
.container-row[data-depth="5"] { margin-left: 9em; }
|
.container-row[data-depth="5"] { margin-left: 9em; }
|
||||||
.container-row[data-depth="6"] { margin-left: 10.8em; }
|
.container-row[data-depth="6"] { margin-left: 10.8em; }
|
||||||
/* Tree prefix sits in the left margin and paints the connecting
|
/* Tree prefix lanes — DOM-painted, not text-glyph-painted. See
|
||||||
├ / └ / │ lanes as CSS rules rather than text glyphs (#388). Each
|
docs/web-ui.md::Topology tree for the full-row-height vertical
|
||||||
ancestor depth gets its own `.tree-lane` so we can paint a
|
bar rationale + icon-midline joint alignment. */
|
||||||
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. */
|
|
||||||
.container-row .tree-prefix {
|
.container-row .tree-prefix {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
/* Extend into the `.containers { gap: 0.4em }` below so vertical
|
/* Extend into the `.containers { gap: 0.4em }` below so vertical
|
||||||
|
|
@ -228,12 +214,10 @@ a:hover {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
color: var(--purple-dim);
|
color: var(--purple-dim);
|
||||||
}
|
}
|
||||||
/* Each depth step is one 1.8em lane wide — same step as the row's
|
/* Prefix-left-edge ladder. Each depth step is 1.8em (matches the
|
||||||
own margin-left ladder above, so the rightmost lane (the joint)
|
row indent ladder above) so the prefix's right edge meets the
|
||||||
sits flush against the row content (the icon). The prefix's left
|
icon and its leftmost lane lines up with top-level rows' icons
|
||||||
edge is depth*1.8em LEFT of the row's left edge, so its right
|
at x = 0. */
|
||||||
edge meets the icon, and the leftmost lane lines up with the
|
|
||||||
top-level rows' icons at x=0. */
|
|
||||||
.container-row[data-depth="1"] .tree-prefix { left: -1.8em; }
|
.container-row[data-depth="1"] .tree-prefix { left: -1.8em; }
|
||||||
.container-row[data-depth="2"] .tree-prefix { left: -3.6em; }
|
.container-row[data-depth="2"] .tree-prefix { left: -3.6em; }
|
||||||
.container-row[data-depth="3"] .tree-prefix { left: -5.4em; }
|
.container-row[data-depth="3"] .tree-prefix { left: -5.4em; }
|
||||||
|
|
@ -277,14 +261,12 @@ a:hover {
|
||||||
width: 2em;
|
width: 2em;
|
||||||
border-top: 1px solid currentColor;
|
border-top: 1px solid currentColor;
|
||||||
}
|
}
|
||||||
/* Live cards get the icon-left / body-right split; tombstone rows keep
|
/* Live cards get the icon-left / body-right split; tombstone rows
|
||||||
the plain stacked block layout. The icon is a background-image div
|
keep the plain stacked block layout. The icon's fixed 5em width
|
||||||
with no intrinsic size, so its load state can never reflow the row
|
+ aspect-ratio-derived height avoid the align-self: stretch
|
||||||
(issue #177). It used to `align-self: stretch` to fill the body
|
feedback loop that used to make different-content cards show
|
||||||
height, but with state badges / rate-limit pills / etc. wrapping the
|
different-sized icons. See docs/web-ui.md::Container row (Icon
|
||||||
head row, the body grew taller and the square icon grew with it —
|
layout + load strategy) for the load-state reflow rationale. */
|
||||||
so two cards with different content showed different-sized icons
|
|
||||||
(issue #344). Fixed at 5em now; height follows from aspect-ratio. */
|
|
||||||
.container-row:not(.tombstone) {
|
.container-row:not(.tombstone) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
@ -298,9 +280,10 @@ a:hover {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background-color: rgba(17, 17, 27, 0.6);
|
background-color: rgba(17, 17, 27, 0.6);
|
||||||
/* #443 — icon is the selection toggle. Cursor + hover ring make
|
/* Icon doubles as the selection toggle — cursor + hover ring
|
||||||
that affordable without a chrome change. The :focus-visible ring
|
make that affordable without a chrome change. The
|
||||||
covers keyboard activation (Enter / Space). */
|
:focus-visible ring covers keyboard activation (Enter / Space).
|
||||||
|
See docs/web-ui.md::Selection bar for the toggle semantics. */
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow 120ms ease, transform 120ms ease;
|
transition: box-shadow 120ms ease, transform 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
@ -321,9 +304,9 @@ a:hover {
|
||||||
.container-row.selected > .container-icon {
|
.container-row.selected > .container-icon {
|
||||||
box-shadow: 0 0 0 2px var(--purple), 0 0 12px -4px var(--purple);
|
box-shadow: 0 0 0 2px var(--purple), 0 0 12px -4px var(--purple);
|
||||||
}
|
}
|
||||||
/* The icon image fills the square wrapper and is taken out of flow
|
/* Icon image — absolutely positioned, fills the square wrapper.
|
||||||
(absolute) so its load state — pending, loaded, broken — can never
|
See docs/web-ui.md::Container row (Icon layout + load strategy)
|
||||||
contribute intrinsic size or reflow the row. (issue #177) */
|
for the load-state-can't-reflow-row rationale. */
|
||||||
.container-row:not(.tombstone) > .container-icon > .container-icon-img {
|
.container-row:not(.tombstone) > .container-icon > .container-icon-img {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
@ -331,8 +314,9 @@ a:hover {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
/* When the <img> fails to load it falls back to the dimmed hyperhive
|
/* When the <img> fails to load it falls back to the dimmed
|
||||||
mark, standing in for the unreachable agent icon (issues #195, #202). */
|
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 {
|
.container-row:not(.tombstone) > .container-icon.icon-unreachable {
|
||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
|
|
@ -341,23 +325,29 @@ a:hover {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
/* Pending state splits queued vs running (#769): queued ops show
|
/* Pending state splits queued vs running — queued ops show only
|
||||||
only the pending-state badge — the row sits unhighlighted so a
|
the pending-state badge (no row tint), running ops keep the
|
||||||
long queue doesn't paint half the SW4RM tab amber. Running ops
|
amber row tint AND get a rotating amber ring on the icon. See
|
||||||
keep the amber row tint AND get a rotating amber ring on the
|
docs/web-ui.md::Container row (Pending-state derivation) for
|
||||||
agent icon so it's obvious which container is actually moving. */
|
the priority order. */
|
||||||
.container-row.pending .actions { opacity: 0.4; pointer-events: none; }
|
.container-row.pending .actions { opacity: 0.4; pointer-events: none; }
|
||||||
.container-row.pending-running {
|
.container-row.pending-running {
|
||||||
border-color: var(--amber);
|
border-color: var(--amber);
|
||||||
background: rgba(250, 179, 135, 0.05);
|
background: rgba(250, 179, 135, 0.05);
|
||||||
}
|
}
|
||||||
/* The spinner is a transparent border ring with two adjacent sides
|
/* The spinner is a faint amber ring with one brighter arc on top
|
||||||
coloured amber, rotated by a CSS animation — looks like an
|
that rotates around the icon — classic CSS spinner shape. The
|
||||||
orbiting arc around the icon. Override the icon's overflow:hidden
|
previous version coloured two adjacent border sides amber, which
|
||||||
so the ring can sit just outside the square; it composes naturally
|
rendered as a rotating L-corner ("_|") rather than a smooth
|
||||||
with the mauve selected-ring (selected + running shows both). */
|
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 {
|
@keyframes container-icon-spin {
|
||||||
to { transform: rotate(360deg); }
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
.container-row.pending-running > .container-icon {
|
.container-row.pending-running > .container-icon {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|
@ -367,9 +357,8 @@ a:hover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -4px;
|
inset: -4px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid rgba(250, 179, 135, 0.2);
|
||||||
border-top-color: var(--amber);
|
border-top-color: var(--amber);
|
||||||
border-right-color: var(--amber);
|
|
||||||
animation: container-icon-spin 1s linear infinite;
|
animation: container-icon-spin 1s linear infinite;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
@ -385,11 +374,13 @@ a:hover {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.container-row .head .meta { margin-left: auto; }
|
.container-row .head .meta { margin-left: auto; }
|
||||||
/* Icon-only nav strip in the head row — the per-container backend-
|
/* Icon-only nav strip in the head row — backend-supplied per-
|
||||||
supplied link list (issue #262). Inline-flex + gap so a longer list
|
container link list. Inline-flex + gap so a longer list (e.g.
|
||||||
(e.g. with `dashboardLinks` extras) doesn't cram (issue #333). Each
|
with `dashboardLinks` extras) doesn't cram. Each link gets a
|
||||||
link gets a comfortable hit target with a subtle hover so the
|
comfortable hit target with a subtle hover so the icons read
|
||||||
icons read as interactive rather than decorative. */
|
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 {
|
.container-row .head .nav-strip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -441,11 +432,10 @@ a:hover {
|
||||||
color: var(--cyan); border-color: var(--cyan);
|
color: var(--cyan); border-color: var(--cyan);
|
||||||
text-shadow: 0 0 6px rgba(137, 220, 235, 0.4);
|
text-shadow: 0 0 6px rgba(137, 220, 235, 0.4);
|
||||||
}
|
}
|
||||||
/* Context-window usage badges on dashboard container rows. Thresholds
|
/* Context-window usage badges on dashboard container rows. See
|
||||||
are derived per-container: yellow ≥ 50% and red ≥ 75% of the model's
|
docs/web-ui.md::Container row Line 1 for the per-container
|
||||||
context window (`ContainerView.context_window_tokens`), mirroring the
|
threshold derivation (yellow ≥ 50% / red ≥ 75% of the model's
|
||||||
harness compaction watermarks. Falls back to fixed 100k / 150k when
|
context window) and fallback values. */
|
||||||
the window is unknown. (issue #66) */
|
|
||||||
.badge-ctx-ok {
|
.badge-ctx-ok {
|
||||||
color: var(--green); border-color: var(--green);
|
color: var(--green); border-color: var(--green);
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
|
|
@ -478,10 +468,10 @@ a:hover {
|
||||||
/* Per-container journald viewer + applied-config viewer. Both open
|
/* Per-container journald viewer + applied-config viewer. Both open
|
||||||
in the side panel and lazy-fetch on open; output is monospace
|
in the side panel and lazy-fetch on open; output is monospace
|
||||||
inside a bordered <pre>, controls (unit select + refresh) above.
|
inside a bordered <pre>, controls (unit select + refresh) above.
|
||||||
#541: the panel-body wrapper is a column flex container that fills
|
The panel-body wrapper is a column flex container that fills the
|
||||||
the side-panel-body so the <pre> can flex-grow into a single tall
|
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
|
scrollable surface instead of a short box at the top with the
|
||||||
of the panel empty. */
|
rest of the panel empty. */
|
||||||
.journal-body {
|
.journal-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -509,9 +499,9 @@ a:hover {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
border: 1px solid var(--purple-dim);
|
border: 1px solid var(--purple-dim);
|
||||||
padding: 0.5em 0.7em;
|
padding: 0.5em 0.7em;
|
||||||
/* #541: take all leftover panel height + scroll inside the pre so
|
/* Take all leftover panel height + scroll inside the pre so long
|
||||||
long log fetches don't push the controls off-screen. `min-height:
|
log fetches don't push the controls off-screen. `min-height: 0`
|
||||||
0` is the canonical "let me actually flex-shrink for overflow"
|
is the canonical "let me actually flex-shrink for overflow"
|
||||||
escape hatch on flex children. */
|
escape hatch on flex children. */
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
@ -605,9 +595,8 @@ code {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.3em;
|
gap: 0.3em;
|
||||||
}
|
}
|
||||||
/* When the approval was requested — right-aligned in the head row;
|
/* Approval requested-at chip — right-aligned, goes amber after
|
||||||
goes amber once it has been pending ≥ 1h so a stale request stands
|
1h. See docs/web-ui.md::Approval card. */
|
||||||
out at a glance (issue #272). */
|
|
||||||
.approval-ts {
|
.approval-ts {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|
@ -660,7 +649,8 @@ code {
|
||||||
border-color: var(--purple);
|
border-color: var(--purple);
|
||||||
background: rgba(203, 166, 247, 0.08);
|
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; }
|
.preview-host { margin-top: 0.5em; }
|
||||||
.img-preview {
|
.img-preview {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -734,7 +724,7 @@ code {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.meta-inputs-bulk {
|
||||||
margin: 0 0 0.5em;
|
margin: 0 0 0.5em;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
|
|
@ -754,7 +744,7 @@ code {
|
||||||
border-color: var(--cyan);
|
border-color: var(--cyan);
|
||||||
text-shadow: 0 0 6px currentColor;
|
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 {
|
.meta-input-twig {
|
||||||
color: var(--purple-dim);
|
color: var(--purple-dim);
|
||||||
margin-right: 0.1em;
|
margin-right: 0.1em;
|
||||||
|
|
@ -780,7 +770,7 @@ code {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
/* In-progress banner for the META INPUTS panel: shown while a
|
/* 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 {
|
.meta-update-running {
|
||||||
margin: 0 0 0.7em;
|
margin: 0 0 0.7em;
|
||||||
padding: 0.4em 0.7em;
|
padding: 0.4em 0.7em;
|
||||||
|
|
@ -836,11 +826,10 @@ code {
|
||||||
.rqe-source-approval { color: var(--green); border-color: var(--green); }
|
.rqe-source-approval { color: var(--green); border-color: var(--green); }
|
||||||
.rqe-when { color: var(--muted); font-size: 0.85em; }
|
.rqe-when { color: var(--muted); font-size: 0.85em; }
|
||||||
.rqe-reason { color: var(--muted); font-size: 0.85em; flex: 1 1 auto; }
|
.rqe-reason { color: var(--muted); font-size: 0.85em; flex: 1 1 auto; }
|
||||||
/* #437: in-flight step indicator on running queue entries — sub-line
|
/* In-flight step indicator on running queue entries — cyan sub-
|
||||||
below the main row, indented under the state glyph + kind. Cyan
|
line below the main row, wraps to its own line via flex-basis:
|
||||||
keeps it visually grouped with the running spinner instead of
|
100%. See docs/web-ui.md::R3BU1LD QU3U3 for the step-annotation
|
||||||
blending into the muted reason/timing chips. flex-basis: 100% so
|
semantics. */
|
||||||
it always wraps to its own line. */
|
|
||||||
.rqe-step {
|
.rqe-step {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
margin: 0.1em 0 0 1.8em;
|
margin: 0.1em 0 0 1.8em;
|
||||||
|
|
@ -858,12 +847,11 @@ code {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* #575: cancel-X for queued rebuild_queue entries. Quiet by default
|
/* Cancel-X for queued rebuild_queue entries — sits at far right
|
||||||
(sits at the far right via margin-left: auto), lights red on hover.
|
via margin-left: auto, lights red on hover. Mirrors the side-
|
||||||
Renders only when `state === 'queued'` per renderQueueEntry's
|
panel-close glyph shape (✗) so it stays unobtrusive in a list
|
||||||
guard — Running / terminal rows don't get the affordance. Mirrors
|
row. See docs/web-ui.md::R3BU1LD QU3U3 for the queued-only
|
||||||
the side-panel-close glyph shape (✗) without the full B6N-style
|
gating. */
|
||||||
`btn-deny` width so it stays unobtrusive in a list row. */
|
|
||||||
.rqe-cancel {
|
.rqe-cancel {
|
||||||
margin-left: auto;
|
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-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-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; }
|
.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
|
/* M0V3 affordance (selection bar) — mauve reads as "structural
|
||||||
accent the question-override / mid-status surfaces use; reads as
|
change" rather than the destructive red / amber chrome of
|
||||||
"structural change" rather than the destructive red / amber chrome
|
destroy / rebuild. See docs/web-ui.md::Selection bar. */
|
||||||
of destroy / rebuild. */
|
|
||||||
.btn-move { color: var(--mauve); border-color: var(--mauve); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
.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-talk { color: var(--cyan); border-color: var(--cyan); }
|
||||||
.btn-spawn { color: var(--amber); border-color: var(--amber); }
|
.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;
|
text-shadow: 0 0 6px currentColor;
|
||||||
box-shadow: 0 0 8px -2px 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
|
parallel destructive-adjacent action (edit changes state, but
|
||||||
isn't deletion). */
|
isn't deletion). */
|
||||||
.btn-edit-schedule { color: var(--yellow, #f9e2af); border-color: var(--yellow, #f9e2af); }
|
.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);
|
background: var(--bg-elev);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.5em 0.8em;
|
padding: 0.5em 0.8em;
|
||||||
/* #450: no max-height cap — let the inbox grow to fill the
|
/* No max-height cap — let the inbox grow to fill the
|
||||||
side-panel-body which already scrolls (`overflow: auto`). The
|
side-panel-body which already scrolls. */
|
||||||
pre-#450 24em cap clamped the list well short of the available
|
|
||||||
panel height even on tall viewports. */
|
|
||||||
}
|
}
|
||||||
.inbox li {
|
.inbox li {
|
||||||
padding: 0.25em 0;
|
padding: 0.25em 0;
|
||||||
|
|
@ -1263,17 +1248,9 @@ summary:hover { color: var(--purple); }
|
||||||
text-indent: 0;
|
text-indent: 0;
|
||||||
}
|
}
|
||||||
.live .msgrow .msg-body {
|
.live .msgrow .msg-body {
|
||||||
/* #485: body takes a full flex line of its own beneath the
|
/* Body takes a full flex line of its own beneath the metadata
|
||||||
metadata chips (ts / arrow / from / sep / to). Previously the
|
chips. See docs/web-ui.md::FL0W page (MESS4GE FL0W) for the
|
||||||
body sat inline with `flex: 1 1 0`, eating whatever the chips
|
flex-basis: 100% + min-width: 0 rationale. */
|
||||||
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. */
|
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -1372,9 +1349,9 @@ footer {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
footer a { color: var(--purple); }
|
footer a { color: var(--purple); }
|
||||||
/* Slug banner now lives at the page footer (#389 follow-up) — give
|
/* Slug banner lives at the page footer — slim top margin so it
|
||||||
it a slim top margin so it doesn't crash into the prior content,
|
doesn't crash into the prior content, bottom margin separating
|
||||||
and bottom margin separating from the divider/link line. */
|
from the divider/link line. */
|
||||||
footer .banner-thin {
|
footer .banner-thin {
|
||||||
margin-bottom: 0.8em;
|
margin-bottom: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
@ -1420,13 +1397,11 @@ footer .banner-thin {
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
/* #451: width is a CSS variable so the drag handle (added by
|
/* Width is a CSS variable so the drag handle can update it live
|
||||||
Panel.bind) can update it live, and so localStorage-persisted
|
(JS sets `--side-panel-w` on the drawer) and localStorage-
|
||||||
widths apply on first paint. Default min(760px, 94vw) preserves
|
persisted widths apply on first paint. Default min(760px, 94vw)
|
||||||
the pre-#451 behaviour for operators who never drag. JS sets
|
covers operators who never drag. See docs/web-ui.md::Side panel
|
||||||
`--side-panel-w` via inline style on the drawer; persistence
|
for the drag-to-resize mechanism + localStorage key. */
|
||||||
lives in localStorage (key `hyperhive:side-panel-width`),
|
|
||||||
replayed onto the var by `Panel.applyStoredWidth` at bind. */
|
|
||||||
width: var(--side-panel-w, min(760px, 94vw));
|
width: var(--side-panel-w, min(760px, 94vw));
|
||||||
/* Clamp so a stored width can never push the drawer off-screen
|
/* Clamp so a stored width can never push the drawer off-screen
|
||||||
or shrink it past readability. min content width matches the
|
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 { pointer-events: auto; }
|
||||||
.side-panel.open .side-panel-backdrop { opacity: 1; }
|
.side-panel.open .side-panel-backdrop { opacity: 1; }
|
||||||
.side-panel.open .side-panel-drawer { transform: translateX(0); }
|
.side-panel.open .side-panel-drawer { transform: translateX(0); }
|
||||||
/* #451: drag-to-resize handle on the drawer's left edge. The handle
|
/* Drag-to-resize handle on the drawer's left edge. Invisible until
|
||||||
itself is invisible until hover/drag so it doesn't compete with the
|
hover/drag so it doesn't compete with the 2px mauve border-left
|
||||||
2px mauve `border-left` for the visual boundary. Pointer-cursor
|
for the visual boundary. Pointer-cursor tells the operator the
|
||||||
tells the operator the edge is grabbable; the brighter glow during
|
edge is grabbable; the brighter glow during drag
|
||||||
drag (`body.side-panel-resizing`) is the affordance the eye
|
(body.side-panel-resizing) is the affordance the eye tracks. */
|
||||||
tracks. */
|
|
||||||
.side-panel-resize {
|
.side-panel-resize {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -1547,22 +1521,17 @@ body.side-panel-resizing * { cursor: ew-resize !important; }
|
||||||
padding: 0.2em 0.5em;
|
padding: 0.2em 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── /flow.html — full-page chat (#369) ───────────────────────────
|
/* ─── /flow.html — full-page chat ─────────────────────────────────
|
||||||
The all-agents chat surface lives on its own page so it can claim
|
See docs/web-ui.md::FL0W page for the page-vs-pane rationale
|
||||||
full-viewport vibec0re styling (operator @ #369#issuecomment-3437).
|
and the inbox-flyout-as-pill ergonomics. Shape mirrors the
|
||||||
Same shape as the per-agent live page (#362): frosted-glass header
|
per-agent live page (frosted-glass header + composer, full-
|
||||||
at top, frosted composer docked at bottom, terminal scrolls
|
viewport terminal). */
|
||||||
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. */
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Approximate height of the flow chrome (tabbar + dashboard-chrome
|
/* Approximate height of the flow chrome (tabbar + dashboard-chrome
|
||||||
padding). The banner-thin slug used to live in the chrome and
|
padding). The slug banner lives at the page footer on /, omitted
|
||||||
padded this up to 4.7em; with the slug moved out (#389 follow-up:
|
on /flow.html since the full-viewport terminal has no normal-flow
|
||||||
slug lives at the page footer on /, simply omitted on /flow.html
|
footer position. */
|
||||||
since there's no normal-flow footer position in the full-viewport
|
|
||||||
terminal), the chrome is just tabs now and shrinks accordingly. */
|
|
||||||
--flow-header-h: 3.6em;
|
--flow-header-h: 3.6em;
|
||||||
--flow-composer-h: 3.6em;
|
--flow-composer-h: 3.6em;
|
||||||
--flow-frost-bg: rgba(30, 30, 46, 0.74);
|
--flow-frost-bg: rgba(30, 30, 46, 0.74);
|
||||||
|
|
@ -1584,8 +1553,8 @@ body.flow-shell {
|
||||||
var(--bg);
|
var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Flow chrome (#383): reuses the dashboard's `.dashboard-chrome` +
|
/* Flow chrome reuses the dashboard's `.dashboard-chrome` + tabbar
|
||||||
tabbar so the operator can switch tabs from the flow page without
|
so the operator can switch tabs from the flow page without
|
||||||
navigating back first. The chrome must be fixed-position (vs
|
navigating back first. The chrome must be fixed-position (vs
|
||||||
sticky on the dashboard) since flow-shell has `overflow: hidden`
|
sticky on the dashboard) since flow-shell has `overflow: hidden`
|
||||||
on body and the main area absolute-positions the terminal. */
|
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);
|
border-color: var(--purple-dim);
|
||||||
box-shadow: 0 -2px 12px -4px rgba(203, 166, 247, 0.4);
|
box-shadow: 0 -2px 12px -4px rgba(203, 166, 247, 0.4);
|
||||||
}
|
}
|
||||||
/* Legacy `.flow-title` / `.flow-hint` / `.flow-back` rules were
|
/* `.notif-row` styling lives under the shared `.tabbar #notif-row`
|
||||||
removed in #383 — the flow page now uses the shared chrome with
|
selector earlier in the file — the flow page reuses the dashboard
|
||||||
the dashboard tab strip, no need for FL0W-specific title/hint
|
tab strip rather than carrying its own title/hint/back chrome. */
|
||||||
elements. The `.notif-row` styling lives under the shared
|
|
||||||
`.tabbar #notif-row` selector earlier in the file. */
|
|
||||||
|
|
||||||
/* Inbox pill — operator inbox flyout trigger. Sits right under the
|
/* Inbox pill — operator inbox flyout trigger. Sits right under the
|
||||||
header so it stays in the operator's gaze without crowding 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 {
|
.flow-pill {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: calc(var(--flow-header-h) + 0.8em);
|
top: calc(var(--flow-header-h) + 0.8em);
|
||||||
|
|
@ -1690,11 +1657,10 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
/* Tail pill (↓ N new): bottom offset clears the floating composer.
|
/* Tail pill (↓ N new): bottom offset clears the floating composer.
|
||||||
z-index escapes the stacking context the .terminal-wrap's
|
Pill is anchored on .flow-main (not .terminal-wrap) so the
|
||||||
backdrop-filter creates (issue #375) — tabs.js anchors the pill
|
backdrop-filter stacking context doesn't trap its z-index — see
|
||||||
on .flow-main now (not .terminal-wrap), so this z-index reaches
|
docs/web-ui.md::Per-agent page (Terminal-wrap) for the same
|
||||||
the root stacking context and properly floats above the
|
gotcha on the agent page. */
|
||||||
composer at z-index 30. */
|
|
||||||
.flow-main .tail-pill {
|
.flow-main .tail-pill {
|
||||||
bottom: calc(var(--flow-composer-h) + 0.6em);
|
bottom: calc(var(--flow-composer-h) + 0.6em);
|
||||||
z-index: 35;
|
z-index: 35;
|
||||||
|
|
@ -1720,10 +1686,11 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
||||||
surface the messages via the pill/flyout instead. */
|
surface the messages via the pill/flyout instead. */
|
||||||
.flow-inbox-headless { display: none !important; }
|
.flow-inbox-headless { display: none !important; }
|
||||||
|
|
||||||
/* ─── scheduled prompts tab (#459) ─────────────────────────────────────
|
/* ─── scheduled prompts tab ────────────────────────────────────────
|
||||||
Creation form at the top, list of queued schedule cards below.
|
Creation form at the top, list of queued schedule cards below.
|
||||||
Cards show: id + source + due-in + cancel-all in the header,
|
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 {
|
.schedule-edit-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1735,9 +1702,9 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
||||||
padding: 0.8em 1em;
|
padding: 0.8em 1em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
}
|
}
|
||||||
/* #474 — inline edit form opens directly under the schedule row's
|
/* Inline edit form opens directly under the schedule row's
|
||||||
actions strip, indented slightly so it visually nests under the
|
actions strip, indented slightly so it visually nests under
|
||||||
row it edits. */
|
the row it edits. */
|
||||||
.schedule-edit-form-wrapper {
|
.schedule-edit-form-wrapper {
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
padding-left: 0.5em;
|
padding-left: 0.5em;
|
||||||
|
|
@ -1800,7 +1767,7 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
||||||
justify-content: flex-end;
|
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. */
|
preview, so the operator never has to multiply seconds by hand. */
|
||||||
.schedule-interval-presets {
|
.schedule-interval-presets {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1844,10 +1811,9 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
||||||
}
|
}
|
||||||
.schedule-interval-preview-oneshot { color: var(--muted); font-style: italic; }
|
.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
|
on the left, one ✓/✕ column per agent in the middle, actions
|
||||||
column on the right. Agent column headers tilt -45° so a stack of
|
column on the right. See docs/web-ui.md::SCH3DUL3S tab. */
|
||||||
short agent names fits in ~28px each. */
|
|
||||||
.schedules-table {
|
.schedules-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|
@ -1867,11 +1833,11 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
||||||
.schedules-table-id { width: 3em; }
|
.schedules-table-id { width: 3em; }
|
||||||
.schedules-table-body-th { min-width: 12em; }
|
.schedules-table-body-th { min-width: 12em; }
|
||||||
.schedules-table-actions-th { width: 7em; }
|
.schedules-table-actions-th { width: 7em; }
|
||||||
/* Tilted agent column headers (#535). Header cell is narrow (~28px)
|
/* Tilted agent column headers — narrow (~28px) and tall (~90px);
|
||||||
and tall (~90px); the inner <div> rotates -45° about its bottom-left
|
the inner <div> rotates -45° about its bottom-left corner, with
|
||||||
corner, with a translate to slide the text up alongside the cell
|
a translate to slide the text up alongside the cell border. The
|
||||||
border. The inner <span> carries the actual baseline so the
|
inner <span> carries the actual baseline so the underline
|
||||||
underline (border-bottom on the cell) aligns with the rotated text. */
|
(border-bottom on the cell) aligns with the rotated text. */
|
||||||
.schedules-table-agent-th {
|
.schedules-table-agent-th {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
|
|
@ -1955,8 +1921,8 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
||||||
padding: 0.1em 0.45em;
|
padding: 0.1em 0.45em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* #564 — inline create row at the bottom of the schedules table.
|
/* Inline create row at the bottom of the schedules table. Cells
|
||||||
Cells host inputs directly so the operator can fill + click + to
|
host inputs directly so the operator can fill + click + to
|
||||||
queue a new schedule without leaving the table view. Slightly
|
queue a new schedule without leaving the table view. Slightly
|
||||||
different background tone so it reads as "this isn't a schedule
|
different background tone so it reads as "this isn't a schedule
|
||||||
yet, it's the create form". */
|
yet, it's the create form". */
|
||||||
|
|
@ -2058,11 +2024,11 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Selection bar (#443). Sticky-bottom strip that surfaces bulk
|
/* Selection bar — sticky-bottom strip that surfaces bulk actions
|
||||||
actions when ≥1 agent is selected (click the icon). Visually
|
when ≥1 agent is selected (click the icon). Visually echoes the
|
||||||
echoes the flow composer's frosted-mauve treatment so the chrome
|
flow composer's frosted-mauve treatment so the chrome reads as
|
||||||
reads as part of the same vibecore family. Hidden when empty —
|
part of the same vibecore family. Hidden when empty. See
|
||||||
leaves the page footer's normal-flow position untouched. */
|
docs/web-ui.md::Selection bar for the bulk-action semantics. */
|
||||||
.selection-bar {
|
.selection-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
@ -2118,11 +2084,11 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
||||||
selection set is non-empty). */
|
selection set is non-empty). */
|
||||||
body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
|
body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
|
||||||
|
|
||||||
/* #486 — M0V3 → <pick> picker. Inline `<select>` + button pair
|
/* M0V3 → <pick> picker — inline `<select>` + button pair sitting
|
||||||
sitting alongside the bulk action buttons in the selection bar.
|
alongside the bulk action buttons in the selection bar. The
|
||||||
The select inherits the terminal-y monospace look so it doesn't
|
select inherits the terminal-y monospace look so it doesn't read
|
||||||
read as system-chrome popping out of the swarm aesthetic. Only
|
as system-chrome popping out of the swarm aesthetic. See
|
||||||
surfaces when exactly one agent is selected. */
|
docs/web-ui.md::Selection bar for the picker semantics. */
|
||||||
.move-picker {
|
.move-picker {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,11 @@
|
||||||
</head>
|
</head>
|
||||||
<body class="flow-shell">
|
<body class="flow-shell">
|
||||||
|
|
||||||
<!-- Fixed-overlay chrome — just the tab strip (#389 follow-up:
|
<!-- Fixed-overlay chrome — just the tab strip. The full-viewport
|
||||||
slug moved to the dashboard's page footer; the flow page is a
|
terminal has no normal-flow footer position so the dashboard's
|
||||||
full-viewport terminal with no normal-flow footer position, so
|
slug doesn't appear here. The operator can still switch tabs
|
||||||
the slug simply doesn't appear here). The operator can still
|
from the flow page without navigating back; FL0W is the current
|
||||||
switch tabs from the flow page without navigating back; FL0W is
|
page, SW4RM / Y3R C4LL / SYST3M / SCH3DUL3S cross-link to the
|
||||||
the current page, SW4RM / Y3R C4LL / SYST3M cross-link to the
|
|
||||||
dashboard with the matching hash. -->
|
dashboard with the matching hash. -->
|
||||||
<header class="dashboard-chrome flow-chrome" id="flow-header">
|
<header class="dashboard-chrome flow-chrome" id="flow-header">
|
||||||
<nav class="tabbar" id="tabbar" role="tablist">
|
<nav class="tabbar" id="tabbar" role="tablist">
|
||||||
|
|
@ -55,7 +54,7 @@
|
||||||
|
|
||||||
<!-- Operator inbox flyout trigger — count + click → side panel
|
<!-- Operator inbox flyout trigger — count + click → side panel
|
||||||
(singleton, declared below). Hidden until the inbox is non-
|
(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
|
<button type="button" id="inbox-pill" class="flow-pill" hidden
|
||||||
title="open operator inbox">
|
title="open operator inbox">
|
||||||
<span class="flow-pill-icon" aria-hidden="true">📬</span>
|
<span class="flow-pill-icon" aria-hidden="true">📬</span>
|
||||||
|
|
@ -111,10 +110,10 @@
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Flow-specific bundle (#406 step 2). Contains the broker
|
<!-- Flow-specific bundle. Contains the broker terminal init, the
|
||||||
terminal init, the operator-inbox derived store, the inbox
|
operator-inbox derived store, the inbox pill flyout, and the
|
||||||
pill flyout, and the @-mention composer. Tab renderers etc.
|
@-mention composer. Tab renderers etc. live in
|
||||||
live in `/static/tabs.js` which /flow.html doesn't load. -->
|
`/static/tabs.js` which /flow.html doesn't load. -->
|
||||||
<script type="module" src="/static/flow.js" defer></script>
|
<script type="module" src="/static/flow.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
// /flow.html entry point (#406 step 2 — flow-specific split from the
|
// /flow.html entry point. Owns the full-page broker terminal, the
|
||||||
// previous combined entry; #406 step 3 renamed that combined entry
|
// operator-inbox derived store (populated from the broker stream),
|
||||||
// from `app.js` to `tabs.js`).
|
// the inbox pill flyout, and the @-mention compose box. Pulls shared
|
||||||
//
|
// infrastructure (DOM helpers, side panel, OS notifications, path
|
||||||
// Owns the full-page broker terminal, the operator-inbox derived store
|
// linkification) from `./common.js`.
|
||||||
// (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
|
// Does NOT contain the dashboard's tab renderers, mutation-event
|
||||||
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
|
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
|
||||||
|
|
@ -106,11 +103,11 @@ import {
|
||||||
if (!flow) return;
|
if (!flow) return;
|
||||||
flow.innerHTML = '';
|
flow.innerHTML = '';
|
||||||
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
|
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
|
||||||
// Pulse the page banner whenever a broker event lands. (Note:
|
// Pulse the page banner whenever a broker event lands. The
|
||||||
// post-#389 the `.banner` lives in the dashboard's <footer>, not
|
// `.banner` element lives in the dashboard's <footer> rather than
|
||||||
// in the flow page chrome — `pulseBanner` no-ops on /flow.html
|
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
|
||||||
// since there's no element to find. Kept for parity if a future
|
// there's no element to find. Kept for parity if a future chrome
|
||||||
// chrome change reintroduces a banner.)
|
// change reintroduces a banner.
|
||||||
const banner = document.querySelector('.banner');
|
const banner = document.querySelector('.banner');
|
||||||
let bannerOffTimer = null;
|
let bannerOffTimer = null;
|
||||||
function pulseBanner() {
|
function pulseBanner() {
|
||||||
|
|
@ -174,33 +171,28 @@ import {
|
||||||
// Register this row so future replies can reference it.
|
// Register this row so future replies can reference it.
|
||||||
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
|
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
|
||||||
}
|
}
|
||||||
// Anchor the `↓ N new` pill in `.flow-main` (NOT the default
|
// Anchor the `↓ N new` pill in `.flow-main` rather than the
|
||||||
// `log.parentElement` = `.terminal-wrap`). `.terminal-wrap`
|
// default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent
|
||||||
// applies `backdrop-filter`, which creates a CSS stacking
|
// page (Terminal-wrap) for the backdrop-filter stacking-context
|
||||||
// context — the pill's z-index would otherwise be trapped
|
// gotcha (same shape on the flow page).
|
||||||
// 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.
|
|
||||||
const flowMain = document.querySelector('.flow-main');
|
const flowMain = document.querySelector('.flow-main');
|
||||||
termCreate({
|
termCreate({
|
||||||
logEl: flow,
|
logEl: flow,
|
||||||
pillAnchor: flowMain,
|
pillAnchor: flowMain,
|
||||||
historyUrl: '/dashboard/history',
|
historyUrl: '/dashboard/history',
|
||||||
// #408: server-side filter — only the kinds this page actually
|
// Server-side filter — only the kinds this page actually renders
|
||||||
// renders or routes (sent/delivered → broker terminal,
|
// or routes (sent/delivered → broker terminal,
|
||||||
// container_state_changed/_removed → local autocomplete cache).
|
// container_state_changed/_removed → local autocomplete cache).
|
||||||
// Backend (#499) pre-parses the allow-list at subscribe time so
|
// Backend pre-parses the allow-list at subscribe time so the
|
||||||
// the per-frame hot path is one HashSet::contains and the
|
// per-frame hot path is one HashSet::contains and the
|
||||||
// JSON-serialise is skipped entirely on irrelevant kinds. The
|
// JSON-serialise is skipped entirely on irrelevant kinds. The
|
||||||
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
|
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
|
||||||
// since it routes every mutation kind into its derived stores.
|
// since it routes every mutation kind into its derived stores.
|
||||||
streamUrl: '/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
|
streamUrl: '/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
|
||||||
// #448: route through the SharedWorker so this page's SSE shares
|
// Route through the SharedWorker — see docs/web-ui.md (SSE
|
||||||
// a single backend connection with /index.html (and any other
|
// multiplexing paragraph). Worker keys on the full URL incl.
|
||||||
// open hyperhive tab). Worker keys on the full URL (incl.
|
// query string, so this filtered subscribe is its own upstream
|
||||||
// query string), so the filtered subscribe is its own upstream
|
// and won't accidentally share with tabs.js's wider subscribe.
|
||||||
// — won't accidentally share with tabs.js's wider subscribe.
|
|
||||||
streamFactory: openStream,
|
streamFactory: openStream,
|
||||||
renderers: {
|
renderers: {
|
||||||
sent: (ev, api) => renderMsg(ev, api, '→'),
|
sent: (ev, api) => renderMsg(ev, api, '→'),
|
||||||
|
|
@ -232,10 +224,9 @@ import {
|
||||||
// Re-sync the local containers cache on every SSE (re)connect.
|
// Re-sync the local containers cache on every SSE (re)connect.
|
||||||
// Live mutation events that fired during a disconnect window
|
// Live mutation events that fired during a disconnect window
|
||||||
// are never replayed, so without this the compose autocomplete
|
// are never replayed, so without this the compose autocomplete
|
||||||
// could drift stale (issue #163). We don't try to recover
|
// could drift stale. We don't try to recover missed broker rows
|
||||||
// missed broker rows here — operator inbox briefly stales on
|
// here — operator inbox briefly stales on reconnect; the
|
||||||
// reconnect; HiveTerminal's history-replay covers the next
|
// history-replay covers the next page load.
|
||||||
// page load.
|
|
||||||
onStreamOpen: () => {
|
onStreamOpen: () => {
|
||||||
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
|
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
|
||||||
if (!s || !Array.isArray(s.containers)) return;
|
if (!s || !Array.isArray(s.containers)) return;
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,11 @@
|
||||||
</head>
|
</head>
|
||||||
<body class="dashboard-shell">
|
<body class="dashboard-shell">
|
||||||
|
|
||||||
<!-- Sticky chrome — just the tab strip now (#389 follow-up: the
|
<!-- Sticky chrome — just the tab strip. The "WE ARE THE WIRED"
|
||||||
"WE ARE THE WIRED" slug moved out of chrome entirely and lives
|
slug lives at the page footer below `<main>`; chrome is
|
||||||
at the page footer below `<main>`; chrome is navigation only).
|
navigation only. Tabs route via the URL hash so F5 / back-
|
||||||
Tabs route via the URL hash so F5 / back-button / shared links
|
button / shared links keep you on the same view. JS owns
|
||||||
keep you on the same view. JS owns the actual show/hide; this
|
the show/hide. -->
|
||||||
is just the menu. -->
|
|
||||||
<header class="dashboard-chrome">
|
<header class="dashboard-chrome">
|
||||||
<nav class="tabbar" id="tabbar" role="tablist">
|
<nav class="tabbar" id="tabbar" role="tablist">
|
||||||
<a class="tab" id="tab-swarm" href="#swarm" role="tab"
|
<a class="tab" id="tab-swarm" href="#swarm" role="tab"
|
||||||
|
|
@ -34,10 +33,10 @@
|
||||||
<span class="tab-label">◆ SYST3M ◆</span>
|
<span class="tab-label">◆ SYST3M ◆</span>
|
||||||
<span class="tab-count" id="tab-count-system" hidden></span>
|
<span class="tab-count" id="tab-count-system" hidden></span>
|
||||||
</a>
|
</a>
|
||||||
<!-- SCH3DUL3S (#459): scheduled-prompts surface. List of
|
<!-- SCH3DUL3S: scheduled-prompts surface. List of queued
|
||||||
queued schedules + an operator-direct creation form.
|
schedules + an operator-direct creation form. Count pill
|
||||||
Count pill mirrors the active (non-cancelled) schedule
|
mirrors the active (non-cancelled) schedule count; hidden
|
||||||
count; hidden when zero. -->
|
when zero. -->
|
||||||
<a class="tab" id="tab-schedules" href="#schedules" role="tab"
|
<a class="tab" id="tab-schedules" href="#schedules" role="tab"
|
||||||
aria-controls="tab-pane-schedules"
|
aria-controls="tab-pane-schedules"
|
||||||
data-tab="schedules">
|
data-tab="schedules">
|
||||||
|
|
@ -45,24 +44,25 @@
|
||||||
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- M4TR1X (#607): optional matrix web client (fluffychat-web by
|
<!-- M4TR1X: optional matrix web client (fluffychat-web by
|
||||||
default) mounted at /matrix/ by hive-c0re's dashboard router
|
default) mounted at /matrix/ by the gateway when
|
||||||
when `hyperhive.matrix.gui.enable = true`. Same-origin same-
|
`hyperhive.matrix.gui.enable = true`. Same-origin
|
||||||
tab navigation (the static dist is its own SPA). Hidden in JS
|
navigation (the static dist is its own SPA). Hidden in
|
||||||
when `state.matrix_gui_enabled === false` so operators without
|
JS when `state.matrix_gui_enabled === false` so operators
|
||||||
matrix-gui on don't see a dead link (#609 covers the
|
without matrix-gui on don't see a dead link. See
|
||||||
post-#15 nginx-front re-root + .well-known auto-discovery). -->
|
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
|
<a class="tab tab-link" id="tab-matrix" href="/matrix/" hidden
|
||||||
title="open the matrix chat client (fluffychat-web)">
|
title="open the matrix chat client (fluffychat-web)">
|
||||||
<span class="tab-label">◆ M4TR1X ◆ →</span>
|
<span class="tab-label">◆ M4TR1X ◆ →</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- FL0W is its own page (`/flow.html`), not a tab — per
|
<!-- FL0W is its own page (`/flow.html`), not a tab. The link
|
||||||
operator @ #369#issuecomment-3437 ("yes terminal can be a
|
lives in the tab strip so it reads as a peer surface;
|
||||||
separate page"). The link lives in the tab strip so it
|
clicking navigates rather than swapping panes in place.
|
||||||
reads as a peer surface; clicking navigates rather than
|
Count pill mirrors the dashboard's operator-inbox length
|
||||||
swapping panes in place. Count pill mirrors the dashboard's
|
and is hidden when zero. See docs/web-ui.md::FL0W page. -->
|
||||||
operator-inbox length and is hidden when zero. -->
|
|
||||||
<a class="tab tab-link" id="tab-flow" href="/flow.html"
|
<a class="tab tab-link" id="tab-flow" href="/flow.html"
|
||||||
title="open the all-agents chat in a dedicated full-page terminal">
|
title="open the all-agents chat in a dedicated full-page terminal">
|
||||||
<span class="tab-label">◆ FL0W ◆ →</span>
|
<span class="tab-label">◆ FL0W ◆ →</span>
|
||||||
|
|
@ -86,9 +86,8 @@
|
||||||
|
|
||||||
<!-- SW4RM: the swarm itself. Container cards (the central thing
|
<!-- SW4RM: the swarm itself. Container cards (the central thing
|
||||||
the operator looks at) and rebuild queue / cascade visualisation
|
the operator looks at) and rebuild queue / cascade visualisation
|
||||||
that drives them. The tab label itself reads SW4RM, so the
|
that drives them. No inline `C0NTAINERS` h2 heading + divider
|
||||||
inline C0NTAINERS h2 heading + divider would be redundant —
|
— the tab label SW4RM already says it. -->
|
||||||
dropped per #385. -->
|
|
||||||
<section class="tab-pane" id="tab-pane-swarm"
|
<section class="tab-pane" id="tab-pane-swarm"
|
||||||
role="tabpanel" aria-labelledby="tab-swarm">
|
role="tabpanel" aria-labelledby="tab-swarm">
|
||||||
<div id="containers-section">
|
<div id="containers-section">
|
||||||
|
|
@ -117,11 +116,8 @@
|
||||||
|
|
||||||
<!-- SYST3M: passive / rare-interaction state. Meta inputs (lock
|
<!-- SYST3M: passive / rare-interaction state. Meta inputs (lock
|
||||||
bumps), rebuild queue (watch only), kept state from previous
|
bumps), rebuild queue (watch only), kept state from previous
|
||||||
tombstoned agents. Queued reminders moved to the SCH3DUL3S
|
tombstoned agents. Per-section content auto-compresses to a
|
||||||
tab in #460 — they're conceptually "fire X at time Y" too,
|
one-line summary when empty (JS toggle). -->
|
||||||
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). -->
|
|
||||||
<section class="tab-pane" id="tab-pane-system"
|
<section class="tab-pane" id="tab-pane-system"
|
||||||
role="tabpanel" aria-labelledby="tab-system">
|
role="tabpanel" aria-labelledby="tab-system">
|
||||||
<h2>◆ M3T4 1NPUTS ◆</h2>
|
<h2>◆ M3T4 1NPUTS ◆</h2>
|
||||||
|
|
@ -145,16 +141,15 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- SCH3DUL3S (#459, #564): scheduled prompts. Creation + edit
|
<!-- SCH3DUL3S: scheduled prompts. Creation + edit are folded
|
||||||
are folded into the same table now (#564) — empty bottom row
|
into the same table — empty bottom row is the create form
|
||||||
is the create form (fill cells, click +), inline-edit-row
|
(fill cells, click +), inline-edit-row expands on the `✎`
|
||||||
expands on the `✎` toggle for existing schedules. Live
|
toggle for existing schedules. Schedules list driven by
|
||||||
schedules list driven by GET /api/schedules; POST /api/schedules
|
GET /api/schedules; POST /api/schedules to create, PATCH
|
||||||
to create, PATCH /api/schedules/{id} to edit, POST
|
/api/schedules/{id} to edit, POST /api/schedules/{id}/cancel
|
||||||
/api/schedules/{id}/cancel for per-target / whole-row cancel.
|
for per-target / whole-row cancel. No SchedulesChanged SSE
|
||||||
#444 backend doesn't emit SchedulesChanged dashboard event yet,
|
event yet, so the list re-fetches on tab activation + after
|
||||||
so the list re-fetches on tab activation + after each submit /
|
each submit / cancel. See docs/web-ui.md::SCH3DUL3S tab. -->
|
||||||
cancel. Live SSE wiring is the future PR C. -->
|
|
||||||
<section class="tab-pane" id="tab-pane-schedules"
|
<section class="tab-pane" id="tab-pane-schedules"
|
||||||
role="tabpanel" aria-labelledby="tab-schedules">
|
role="tabpanel" aria-labelledby="tab-schedules">
|
||||||
<h2>◆ SCH3DUL3S ◆</h2>
|
<h2>◆ SCH3DUL3S ◆</h2>
|
||||||
|
|
@ -164,12 +159,12 @@
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- QU3U3D R3M1ND3RS (#460): self-scheduled agent reminders.
|
<!-- QU3U3D R3M1ND3RS: self-scheduled agent reminders. Lives
|
||||||
Moved here from the SYST3M tab so the operator has one
|
on this tab so the operator has one place for everything
|
||||||
place for everything that fires at a future time —
|
that fires at a future time — operator-set schedules
|
||||||
operator-set schedules above, agent-self reminders here.
|
above, agent-self reminders here. Backed by GET
|
||||||
Backed by GET /api/reminders; refresh handled by
|
/api/reminders; refresh handled by refreshReminders()
|
||||||
refreshReminders() (called from refreshState). -->
|
(called from refreshState). -->
|
||||||
<h2>◆ QU3U3D R3M1ND3RS ◆</h2>
|
<h2>◆ QU3U3D R3M1ND3RS ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<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>
|
<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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selection action bar (#443). Sticky-bottom strip that slides
|
<!-- Selection action bar. Sticky-bottom strip that slides into
|
||||||
into view when one or more agent cards is selected (click the
|
view when one or more agent cards is selected (click the icon
|
||||||
icon to toggle). Shows the selection count + actions that
|
to toggle). See docs/web-ui.md::Selection bar for the bulk
|
||||||
apply to ALL selected; disabled-with-tooltip for actions that
|
action gating + clear semantics. -->
|
||||||
don't (mara picked option B). Hidden when selection is empty. -->
|
|
||||||
<div id="selection-bar" class="selection-bar" hidden role="toolbar"
|
<div id="selection-bar" class="selection-bar" hidden role="toolbar"
|
||||||
aria-label="bulk agent actions">
|
aria-label="bulk agent actions">
|
||||||
<span class="selection-count" id="selection-count"></span>
|
<span class="selection-count" id="selection-count"></span>
|
||||||
|
|
@ -223,11 +217,10 @@
|
||||||
title="clear selection (esc)">✕ clear</button>
|
title="clear selection (esc)">✕ clear</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Single bundled entry (#406 step 3 — renamed from app.js to
|
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
|
||||||
tabs.js since this bundle is the dashboard *tabs* surface only;
|
flow.html has its own flow.js bundle. esbuild folds
|
||||||
flow.html has its own flow.js bundle). esbuild folds
|
|
||||||
@hive/shared/terminal.js and the marked npm package into
|
@hive/shared/terminal.js and the marked npm package into
|
||||||
tabs.js; load order is preserved by the module bundler. -->
|
tabs.js. -->
|
||||||
<script type="module" src="/static/tabs.js" defer></script>
|
<script type="module" src="/static/tabs.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ window.marked = marked;
|
||||||
// agent rebuild ripple) runs in the background. Cold-loaded from
|
// agent rebuild ripple) runs in the background. Cold-loaded from
|
||||||
// `s.meta_update_running`, then flipped live by the
|
// `s.meta_update_running`, then flipped live by the
|
||||||
// `meta_update_running` event. Drives the META INPUTS panel's
|
// `meta_update_running` event. Drives the META INPUTS panel's
|
||||||
// disabled "updating…" state (issue #259).
|
// disabled "updating…" state.
|
||||||
let metaUpdateRunning = false;
|
let metaUpdateRunning = false;
|
||||||
function syncTombstonesFromSnapshot(s) {
|
function syncTombstonesFromSnapshot(s) {
|
||||||
tombstonesState = (s.tombstones || []).slice();
|
tombstonesState = (s.tombstones || []).slice();
|
||||||
|
|
@ -802,7 +802,7 @@ window.marked = marked;
|
||||||
// and own descendants on the client side; the
|
// and own descendants on the client side; the
|
||||||
// backend rechecks via `topology::set_parent`).
|
// 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
|
// form-encoded `child=<name>&new_parent=<target-or-empty>`. The
|
||||||
// backend re-emits container snapshots on success, so the tree
|
// backend re-emits container snapshots on success, so the tree
|
||||||
// repaints without a separate refresh.
|
// repaints without a separate refresh.
|
||||||
|
|
@ -830,11 +830,10 @@ window.marked = marked;
|
||||||
});
|
});
|
||||||
|
|
||||||
// M0V3 → <pick>: inline `<select>` of candidate parents + submit
|
// M0V3 → <pick>: inline `<select>` of candidate parents + submit
|
||||||
// button. Available for any selection size (mara on #695 — was
|
// button. Available for any selection size. Picker omits each
|
||||||
// single-agent only in v1). Picker omits each selected agent itself
|
// selected agent itself plus the union of every selected agent's
|
||||||
// plus the union of every selected agent's descendants (cycle-safe;
|
// descendants (cycle-safe; backend `topology::set_parent`
|
||||||
// backend `topology::set_parent` re-checks). Empty candidate list ⇒
|
// re-checks). Empty candidate list ⇒ disable the picker.
|
||||||
// disable the picker.
|
|
||||||
const candidates = validReparentCandidates(selected, containers);
|
const candidates = validReparentCandidates(selected, containers);
|
||||||
const wrap = el('span', { class: 'move-picker' });
|
const wrap = el('span', { class: 'move-picker' });
|
||||||
const selectTitle = selected.length === 1
|
const selectTitle = selected.length === 1
|
||||||
|
|
@ -1033,11 +1032,9 @@ window.marked = marked;
|
||||||
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
||||||
} else {
|
} else {
|
||||||
pre.textContent = text || '(empty)';
|
pre.textContent = text || '(empty)';
|
||||||
// Auto-scroll to the newest lines on fresh fetch. #541
|
// Auto-scroll to the newest lines on fresh fetch. The
|
||||||
// moved the scroll surface from side-panel-body onto the
|
// scroll surface is the <pre> itself (panel-body fills
|
||||||
// <pre> itself (the panel-body now fills the viewport and
|
// the viewport, <pre> is the inner overflow container).
|
||||||
// the <pre> is the inner overflow container), so scroll
|
|
||||||
// the <pre> instead of the side-panel-body.
|
|
||||||
pre.scrollTop = pre.scrollHeight;
|
pre.scrollTop = pre.scrollHeight;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -2946,9 +2943,9 @@ window.marked = marked;
|
||||||
if (!confirm(prompt)) return;
|
if (!confirm(prompt)) return;
|
||||||
// Capture child nodes so we can restore on error, then replace
|
// Capture child nodes so we can restore on error, then replace
|
||||||
// with DOM-built content (textContent + element children rather
|
// with DOM-built content (textContent + element children rather
|
||||||
// than innerHTML — per argus's review note on #471, the format
|
// than innerHTML — the format string only carries server-side
|
||||||
// string only carries server-side ints/bool today but textContent
|
// ints/bool today but textContent is the safer pattern if a
|
||||||
// is the safer pattern if a stringy field ever lands).
|
// stringy field ever lands).
|
||||||
const originalChildren = btn ? Array.from(btn.childNodes) : [];
|
const originalChildren = btn ? Array.from(btn.childNodes) : [];
|
||||||
const restoreBtn = () => {
|
const restoreBtn = () => {
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
@ -3206,7 +3203,7 @@ window.marked = marked;
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ─── tab routing (#369) ────────────────────────────────────────────────
|
// ─── tab routing ───────────────────────────────────────────────────────
|
||||||
// Hash-based: `#swarm` / `#call` / `#system` activate the matching
|
// Hash-based: `#swarm` / `#call` / `#system` activate the matching
|
||||||
// pane on the dashboard. Empty hash defaults to SW4RM. FL0W is NOT
|
// pane on the dashboard. Empty hash defaults to SW4RM. FL0W is NOT
|
||||||
// a tab — it's a separate page (`/flow.html`) reached via the
|
// 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
|
colour; pages that don't emit a given kind simply never produce that
|
||||||
class — the unused rule sits in the bundle harmlessly.
|
class — the unused rule sits in the bundle harmlessly.
|
||||||
|
|
||||||
`.terminal-wrap` provides the crust-on-black phosphor chrome that makes
|
`.terminal-wrap` provides the crust-on-black phosphor chrome that
|
||||||
the agent page feel like a terminal. Pages can opt in by wrapping a
|
makes the agent page feel like a terminal. Pages can opt in by
|
||||||
block in this class; or skip it and the rows still render with their
|
wrapping a block in this class; or skip it and the rows still render
|
||||||
class colours, just without the frame.
|
with their class colours, just without the frame.
|
||||||
|
|
||||||
No `.term-input` here — composers are a separate concern (see
|
No `.term-input` here — composers are a separate concern, owned by
|
||||||
hive-fr0nt::COMPOSER_CSS / COMPOSER_JS once introduced). */
|
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 {
|
.terminal-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -213,7 +215,7 @@ details.row > pre.diff-body .diff-ctx { color: var(--fg); }
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
.live .row .md a { color: var(--cyan); text-decoration: underline; }
|
.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 { color: var(--cyan); text-decoration: underline; }
|
||||||
.live .row a:hover { color: var(--fg); }
|
.live .row a:hover { color: var(--fg); }
|
||||||
.live .row .md strong { color: inherit; font-weight: bold; }
|
.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.
|
// count=0); pages use it to set state flags from the replayed history.
|
||||||
|
|
||||||
const NEAR_BOTTOM_PX = 48;
|
const NEAR_BOTTOM_PX = 48;
|
||||||
// Snap-to-bottom animation duration (#400 + mara feedback). Browser
|
// Snap-to-bottom animation duration. See docs/web-ui.md::Shared
|
||||||
// default `scrollTo({ behavior: 'smooth' })` runs ~500ms, which read
|
// terminal pane (Sticky-bottom + snap animation) for the 140ms-vs-
|
||||||
// as "still smooth, but visibly slow." 140ms with ease-out is fast
|
// 500ms-browser-default + 24px short-circuit rationale.
|
||||||
// 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.
|
|
||||||
const SCROLL_ANIM_MS = 140;
|
const SCROLL_ANIM_MS = 140;
|
||||||
const SCROLL_SNAP_PX = 24;
|
const SCROLL_SNAP_PX = 24;
|
||||||
|
|
||||||
|
|
@ -77,16 +74,9 @@ export function create(opts) {
|
||||||
// handler so both programmatic scrollTop assignments and
|
// handler so both programmatic scrollTop assignments and
|
||||||
// operator-driven wheel/drag stay in sync.
|
// operator-driven wheel/drag stay in sync.
|
||||||
let stickToBottom = true;
|
let stickToBottom = true;
|
||||||
// Guards scroll-event-handler from misreading the position while
|
// Scroll-handler gate during in-flight snap animations — see
|
||||||
// our own animation is mid-flight (#400). The animation drives
|
// docs/web-ui.md::Shared terminal pane (Mid-animation scroll-event
|
||||||
// scrollTop with rAF, which fires a stream of scroll events as
|
// guard) for the eased-through-not-near-bottom rationale.
|
||||||
// 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.
|
|
||||||
let smoothScrollingUntil = 0;
|
let smoothScrollingUntil = 0;
|
||||||
// rAF id for the current snap animation. Cancelled when a new
|
// rAF id for the current snap animation. Cancelled when a new
|
||||||
// snap starts so we never have two animations fighting over
|
// snap starts so we never have two animations fighting over
|
||||||
|
|
@ -96,16 +86,10 @@ export function create(opts) {
|
||||||
function isNearBottom() {
|
function isNearBottom() {
|
||||||
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
||||||
}
|
}
|
||||||
// Snap the log to the bottom with a brief eased animation
|
// Snap the log to the bottom with a brief eased animation. Cancels
|
||||||
// (#400 + mara: snappier than the browser's default 500ms smooth
|
// any in-flight frame loop so back-to-back snaps coalesce; falls
|
||||||
// scroll). Each call cancels the previous frame loop and starts a
|
// back to instant scroll during `currentNoAnim` backfill replay
|
||||||
// fresh one, so a burst of mutations coalesces into one ride to
|
// or under SCROLL_SNAP_PX. See docs/web-ui.md::Shared terminal pane.
|
||||||
// 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.
|
|
||||||
function snapToBottom(immediate) {
|
function snapToBottom(immediate) {
|
||||||
stickToBottom = true;
|
stickToBottom = true;
|
||||||
if (scrollAnimRaf) {
|
if (scrollAnimRaf) {
|
||||||
|
|
@ -161,48 +145,26 @@ export function create(opts) {
|
||||||
pill.classList.add('visible');
|
pill.classList.add('visible');
|
||||||
}
|
}
|
||||||
log.addEventListener('scroll', () => {
|
log.addEventListener('scroll', () => {
|
||||||
// Mid-smooth-scroll: ignore the intermediate scroll events. The
|
// Swallow scroll events during smooth-snap animations — see
|
||||||
// gate releases when the animation has had time to settle (or
|
// docs/web-ui.md::Shared terminal pane (Mid-animation scroll-event
|
||||||
// when the next snap re-arms it). Without this, easing toward
|
// guard).
|
||||||
// bottom would flip `stickToBottom` false partway and the next
|
|
||||||
// MO callback would skip the snap.
|
|
||||||
if (Date.now() < smoothScrollingUntil) return;
|
if (Date.now() < smoothScrollingUntil) return;
|
||||||
stickToBottom = isNearBottom();
|
stickToBottom = isNearBottom();
|
||||||
if (stickToBottom) { unseen = 0; updatePill(); }
|
if (stickToBottom) { unseen = 0; updatePill(); }
|
||||||
});
|
});
|
||||||
// Post-append mutations (issue #393). Renderers commonly call
|
// Post-append mutation snap — catches renderer mutations that land
|
||||||
// `api.row(cls, text)` to create the row shell, then mutate it
|
// after `api.row` returns (badges, multi-line bodies, tool
|
||||||
// by appending more children (badges, multi-line bodies, tool
|
// panes). See docs/web-ui.md::Shared terminal pane (Post-append
|
||||||
// result panes) AFTER api.row returned. The afterAppend scroll
|
// MutationObserver) for why the pre-append `afterAppend` hop
|
||||||
// below only sees the row's INITIAL height — once the renderer
|
// alone isn't enough.
|
||||||
// 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.
|
|
||||||
const mo = new MutationObserver(() => {
|
const mo = new MutationObserver(() => {
|
||||||
if (stickToBottom) snapToBottom();
|
if (stickToBottom) snapToBottom();
|
||||||
});
|
});
|
||||||
mo.observe(log, { childList: true, subtree: true, characterData: true });
|
mo.observe(log, { childList: true, subtree: true, characterData: true });
|
||||||
|
|
||||||
// Auto-scroll decision uses the PRE-append scroll position
|
// Pre-append nearBottom snapshot drives the initial snap decision —
|
||||||
// (issue #375). Checking after the append underestimates
|
// see docs/web-ui.md::Shared terminal pane (Post-append
|
||||||
// "nearness" because the new row's own height has already pushed
|
// MutationObserver) for why we need both this and the MO.
|
||||||
// `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.)
|
|
||||||
function afterAppend(wasNearBottom) {
|
function afterAppend(wasNearBottom) {
|
||||||
if (currentNoAnim || wasNearBottom) {
|
if (currentNoAnim || wasNearBottom) {
|
||||||
snapToBottom();
|
snapToBottom();
|
||||||
|
|
@ -318,12 +280,11 @@ export function create(opts) {
|
||||||
let live = false;
|
let live = false;
|
||||||
let buffered = [];
|
let buffered = [];
|
||||||
|
|
||||||
// #448: callers can supply a `streamFactory(url)` that returns an
|
// Optional streamFactory(url) → EventSource-shaped facade. Lets
|
||||||
// EventSource-shaped object (must expose onmessage/onopen/onerror
|
// the dashboard hand the factory a SharedWorker-backed source so
|
||||||
// + .close()). The dashboard pages pass a SharedWorker-backed
|
// open hyperhive tabs share one upstream — see
|
||||||
// factory so all open hyperhive tabs share ONE upstream SSE
|
// docs/web-ui.md::Shared terminal pane (Backfill + SSE). Default
|
||||||
// connection. Default keeps the direct `new EventSource(url)`
|
// falls back to `new EventSource(url)`.
|
||||||
// behaviour so non-dashboard consumers (per-agent UI) are unchanged.
|
|
||||||
const es = opts.streamFactory
|
const es = opts.streamFactory
|
||||||
? opts.streamFactory(opts.streamUrl)
|
? opts.streamFactory(opts.streamUrl)
|
||||||
: new EventSource(opts.streamUrl);
|
: new EventSource(opts.streamUrl);
|
||||||
|
|
@ -351,7 +312,7 @@ export function create(opts) {
|
||||||
// during a disconnect window, so a consumer with
|
// during a disconnect window, so a consumer with
|
||||||
// snapshot-derived state (the dashboard's /api/state stores)
|
// snapshot-derived state (the dashboard's /api/state stores)
|
||||||
// must re-sync here or it shows stale state until a manual
|
// must re-sync here or it shows stale state until a manual
|
||||||
// reload (issue #163).
|
// reload.
|
||||||
if (opts.onStreamOpen) {
|
if (opts.onStreamOpen) {
|
||||||
try { opts.onStreamOpen(); }
|
try { opts.onStreamOpen(); }
|
||||||
catch (err) { console.error('onStreamOpen threw', err); }
|
catch (err) { console.error('onStreamOpen threw', err); }
|
||||||
|
|
@ -370,7 +331,7 @@ export function create(opts) {
|
||||||
// carried by the history endpoint; deduping them against the
|
// carried by the history endpoint; deduping them against the
|
||||||
// broker-history seq would wrongly drop ones that fired
|
// broker-history seq would wrongly drop ones that fired
|
||||||
// between a consumer's own snapshot read and this history
|
// 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
|
if (boundarySeq != null
|
||||||
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
|
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
|
||||||
&& historyKinds && historyKinds.has(ev.kind)) {
|
&& historyKinds && historyKinds.has(ev.kind)) {
|
||||||
|
|
@ -427,9 +388,9 @@ export function create(opts) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a DocumentFragment from `text`, turning bare http(s) URLs into
|
// 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
|
// clickable links that open in a new tab. See docs/web-ui.md::Shared
|
||||||
// text nodes — no innerHTML, so this is XSS-safe. Trailing sentence
|
// terminal pane (linkify) for the text-node-only / no-innerHTML
|
||||||
// punctuation is kept out of the link. (issue #233)
|
// XSS-safety + trailing-punctuation strip.
|
||||||
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
|
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
|
||||||
export function linkify(text) {
|
export function linkify(text) {
|
||||||
const str = text == null ? '' : String(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 tokio::process::Command;
|
||||||
|
|
||||||
use crate::client;
|
|
||||||
|
|
||||||
const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json";
|
const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json";
|
||||||
const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json";
|
const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json";
|
||||||
const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.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
|
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
|
||||||
/// `notify_recipient` is `Some(name)`, install failures also get sent
|
/// Returns a list of human-readable failure messages so the caller can
|
||||||
/// as a hyperhive message to that recipient (typically `"manager"` for
|
/// route them through their own per-role surface (turn-failure-style
|
||||||
/// sub-agents) so it surfaces in the inbox rather than being buried in
|
/// notification, see `Surface::send_to_parent`). Pre-#692 this function
|
||||||
/// journald. The manager itself passes `None` — there's nobody above
|
/// hardcoded `"manager"` as the failure-notification recipient via a
|
||||||
/// it to notify.
|
/// `notify_recipient: Option<&str>` arg; mara on #778 wanted the
|
||||||
pub async fn install_configured(socket: &Path, notify_recipient: Option<&str>) {
|
/// 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 {
|
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) {
|
let specs: Vec<String> = match serde_json::from_str(&raw) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
|
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
|
||||||
return;
|
return Vec::new();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if specs.is_empty() {
|
if specs.is_empty() {
|
||||||
return;
|
return Vec::new();
|
||||||
}
|
}
|
||||||
add_marketplaces().await;
|
add_marketplaces().await;
|
||||||
if auto_update_enabled().await {
|
if auto_update_enabled().await {
|
||||||
|
|
@ -125,6 +127,7 @@ pub async fn install_configured(socket: &Path, notify_recipient: Option<&str>) {
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
|
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
|
||||||
}
|
}
|
||||||
|
let mut failures = Vec::new();
|
||||||
for spec in specs {
|
for spec in specs {
|
||||||
match Command::new("claude")
|
match Command::new("claude")
|
||||||
.args(["plugin", "install", &spec])
|
.args(["plugin", "install", &spec])
|
||||||
|
|
@ -142,43 +145,18 @@ pub async fn install_configured(socket: &Path, notify_recipient: Option<&str>) {
|
||||||
stderr = %stderr,
|
stderr = %stderr,
|
||||||
"claude plugin install failed",
|
"claude plugin install failed",
|
||||||
);
|
);
|
||||||
if let Some(to) = notify_recipient {
|
failures.push(format!(
|
||||||
notify(
|
"claude plugin install failed for `{spec}`:\n{}",
|
||||||
socket,
|
stderr.trim()
|
||||||
to,
|
));
|
||||||
format!(
|
|
||||||
"claude plugin install failed for `{spec}`:\n{}",
|
|
||||||
stderr.trim()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
|
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
|
||||||
if let Some(to) = notify_recipient {
|
failures.push(format!(
|
||||||
notify(
|
"claude plugin install spawn failed for `{spec}`: {e}"
|
||||||
socket,
|
));
|
||||||
to,
|
|
||||||
format!("claude plugin install spawn failed for `{spec}`: {e}"),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
failures
|
||||||
|
|
||||||
/// 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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
@ -78,7 +78,19 @@ pub type Flavor = mcp::Flavor;
|
||||||
|
|
||||||
/// # Errors
|
/// # 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(
|
pub async fn serve(
|
||||||
label: String,
|
label: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
|
|
@ -138,13 +150,54 @@ pub async fn serve(
|
||||||
// hyperhive.frontend.mergedDist in nix).
|
// hyperhive.frontend.mergedDist in nix).
|
||||||
.fallback_service(ServeDir::new(&static_dir))
|
.fallback_service(ServeDir::new(&static_dir))
|
||||||
.with_state(state);
|
.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 addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
let listener = bind_with_retry(addr, "web UI").await?;
|
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?;
|
axum::serve(listener, app).await?;
|
||||||
Ok(())
|
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
|
// 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 actions;
|
||||||
pub mod agent_ports;
|
pub mod agent_ports;
|
||||||
pub mod agent_server;
|
pub mod agent_server;
|
||||||
|
pub mod agent_sockets;
|
||||||
pub mod approvals;
|
pub mod approvals;
|
||||||
pub mod auto_update;
|
pub mod auto_update;
|
||||||
pub mod broker;
|
pub mod broker;
|
||||||
|
|
|
||||||
|
|
@ -1141,6 +1141,40 @@ fn set_nspawn_flags(
|
||||||
// empty RO dir instead of a container that won't boot.
|
// empty RO dir instead of a container that won't boot.
|
||||||
std::fs::create_dir_all(&config_dir).with_context(|| format!("create {config_dir}"))?;
|
std::fs::create_dir_all(&config_dir).with_context(|| format!("create {config_dir}"))?;
|
||||||
let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config");
|
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 bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\"");
|
||||||
let mut lines: Vec<String> = original
|
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)");
|
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 {
|
if initial {
|
||||||
git(&dir, &["init", "--initial-branch=main"]).await?;
|
git(&dir, &["init", "--initial-branch=main"]).await?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,10 @@ enum Verb {
|
||||||
Diff(verbs::diff::Args),
|
Diff(verbs::diff::Args),
|
||||||
/// Get or set this user's watch subscription on a repo.
|
/// Get or set this user's watch subscription on a repo.
|
||||||
Subscription(verbs::subscription::Args),
|
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.
|
/// Upload a file as an attachment to an issue.
|
||||||
AttachIssue(verbs::attach::IssueArgs),
|
AttachIssue(verbs::attach::IssueArgs),
|
||||||
/// Upload a file as an attachment to a comment.
|
/// 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::TreeSha(a) => verbs::tree_sha::run(&client, a),
|
||||||
Verb::Diff(a) => verbs::diff::run(&client, a),
|
Verb::Diff(a) => verbs::diff::run(&client, a),
|
||||||
Verb::Subscription(a) => verbs::subscription::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::AttachIssue(a) => verbs::attach::run_issue(&client, a),
|
||||||
Verb::AttachComment(a) => verbs::attach::run_comment(&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
|
//! `comments <number> [--limit N | --tail N]` — list comments on an
|
||||||
//! or PR. Closes the curl-fallback gap (#418). Use the global
|
//! issue or PR. Closes the curl-fallback gap (#418); `--tail`
|
||||||
//! `--json` flag for JSON output (#421).
|
//! 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 anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
|
|
@ -9,42 +23,51 @@ use serde_json::{Value, json};
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
use crate::verbs::print_json;
|
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)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
/// Issue or PR number.
|
/// Issue or PR number.
|
||||||
number: u64,
|
number: u64,
|
||||||
/// Page size (Forgejo caps at 50 by default).
|
/// Page size for the head-of-thread shape (Forgejo caps at 50).
|
||||||
#[arg(long, default_value_t = 50)]
|
/// Mutually exclusive with `--tail`.
|
||||||
|
#[arg(long, default_value_t = 50, conflicts_with = "tail")]
|
||||||
limit: u64,
|
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<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let repo = client.repo();
|
let repo = client.repo();
|
||||||
let v = client.get_json(&format!(
|
let comments = match args.tail {
|
||||||
"/repos/{repo}/issues/{}/comments?limit={}",
|
Some(n) => fetch_tail(client, &repo, args.number, n)?,
|
||||||
args.number, args.limit
|
None => fetch_head(client, &repo, args.number, args.limit)?,
|
||||||
))?;
|
};
|
||||||
if client.json_mode() {
|
if client.json_mode() {
|
||||||
let trimmed: Vec<Value> = v
|
let trimmed: Vec<Value> = comments
|
||||||
.as_array()
|
.iter()
|
||||||
.map(|a| {
|
.map(|c| {
|
||||||
a.iter()
|
json!({
|
||||||
.map(|c| {
|
"id": c.get("id"),
|
||||||
json!({
|
"user": c.get("user").and_then(|u| u.get("login")),
|
||||||
"id": c.get("id"),
|
"created_at": c.get("created_at"),
|
||||||
"user": c.get("user").and_then(|u| u.get("login")),
|
"updated_at": c.get("updated_at"),
|
||||||
"created_at": c.get("created_at"),
|
"body": c.get("body"),
|
||||||
"updated_at": c.get("updated_at"),
|
"url": c.get("html_url"),
|
||||||
"body": c.get("body"),
|
})
|
||||||
"url": c.get("html_url"),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.collect();
|
||||||
print_json(&Value::Array(trimmed))
|
print_json(&Value::Array(trimmed))
|
||||||
} else {
|
} else {
|
||||||
for c in v.as_array().cloned().unwrap_or_default() {
|
for c in &comments {
|
||||||
let user = c
|
let user = c
|
||||||
.get("user")
|
.get("user")
|
||||||
.and_then(|u| u.get("login"))
|
.and_then(|u| u.get("login"))
|
||||||
|
|
@ -58,3 +81,124 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
Ok(())
|
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_create;
|
||||||
pub mod pr_reviews;
|
pub mod pr_reviews;
|
||||||
pub mod subscription;
|
pub mod subscription;
|
||||||
|
pub mod timeline;
|
||||||
pub mod tree_sha;
|
pub mod tree_sha;
|
||||||
pub mod view;
|
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>,
|
pub in_reply_to: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One delivered message in a `Recv` response. The unified
|
/// One delivered message in a `Recv` response.
|
||||||
/// `Recv { max }` always returns a `Vec<DeliveredMessage>` — single
|
/// See `docs/conventions.md::Broker delivery + ack cycle` for the
|
||||||
/// pop = a one-element vec, batch = up to `max` elements, idle =
|
/// full delivery/ack/requeue story.
|
||||||
/// 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.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DeliveredMessage {
|
pub struct DeliveredMessage {
|
||||||
pub from: String,
|
pub from: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
/// Broker row id, mirrored from the `Delivery` struct. Opaque to
|
/// Broker row id, tracked by the harness for `AckTurn`. Opaque to
|
||||||
/// claude but tracked by the harness so the broker's in-memory
|
/// claude. `default` for wire backwards-compat.
|
||||||
/// unacked list can be drained on `AckTurn`. Marked `default` for
|
|
||||||
/// wire backwards-compat — pre-feature peers parse to 0.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
/// `true` when this row was previously popped, never acked, and
|
/// `true` if this row was resurfaced by `RequeueInflight` (previously
|
||||||
/// resurfaced by `RequeueInflight`. The format helper prepends the
|
/// popped, never acked). Formatter prepends a "may already be handled"
|
||||||
/// "may already be handled" hint to the rendered body so claude
|
/// hint when set.
|
||||||
/// sees the warning per-message in the batch.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub redelivered: bool,
|
pub redelivered: bool,
|
||||||
/// Row-id of the message this is a reply to, if any.
|
/// 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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
in_reply_to: Option<i64>,
|
in_reply_to: Option<i64>,
|
||||||
},
|
},
|
||||||
/// Pop pending messages from this agent's inbox. Always returns
|
/// Pop pending messages from this agent's inbox.
|
||||||
/// a list (`Messages { messages }`) — empty when nothing's
|
/// Delivery + ack cycle: see
|
||||||
/// pending. `max` caps the batch size (default 1 = single-message
|
/// `docs/conventions.md::Broker delivery + ack cycle`.
|
||||||
/// 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.
|
|
||||||
Recv {
|
Recv {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
wait_seconds: Option<u64>,
|
wait_seconds: Option<u64>,
|
||||||
/// Maximum number of messages to pop. None = 1 (single).
|
|
||||||
/// Server-side cap is 32; values above clamp silently.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
max: Option<u32>,
|
max: Option<u32>,
|
||||||
},
|
},
|
||||||
|
|
@ -498,27 +482,14 @@ pub enum AgentRequest {
|
||||||
/// row. The manager surface uses the same wire variant but
|
/// row. The manager surface uses the same wire variant but
|
||||||
/// accepts any id.
|
/// accepts any id.
|
||||||
CancelLooseEnd { kind: CancelLooseEndKind, id: i64 },
|
CancelLooseEnd { kind: CancelLooseEndKind, id: i64 },
|
||||||
/// Mark every message popped by this agent since the last `AckTurn`
|
/// Mark every message popped since the last `AckTurn` as handled.
|
||||||
/// as fully handled. Fired by the harness after `TurnOutcome::Ok`
|
/// Harness↔broker pairing fired after `TurnOutcome::Ok`. See
|
||||||
/// — claude doesn't see this surface, it's harness↔broker only.
|
/// `docs/conventions.md::Broker delivery + ack cycle`.
|
||||||
/// 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).
|
|
||||||
AckTurn,
|
AckTurn,
|
||||||
/// Requeue every message the broker handed to this agent that
|
/// Requeue every popped-but-unacked message back into the inbox.
|
||||||
/// never got acked. Fired by the harness exactly once at boot,
|
/// Harness fires this once at boot to recover from
|
||||||
/// before entering the serve loop — catches the
|
/// crashed-mid-turn sessions. See
|
||||||
/// crashed-mid-turn / OOM-killed / container-restarted cases
|
/// `docs/conventions.md::Broker delivery + ack cycle`.
|
||||||
/// 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.
|
|
||||||
RequeueInflight,
|
RequeueInflight,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,38 +4,24 @@
|
||||||
librsvg,
|
librsvg,
|
||||||
}:
|
}:
|
||||||
|
|
||||||
# Static assets the rust workspace reads at runtime: the project's
|
# Branding SVG/PNG family + claude prompts, split out from the rust
|
||||||
# branding SVG/PNG family + the claude system-prompt template +
|
# workspace so a tweak here doesn't invalidate the rust cargo cache.
|
||||||
# claude-settings JSON. Lives as its own derivation so a tweak to
|
# Rationale + agent-configs PNG rendering: docs/gotchas.md::Split asset
|
||||||
# branding/agent-configs.svg or hive-ag3nt/prompts/system.md doesn't
|
# derivations away from the rust workspace.
|
||||||
# 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).
|
|
||||||
#
|
#
|
||||||
# Output layout:
|
# Output layout:
|
||||||
#
|
# $out/share/hyperhive/branding/{hyperhive,agent-configs}.{svg,png}
|
||||||
# $out/share/hyperhive/branding/{hyperhive.svg, hyperhive.png,
|
|
||||||
# agent-configs.svg, agent-configs.png}
|
|
||||||
# $out/share/hyperhive/prompts/{system.md, claude-settings.json}
|
# $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 {
|
stdenv.mkDerivation {
|
||||||
pname = "hyperhive-assets";
|
pname = "hyperhive-assets";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
# `src` is intentionally narrow — only branding/ + the hive-ag3nt/prompts/
|
# Narrow `srcs` (branding/ + hive-ag3nt/prompts/) is what decouples
|
||||||
# subdir, NOT the whole tree. Keeps the input hash decoupled from
|
# this derivation's input hash from the rest of the tree.
|
||||||
# rust source / docs / nix module edits.
|
|
||||||
srcs = [
|
srcs = [
|
||||||
../branding
|
../branding
|
||||||
../hive-ag3nt/prompts
|
../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 = ''
|
unpackPhase = ''
|
||||||
runHook preUnpack
|
runHook preUnpack
|
||||||
cp -r ${../branding} branding
|
cp -r ${../branding} branding
|
||||||
|
|
@ -46,10 +32,8 @@ stdenv.mkDerivation {
|
||||||
|
|
||||||
nativeBuildInputs = [ librsvg ];
|
nativeBuildInputs = [ librsvg ];
|
||||||
|
|
||||||
# No real build step — just render the agent-configs PNG alongside
|
# 300×300 matches branding/hyperhive.png — the size Forgejo's avatar
|
||||||
# its SVG. 300×300 matches branding/hyperhive.png, which is the size
|
# endpoint accepts without resampling on upload.
|
||||||
# Forgejo's avatar endpoint accepts without resampling on upload (the
|
|
||||||
# same constraint hive-c0re/build.rs encoded).
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
rsvg-convert --width 300 --height 300 \
|
rsvg-convert --width 300 --height 300 \
|
||||||
|
|
|
||||||
|
|
@ -4,34 +4,15 @@
|
||||||
self,
|
self,
|
||||||
nixosSystem,
|
nixosSystem,
|
||||||
}:
|
}:
|
||||||
# Options documentation for hyperhive's NixOS module surfaces.
|
# Nix options reference: `pkgs.nixosOptionsDoc` over two evaluated
|
||||||
# Closes #616. HTML output added per mara on internal-requests #8.
|
# module trees, rendered as CommonMark + HTML + bundled static site
|
||||||
#
|
# the operator's nginx serves from `/options/`. Full pipeline +
|
||||||
# Three rendering layers:
|
# subtree-pick / output-tree rationale: docs/gotchas.md::Nix options
|
||||||
# CommonMark — `pkgs.nixosOptionsDoc.optionsCommonMark`. Source of
|
# reference.
|
||||||
# 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.
|
|
||||||
let
|
let
|
||||||
# Evaluate the host module under a stub NixOS system. Stubs satisfy
|
# Stub host system: every hyperhive subsystem `mkForce false` so
|
||||||
# the few hard-required options (filesystems, stateVersion) without
|
# heavy build inputs (matrix container, forge, etc.) stay out of
|
||||||
# actually enabling the hive — we only want the option *declarations*
|
# the eval — only option *declarations* matter for the doc walk.
|
||||||
# to evaluate, not the config.
|
|
||||||
hostEval = nixosSystem {
|
hostEval = nixosSystem {
|
||||||
system = pkgs.stdenv.hostPlatform.system;
|
system = pkgs.stdenv.hostPlatform.system;
|
||||||
modules = [
|
modules = [
|
||||||
|
|
@ -46,10 +27,6 @@ let
|
||||||
};
|
};
|
||||||
boot.loader.grub.enable = false;
|
boot.loader.grub.enable = false;
|
||||||
system.stateVersion = "25.11";
|
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.enable = lib.mkForce false;
|
||||||
services.hyperhive.forge.enable = lib.mkForce false;
|
services.hyperhive.forge.enable = lib.mkForce false;
|
||||||
services.hyperhive.matrix.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
|
# Reuse the already-evaluated agent-base config — its options tree is
|
||||||
# config. Reusing it avoids re-evaluating the harness module against
|
# identical to what a real agent container sees, no second eval needed.
|
||||||
# a fresh stub — the options tree is identical to what a real agent
|
|
||||||
# container sees.
|
|
||||||
agentEval = self.nixosConfigurations.agent-base;
|
agentEval = self.nixosConfigurations.agent-base;
|
||||||
|
|
||||||
# Strip the nix-store prefix from option declaration paths and rewrite
|
# Rewrite option declaration paths from nix-store absolute paths to
|
||||||
# them as forge URLs so the rendered docs link back to the source.
|
# forge URLs so rendered docs link back to source.
|
||||||
forgeRoot = "https://forge.darkest.space/hyperhive/hyperhive/src/branch/main";
|
forgeRoot = "https://forge.darkest.space/hyperhive/hyperhive/src/branch/main";
|
||||||
storePrefix = toString self + "/";
|
storePrefix = toString self + "/";
|
||||||
transformOptions =
|
transformOptions =
|
||||||
|
|
@ -90,10 +65,10 @@ let
|
||||||
) opt.declarations;
|
) opt.declarations;
|
||||||
};
|
};
|
||||||
|
|
||||||
# Filter an evaluated `options` tree down to a set of top-level
|
# Filter to a set of top-level subtree roots — keeps the rendered docs
|
||||||
# subtrees we care about. Anything outside the listed roots is
|
# focused on hyperhive's surface instead of NixOS's 10k+ default
|
||||||
# dropped — keeps the rendered docs focused on hyperhive's surface
|
# options. Root choice matters: see docs/gotchas.md::Nix options
|
||||||
# instead of NixOS's 10k+ default options.
|
# reference for the post-#615 services.hyperhive consolidation history.
|
||||||
pickSubtrees =
|
pickSubtrees =
|
||||||
options: roots:
|
options: roots:
|
||||||
let
|
let
|
||||||
|
|
@ -106,12 +81,6 @@ let
|
||||||
in
|
in
|
||||||
lib.foldl' lib.recursiveUpdate { } (map pick roots);
|
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 [
|
hostOptions = pickSubtrees hostEval.options [
|
||||||
[
|
[
|
||||||
"services"
|
"services"
|
||||||
|
|
@ -133,8 +102,7 @@ let
|
||||||
inherit transformOptions;
|
inherit transformOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
# Plain-markdown page (with a short header). Source of truth; the
|
# CommonMark .md = source of truth; HTML is rendered from this.
|
||||||
# HTML version is rendered from this.
|
|
||||||
mkMarkdownPage =
|
mkMarkdownPage =
|
||||||
name: title: doc:
|
name: title: doc:
|
||||||
pkgs.runCommand "hyperhive-${name}.md" { } ''
|
pkgs.runCommand "hyperhive-${name}.md" { } ''
|
||||||
|
|
@ -149,16 +117,11 @@ let
|
||||||
} > $out
|
} > $out
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Inline-stylesheet, loaded as plain text from `./style.css` so it's
|
# Loaded as text so it's editable with normal CSS tooling and
|
||||||
# editable with normal CSS tooling (#625). Inlined into every page
|
# inlined into every page (no second-fetch dependency).
|
||||||
# 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).
|
|
||||||
styleCSS = builtins.readFile ./style.css;
|
styleCSS = builtins.readFile ./style.css;
|
||||||
|
|
||||||
# HTML page: CommonMark → cmark-gfm → minimal template with inline
|
# CommonMark → cmark-gfm → minimal template, inline CSS, relative links.
|
||||||
# CSS + relative-only links. cmark-gfm rather than plain cmark so
|
|
||||||
# any future tables / autolinks Just Work without revisiting.
|
|
||||||
mkHtmlPage =
|
mkHtmlPage =
|
||||||
name: title: doc:
|
name: title: doc:
|
||||||
pkgs.runCommand "hyperhive-${name}.html" { nativeBuildInputs = [ pkgs.cmark-gfm ]; } ''
|
pkgs.runCommand "hyperhive-${name}.html" { nativeBuildInputs = [ pkgs.cmark-gfm ]; } ''
|
||||||
|
|
@ -192,9 +155,7 @@ let
|
||||||
} > $out
|
} > $out
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Landing page — same template shape as the option pages but
|
# Landing page — same template shape, hand-authored intro + cross-links.
|
||||||
# hand-authored content (short intro + cross-links). Kept tight; the
|
|
||||||
# detail lives on the two option pages.
|
|
||||||
indexHTML = pkgs.runCommand "hyperhive-docs-index.html" { } ''
|
indexHTML = pkgs.runCommand "hyperhive-docs-index.html" { } ''
|
||||||
{
|
{
|
||||||
echo '<!doctype html>'
|
echo '<!doctype html>'
|
||||||
|
|
@ -240,14 +201,11 @@ let
|
||||||
agentMD = mkMarkdownPage "docs-agent" "hyperhive — per-agent options" agentDoc;
|
agentMD = mkMarkdownPage "docs-agent" "hyperhive — per-agent options" agentDoc;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# Individual page outputs (HTML is the primary surface; the .md
|
|
||||||
# source is one `nix build` step away if needed).
|
|
||||||
host = hostHTML;
|
host = hostHTML;
|
||||||
agent = agentHTML;
|
agent = agentHTML;
|
||||||
|
|
||||||
# Bundled static site for nginx to serve at `/options/`. Asset paths
|
# Bundled static site nginx serves at `/options/`. Asset paths are
|
||||||
# are all relative, no root-absolute references, so the prefix can
|
# all relative so the prefix can change without rebuild.
|
||||||
# change without rebuild.
|
|
||||||
bundle = pkgs.runCommand "hyperhive-options-docs" { } ''
|
bundle = pkgs.runCommand "hyperhive-options-docs" { } ''
|
||||||
mkdir -p $out
|
mkdir -p $out
|
||||||
cp ${indexHTML} $out/index.html
|
cp ${indexHTML} $out/index.html
|
||||||
|
|
|
||||||
|
|
@ -238,28 +238,9 @@ in
|
||||||
managerToplevel
|
managerToplevel
|
||||||
];
|
];
|
||||||
|
|
||||||
# Per-container web UIs share the host's network namespace and need
|
# Open the per-agent web-port range when the gateway is *off* —
|
||||||
# their ports reachable when there's no gateway in front. Every
|
# otherwise the gateway nginx is the sole external entry point.
|
||||||
# container — including the manager (#753 dropped the pre-#753
|
# See `docs/gateway.md::Firewall posture (host-level)`.
|
||||||
# "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.
|
|
||||||
networking.firewall = lib.mkIf (!config.services.hyperhive.gateway.enable) {
|
networking.firewall = lib.mkIf (!config.services.hyperhive.gateway.enable) {
|
||||||
allowedTCPPortRanges = [
|
allowedTCPPortRanges = [
|
||||||
{
|
{
|
||||||
|
|
@ -307,20 +288,11 @@ in
|
||||||
HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName;
|
HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName;
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs config.services.hyperhive.forge.enable {
|
// lib.optionalAttrs config.services.hyperhive.forge.enable {
|
||||||
# Agents poll this URL for Forgejo notifications + run all
|
# Loopback for in-cluster calls (agents share host netns;
|
||||||
# `hive-forge` calls against it. Pinned to `127.0.0.1` for
|
# external `forge.<hive>` sub-domain isn't DNS-resolvable
|
||||||
# the in-cluster path: every agent container shares the
|
# from inside nspawn). See
|
||||||
# host's network namespace so loopback reaches the forge
|
# `docs/gateway.md::HIVE_FORGE_URL: loopback for in-cluster,
|
||||||
# container directly, no DNS lookup needed (closes #761).
|
# sub-domain for the operator`.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
HIVE_FORGE_URL = "http://127.0.0.1:${toString config.services.hyperhive.forge.httpPort}";
|
HIVE_FORGE_URL = "http://127.0.0.1:${toString config.services.hyperhive.forge.httpPort}";
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs config.services.hyperhive.matrix.gui.enable {
|
// lib.optionalAttrs config.services.hyperhive.matrix.gui.enable {
|
||||||
|
|
|
||||||
|
|
@ -30,21 +30,10 @@ let
|
||||||
effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl;
|
effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# Private Forgejo for hyperhive agents, wrapped in a nixos-container
|
# Private Forgejo in a `hive-forge` nixos-container, shared host
|
||||||
# so it doesn't fight any `services.forgejo` the operator already
|
# netns so agents reach it on loopback. State at
|
||||||
# runs on the host. The container shares the host network namespace
|
# `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives
|
||||||
# (`privateNetwork = false`) so agents reach the forge at
|
# restart. See `docs/gateway.md::hive-forge container shape`.
|
||||||
# `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.
|
|
||||||
|
|
||||||
options.services.hyperhive.forge = {
|
options.services.hyperhive.forge = {
|
||||||
enable = lib.mkOption {
|
enable = lib.mkOption {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,56 @@ let
|
||||||
{ }
|
{ }
|
||||||
else
|
else
|
||||||
builtins.fromJSON (builtins.readFile cfg.agentPortsFile);
|
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
|
in
|
||||||
{
|
{
|
||||||
# Single nginx in front of every hyperhive web surface — dashboard,
|
# Single nginx in front of every hyperhive web surface — dashboard,
|
||||||
|
|
@ -268,8 +318,11 @@ in
|
||||||
# per entry in `agentPortsTable`. Trailing-slash pair
|
# per entry in `agentPortsTable`. Trailing-slash pair
|
||||||
# strips the prefix; `X-Forwarded-Prefix` lets the
|
# strips the prefix; `X-Forwarded-Prefix` lets the
|
||||||
# harness build absolute URLs when relative isn't
|
# harness build absolute URLs when relative isn't
|
||||||
# enough. See `docs/gateway.md` for the vhost map
|
# enough. `proxy_intercept_errors` + `error_page` rewrite
|
||||||
# + tuning rationale.
|
# 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: {
|
lib.mapAttrs' (name: port: {
|
||||||
name = "/agent/${name}/";
|
name = "/agent/${name}/";
|
||||||
value = {
|
value = {
|
||||||
|
|
@ -279,9 +332,44 @@ in
|
||||||
proxy_set_header X-Forwarded-Prefix /agent/${name};
|
proxy_set_header X-Forwarded-Prefix /agent/${name};
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_read_timeout 1d;
|
proxy_read_timeout 1d;
|
||||||
|
proxy_intercept_errors on;
|
||||||
|
error_page 502 503 504 = /__hive_agent_unreachable;
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
}) agentPortsTable
|
}) 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
|
# Everything else proxies to hive-c0re. Upgrade
|
||||||
# headers stay set so SSE (`/dashboard/stream`,
|
# headers stay set so SSE (`/dashboard/stream`,
|
||||||
|
|
|
||||||
|
|
@ -9,26 +9,16 @@ let
|
||||||
hyperhiveDomain = config.services.hyperhive.domain;
|
hyperhiveDomain = config.services.hyperhive.domain;
|
||||||
effectiveServerName = if cfg.serverName != null then cfg.serverName else hyperhiveDomain;
|
effectiveServerName = if cfg.serverName != null then cfg.serverName else hyperhiveDomain;
|
||||||
|
|
||||||
# Three files are missing from `pkgs.fluffychat-web` because
|
# fluffychat-web build fixes: nixpkgs's `flutter341.buildFlutterApplication`
|
||||||
# `flutter341.buildFlutterApplication` doesn't run the dart
|
# skips the dart web-worker compile + the emscripten native_imaging
|
||||||
# web-worker compile pass + doesn't run the native_imaging emscripten
|
# build. Two derivations below cover both. Full rationale (why
|
||||||
# build (#685). `fluffychat-web-imaging` below builds the latter from
|
# passthru.pubspecLock.dependencySources, why `dontConfigure`, why
|
||||||
# source via `pkgs.emscripten`; the worker compile is inline in
|
# `make -C js`, why build-CWD-relative dart path): docs/matrix.md::
|
||||||
# `fluffychat-web-fixed.postInstall`. Drop both when nixpkgs's
|
# fluffychat-web build fixes.
|
||||||
# flutter builder grows worker + emcc support upstream.
|
|
||||||
|
|
||||||
# `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 {
|
fluffychat-web-imaging = pkgs.stdenv.mkDerivation {
|
||||||
pname = "fluffychat-web-imaging";
|
pname = "fluffychat-web-imaging";
|
||||||
version = pkgs.fluffychat-web.passthru.pubspecLock.dependencyVersions.native_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;
|
src = pkgs.fluffychat-web.passthru.pubspecLock.dependencySources.native_imaging;
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
|
|
@ -38,21 +28,18 @@ let
|
||||||
jq
|
jq
|
||||||
];
|
];
|
||||||
|
|
||||||
# cmake config runs inside `js/Makefile` (via `emcmake cmake`) —
|
# cmake runs inside js/Makefile via `emcmake cmake`; the default
|
||||||
# skip the default `configurePhase` which would try to invoke
|
# configurePhase would invoke cmake at the package root (no
|
||||||
# cmake against the package root and fail (no CMakeLists at top).
|
# CMakeLists) and fail.
|
||||||
dontConfigure = true;
|
dontConfigure = true;
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
# emscripten needs HOME + a writable cache dir for its sysroot
|
# emscripten on-demand sysroot build needs writable HOME + cache.
|
||||||
# build (libc, libc++, etc. compiled to wasm on demand).
|
|
||||||
export HOME=$TMPDIR
|
export HOME=$TMPDIR
|
||||||
export EM_CACHE=$TMPDIR/.emscriptencache
|
export EM_CACHE=$TMPDIR/.emscriptencache
|
||||||
mkdir -p $EM_CACHE
|
mkdir -p $EM_CACHE
|
||||||
# `make -C js` keeps the build phase pwd at the source root so
|
# `make -C js` keeps pwd at source root for the installPhase.
|
||||||
# installPhase doesn't have to know about the cd (argus 🟡 on
|
|
||||||
# PR #697 v2 — robust against future reorders / `dontBuild`).
|
|
||||||
make -C js Imaging.js Imaging.wasm
|
make -C js Imaging.js Imaging.wasm
|
||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
@ -66,71 +53,37 @@ let
|
||||||
'';
|
'';
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
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";
|
homepage = "https://pub.dev/packages/native_imaging";
|
||||||
license = licenses.agpl3Plus;
|
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: {
|
fluffychat-web-fixed = pkgs.fluffychat-web.overrideAttrs (old: {
|
||||||
# `dart` from the flutter341 closure (already pulled, no
|
# dart from the flutter341 closure (already pulled, no incremental
|
||||||
# incremental closure cost) so we can compile the web-worker
|
# cost) to compile the web-worker entry point.
|
||||||
# entry point that buildFlutterApplication skips.
|
|
||||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.flutter341.dart ];
|
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.flutter341.dart ];
|
||||||
|
|
||||||
postInstall =
|
postInstall = (old.postInstall or "") + ''
|
||||||
(old.postInstall or "")
|
# `web/...` is BUILD-CWD-relative (not `$src/...`) so dart's
|
||||||
+ ''
|
# package_config walk-up hits buildFlutterApplication's
|
||||||
# `web/...` is relative to build CWD so dart's package_config
|
# pub-get output `.dart_tool/`.
|
||||||
# walk-up hits buildFlutterApplication's pub-get output (#685
|
${pkgs.flutter341.dart}/bin/dart compile js \
|
||||||
# / #733 fixup — `$src/web/...` would walk up to a read-only
|
-o $out/native_executor.js \
|
||||||
# store path with no `.dart_tool/`).
|
web/native_executor.dart
|
||||||
${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.js $out/Imaging.js
|
||||||
install -m 644 ${fluffychat-web-imaging}/Imaging.wasm $out/Imaging.wasm
|
install -m 644 ${fluffychat-web-imaging}/Imaging.wasm $out/Imaging.wasm
|
||||||
'';
|
'';
|
||||||
});
|
});
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# Private Matrix homeserver (matrix-tuwunel — the official conduwuit
|
# Private matrix-tuwunel homeserver wrapped in a nixos-container,
|
||||||
# successor) for hyperhive agents, wrapped in a nixos-container so it
|
# optional fluffychat-web client at matrix.<hive>/. Container shape,
|
||||||
# doesn't fight any existing `services.matrix-*` the operator may
|
# serverName vs gatewayHost split, provisioning flow (registration
|
||||||
# already run on the host. Same shape as `nix/modules/hive-forge.nix`:
|
# token + LoadCredential), assertion rationale, initial rollout
|
||||||
# shared host netns (`privateNetwork = false`) so agents reach it at
|
# settings: docs/matrix.md. Vhost map + discovery flow + tuning
|
||||||
# `http://localhost:<httpPort>` (or via the configured server_name
|
# knobs: docs/gateway.md.
|
||||||
# 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.
|
|
||||||
|
|
||||||
options.services.hyperhive.matrix = {
|
options.services.hyperhive.matrix = {
|
||||||
enable = lib.mkOption {
|
enable = lib.mkOption {
|
||||||
|
|
@ -322,12 +275,10 @@ in
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
# mara on #548: "there is no default, but it is required. add
|
# serverName must exist (mara on #548 — irrevocably embedded in
|
||||||
# assertion." — fail eval with a helpful message rather than
|
# user/room IDs); gatewayHost may not be "" (argus 🟡 on #764 —
|
||||||
# spawning a homeserver with a bogus server_name we can never
|
# same footgun as forge.domain). docs/matrix.md::Assertion
|
||||||
# change later. `services.hyperhive.domain` is host-wide; matrix derives
|
# rationale.
|
||||||
# the server_name from it (or from `cfg.serverName` if the
|
|
||||||
# operator wants to override).
|
|
||||||
assertions = [
|
assertions = [
|
||||||
{
|
{
|
||||||
assertion = hyperhiveDomain != null || cfg.serverName != null;
|
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 != "";
|
assertion = cfg.gatewayHost == null || cfg.gatewayHost != "";
|
||||||
message = ''
|
message = ''
|
||||||
services.hyperhive.matrix.gatewayHost = "" is rejected. The
|
services.hyperhive.matrix.gatewayHost = "" is rejected. The
|
||||||
|
|
@ -361,22 +307,10 @@ in
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
# Generate the registration token at system activation time, BEFORE
|
# Activation-time token generation (argus #565: the bind-mount
|
||||||
# the hive-matrix container would otherwise start with an empty
|
# would otherwise hand tuwunel an empty file on first boot and
|
||||||
# bind-mount target (argus nit on #565: nspawn creates an empty
|
# break every registration until restart). Idempotent;
|
||||||
# file when the host path is missing, tuwunel reads it as
|
# docs/matrix.md::Provisioning flow.
|
||||||
# `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).
|
|
||||||
system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] ''
|
system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] ''
|
||||||
tokenFile=${lib.escapeShellArg (toString cfg.registrationTokenFile)}
|
tokenFile=${lib.escapeShellArg (toString cfg.registrationTokenFile)}
|
||||||
if [ ! -s "$tokenFile" ]; then
|
if [ ! -s "$tokenFile" ]; then
|
||||||
|
|
@ -385,25 +319,17 @@ in
|
||||||
echo >> "$tokenFile"
|
echo >> "$tokenFile"
|
||||||
echo "hive-matrix: generated registration token at $tokenFile"
|
echo "hive-matrix: generated registration token at $tokenFile"
|
||||||
fi
|
fi
|
||||||
# Always re-apply 0600 (idempotent on already-correct files;
|
# Re-apply 0600 (normalises any pre-LoadCredential carry-over).
|
||||||
# also normalises any 0640 / world-readable carry-over from
|
|
||||||
# pre-LoadCredential deployments).
|
|
||||||
chmod 0600 "$tokenFile"
|
chmod 0600 "$tokenFile"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
containers.hive-matrix = {
|
containers.hive-matrix = {
|
||||||
autoStart = true;
|
autoStart = true;
|
||||||
ephemeral = false;
|
ephemeral = false;
|
||||||
# Share host netns — tuwunel's listeners look exactly like
|
# Shared host netns — agents reach tuwunel at localhost:<port>.
|
||||||
# host-side services, no port-forward plumbing, and agent
|
|
||||||
# containers (also host netns) reach it via plain `localhost`.
|
|
||||||
privateNetwork = false;
|
privateNetwork = false;
|
||||||
# Read-only bind of the host-managed registration token so
|
# Read-only bind of the host-managed registration token; tuwunel
|
||||||
# tuwunel can resolve `registration_token_file` to a real
|
# reads it via systemd LoadCredential below (not directly).
|
||||||
# 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).
|
|
||||||
bindMounts.${cfg.registrationTokenFile} = {
|
bindMounts.${cfg.registrationTokenFile} = {
|
||||||
hostPath = cfg.registrationTokenFile;
|
hostPath = cfg.registrationTokenFile;
|
||||||
isReadOnly = true;
|
isReadOnly = true;
|
||||||
|
|
@ -417,44 +343,28 @@ in
|
||||||
package = cfg.package;
|
package = cfg.package;
|
||||||
settings.global = {
|
settings.global = {
|
||||||
server_name = effectiveServerName;
|
server_name = effectiveServerName;
|
||||||
# `address` is `listOf nonEmptyStr` upstream (multi-bind
|
# `address` + `port` are upstream `listOf` — wrap singles.
|
||||||
# support). Single-host bind goes through as a one-element list.
|
|
||||||
address = [ "0.0.0.0" ];
|
address = [ "0.0.0.0" ];
|
||||||
# `port` is `listOf port` upstream. Same shape.
|
|
||||||
port = [ cfg.httpPort ];
|
port = [ cfg.httpPort ];
|
||||||
max_request_size = cfg.maxRequestSize;
|
max_request_size = cfg.maxRequestSize;
|
||||||
# Federation enabled at the protocol level so swarms
|
# Federation enabled at the protocol level; empty
|
||||||
# can be wired up later by extending `trustedServers`
|
# trustedServers keeps it effectively closed.
|
||||||
# without a homeserver restart. Empty trusted_servers
|
|
||||||
# keeps it effectively closed until peers are listed.
|
|
||||||
allow_federation = true;
|
allow_federation = true;
|
||||||
trusted_servers = cfg.trustedServers;
|
trusted_servers = cfg.trustedServers;
|
||||||
# Token-gated registration: hive-c0re holds the token,
|
# Token-gated registration. The absent
|
||||||
# agents never see it. allow_registration must be true
|
|
||||||
# for the token flow to engage; the absent
|
|
||||||
# `yes_i_am_very_very_sure_…_open_registration_…` flag
|
# `yes_i_am_very_very_sure_…_open_registration_…` flag
|
||||||
# keeps the server closed to anyone without the token.
|
# keeps the server closed to anyone without the token.
|
||||||
allow_registration = true;
|
allow_registration = true;
|
||||||
# Read the registration token via systemd's
|
# LoadCredential below copies the host file into a
|
||||||
# `LoadCredential=` mechanism (wired below) instead of
|
# 0400 dynamic-user-owned path; tuwunel reads from there.
|
||||||
# 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).
|
|
||||||
registration_token_file = "/run/credentials/tuwunel.service/registration_token";
|
registration_token_file = "/run/credentials/tuwunel.service/registration_token";
|
||||||
# E2EE disabled in initial rollout per operator call
|
# E2EE disabled in initial rollout (#548); re-enable at #551.
|
||||||
# (#548) — re-enabling tracked at #551.
|
|
||||||
allow_encryption = false;
|
allow_encryption = false;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
# `LoadCredential=<id>:<host-path>` makes systemd copy the
|
# Keeps DynamicUser=true + PrivateUsers=true intact — no
|
||||||
# bind-mounted host file into `/run/credentials/tuwunel.service/<id>`
|
# host-side chown :tuwunel / GID-pin gymnastics needed (#644 /
|
||||||
# owned by the service's (dynamic) user with mode 0400 at
|
# iris on 8043). See `man systemd.exec` → LoadCredential.
|
||||||
# service start. The hardcoded path in `registration_token_file`
|
|
||||||
# above is the systemd-stable credentials dir; see
|
|
||||||
# `man systemd.exec` → LoadCredential.
|
|
||||||
systemd.services.tuwunel.serviceConfig.LoadCredential = [
|
systemd.services.tuwunel.serviceConfig.LoadCredential = [
|
||||||
"registration_token:${toString cfg.registrationTokenFile}"
|
"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 {
|
options.hyperhive.role = lib.mkOption {
|
||||||
type = lib.types.enum [
|
type = lib.types.enum [
|
||||||
"agent"
|
"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 {
|
options.hyperhive.autoCompact = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = true;
|
default = true;
|
||||||
|
|
@ -656,46 +748,20 @@ in
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
# First-boot migration from the legacy root-run shape (#658).
|
# Post-#658 first-boot migration to the per-agent unix user —
|
||||||
# Runs on every activation; marker-guarded so the move only
|
# creates the home dir, chowns the bind-mounted state +
|
||||||
# happens once. The bind mount that hive-c0re sets up has
|
# `~/.claude/`, and (marker-guarded) moves any leftover
|
||||||
# already moved from `/root/.claude` to `${homeDir}/.claude`
|
# `/root/.claude` content from the pre-#658 root-run shape. See
|
||||||
# by the time we get here (per `lifecycle::CONTAINER_CLAUDE_MOUNT`
|
# `docs/persistence.md::First-boot agent-user migration` for the
|
||||||
# — the host-side path stays the same, the container-side
|
# step-by-step rationale; this script implements it.
|
||||||
# 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.
|
|
||||||
system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] ''
|
system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] ''
|
||||||
homeDir=${lib.escapeShellArg homeDir}
|
homeDir=${lib.escapeShellArg homeDir}
|
||||||
userName=${lib.escapeShellArg userName}
|
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"
|
mkdir -p "$homeDir"
|
||||||
chown "$userName:$userName" "$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
|
marker=/var/lib/hive-agent-user-migrated
|
||||||
if [ ! -e "$marker" ] && [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then
|
if [ ! -e "$marker" ] && [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then
|
||||||
mkdir -p "$homeDir/.claude"
|
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
|
if cp -an /root/.claude/. "$homeDir/.claude/" 2>/dev/null; then
|
||||||
rm -rf /root/.claude
|
rm -rf /root/.claude
|
||||||
echo "hive-agent-user-migrate: moved /root/.claude → $homeDir/.claude"
|
echo "hive-agent-user-migrate: moved /root/.claude → $homeDir/.claude"
|
||||||
|
|
@ -703,25 +769,10 @@ in
|
||||||
fi
|
fi
|
||||||
mkdir -p "$(dirname "$marker")"
|
mkdir -p "$(dirname "$marker")"
|
||||||
: > "$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
|
for stateDir in /agents/*/state; do
|
||||||
[ -d "$stateDir" ] || continue
|
[ -d "$stateDir" ] || continue
|
||||||
chown -hR "$userName:$userName" "$stateDir" 2>/dev/null || true
|
chown -hR "$userName:$userName" "$stateDir" 2>/dev/null || true
|
||||||
done
|
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
|
if [ -d "$homeDir/.claude" ]; then
|
||||||
chown -hR "$userName:$userName" "$homeDir/.claude" 2>/dev/null || true
|
chown -hR "$userName:$userName" "$homeDir/.claude" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
@ -753,6 +804,55 @@ in
|
||||||
source = config.hyperhive.icon;
|
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 =
|
environment.etc."hyperhive/bash-allow.json".text =
|
||||||
builtins.toJSON config.hyperhive.allowedBashPatterns;
|
builtins.toJSON config.hyperhive.allowedBashPatterns;
|
||||||
|
|
||||||
|
|
@ -814,8 +914,28 @@ in
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs (config.hyperhive.forge.skipNotifyReasons != [ ]) {
|
// lib.optionalAttrs (config.hyperhive.forge.skipNotifyReasons != [ ]) {
|
||||||
HIVE_FORGE_NOTIFY_SKIP_REASONS = lib.concatStringsSep "," 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;
|
boot.isNspawnContainer = true;
|
||||||
|
|
||||||
# Every agent gets flakes + the modern `nix` CLI out of the box.
|
# Every agent gets flakes + the modern `nix` CLI out of the box.
|
||||||
|
|
@ -829,15 +949,11 @@ in
|
||||||
"flakes"
|
"flakes"
|
||||||
];
|
];
|
||||||
|
|
||||||
# Containers bind-mount the host's nix-daemon socket. The host daemon
|
# `lib.mkForce` overrides nixpkgs's normal-priority `false` so
|
||||||
# may be configured with remote builders or strict sandbox settings
|
# in-container `nix build` invocations fall back to unsandboxed
|
||||||
# (sandbox-fallback = false) that make local `nix build` invocations
|
# local builds rather than failing on the missing user-namespace.
|
||||||
# fail inside the container. Enable sandbox-fallback so builds that
|
# See `docs/gotchas.md::Containerized nix-daemon needs
|
||||||
# can't set up the sandbox (no user-namespaces in nspawn) fall back
|
# sandbox-fallback = true` + `docs/security.md` for the rationale.
|
||||||
# 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.
|
|
||||||
nix.settings.sandbox-fallback = lib.mkForce true;
|
nix.settings.sandbox-fallback = lib.mkForce true;
|
||||||
|
|
||||||
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
# `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).
|
# Long-running matrix-sdk client + sync per agent. Holds the unix
|
||||||
# Holds the unix socket the stdio `hive-matrix-mcp` bridge talks
|
# socket the stdio `hive-matrix-mcp` bridge connects to + emits
|
||||||
# to, and emits hyperhive wake signals on incoming room events
|
# hyperhive wake signals on incoming room events via
|
||||||
# via `/run/hive/mcp.sock`. Conditional on `hyperhive.matrix.enable`
|
# `/run/hive/mcp.sock`. See
|
||||||
# AND token-file presence (the daemon binary itself exits 0 on
|
# `docs/persistence.md::Matrix per-agent daemon + token-arrival
|
||||||
# missing token, but the path watcher below restarts it the
|
# trigger` for the socket-path / first-boot-ordering rationale.
|
||||||
# moment the token lands — same first-boot-ordering pattern as
|
|
||||||
# matrix-avatar-sync.path / #571).
|
|
||||||
systemd.services.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable {
|
systemd.services.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable {
|
||||||
description = "long-running matrix-sdk Client + MCP daemon socket";
|
description = "long-running matrix-sdk Client + MCP daemon socket";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
@ -1020,12 +1134,6 @@ in
|
||||||
wants = [ "network-online.target" ];
|
wants = [ "network-online.target" ];
|
||||||
environment = {
|
environment = {
|
||||||
HIVE_MATRIX_URL = config.hyperhive.matrix.url;
|
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";
|
HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket";
|
||||||
RUST_LOG = "info";
|
RUST_LOG = "info";
|
||||||
};
|
};
|
||||||
|
|
@ -1033,26 +1141,17 @@ in
|
||||||
ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon";
|
ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 5;
|
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;
|
User = userName;
|
||||||
Group = userName;
|
Group = userName;
|
||||||
RuntimeDirectory = "hive-matrix";
|
RuntimeDirectory = "hive-matrix";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Path-trigger sibling so hive-matrix-daemon fires the moment
|
# Re-fire the daemon when the matrix token appears (hive-c0re
|
||||||
# `<state>/matrix-token` appears (#548 phase 3, mirrors the
|
# provisions it after agent containers come up). Without this
|
||||||
# matrix-avatar-sync.path pattern from #571). On clean boot
|
# the daemon would exit 0 silently on first boot and the MCP
|
||||||
# hive-c0re provisions the token AFTER agent containers come up;
|
# would have no backend until next restart. See
|
||||||
# without the trigger the daemon would exit 0 quietly and the
|
# `docs/persistence.md` (same section as above).
|
||||||
# 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).
|
|
||||||
systemd.paths.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable {
|
systemd.paths.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable {
|
||||||
description = "trigger hive-matrix-daemon when matrix-token appears";
|
description = "trigger hive-matrix-daemon when matrix-token appears";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
@ -1217,12 +1316,10 @@ in
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Manager-only forge defaults (#671): skip the
|
# Manager-only forge defaults: subscription/participation
|
||||||
# subscription/participation firehose so the manager's inbox
|
# firehose stays off so the manager's inbox isn't drowned in
|
||||||
# only carries direct mentions, reviews, and assignments. Sub-
|
# noise. Full rationale + sub-agent contrast:
|
||||||
# agents keep the noisier defaults (`keepSubscriptions = true`,
|
# docs/agent-hierarchy.md::Manager-only defaults.
|
||||||
# `skipNotifyReasons = [ ]`). `mkDefault` so any agent that
|
|
||||||
# wants to invert it can.
|
|
||||||
hyperhive.forge = lib.mkIf (config.hyperhive.role == "manager") {
|
hyperhive.forge = lib.mkIf (config.hyperhive.role == "manager") {
|
||||||
keepSubscriptions = lib.mkDefault false;
|
keepSubscriptions = lib.mkDefault false;
|
||||||
skipNotifyReasons = lib.mkDefault [
|
skipNotifyReasons = lib.mkDefault [
|
||||||
|
|
@ -1231,91 +1328,49 @@ in
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
# Harness systemd unit. Role-driven so the same `harness-base.nix`
|
# Role-driven harness systemd unit: one binary, two unit names
|
||||||
# covers both `nixosConfigurations.agent-base` (`hive-ag3nt serve`)
|
# for log/ExecStartPre stability. Unit shape (PATH wrapper-dir
|
||||||
# and `nixosConfigurations.manager` (`hive-m1nd serve`) without a
|
# trick, env vars, RuntimeDirectory, User=, standalone-eval
|
||||||
# second template file (#671). Per-agent HIVE_PORT / HIVE_LABEL
|
# fallbacks): docs/agent-hierarchy.md::Harness systemd unit
|
||||||
# come from the meta-flake's generated `applied/<name>/flake.nix`;
|
# shape (per-role). PATH /bin auto-append behaviour:
|
||||||
# the manager has hardcoded fallbacks here so `nixosConfigurations.manager`
|
# docs/gotchas.md::systemd.services.*.path appends /bin to
|
||||||
# still builds standalone.
|
# every entry.
|
||||||
systemd.services.${if config.hyperhive.role == "manager" then "hive-m1nd" else "hive-ag3nt"} =
|
systemd.services.${if config.hyperhive.role == "manager" then "hive-m1nd" else "hive-ag3nt"} =
|
||||||
let
|
let
|
||||||
isManager = config.hyperhive.role == "manager";
|
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";
|
binary = "hive";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
description = "${binary}${lib.optionalString isManager " manager"} harness";
|
description = "${binary}${lib.optionalString isManager " manager"} harness";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
after = [ "network.target" ];
|
after = [ "network.target" ];
|
||||||
# systemd units get a minimal PATH by default and don't inherit
|
# `/run/wrappers` before `/run/current-system/sw` so setuid
|
||||||
# `environment.systemPackages`. Pointing at `/run/current-system/sw`
|
# `sudo` resolves first. Passing the bare prefixes (no trailing
|
||||||
# gives the harness (and any tools claude shells out to via Bash)
|
# `/bin`) is intentional — see docs pointer above.
|
||||||
# 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.
|
|
||||||
path = [
|
path = [
|
||||||
"/run/wrappers"
|
"/run/wrappers"
|
||||||
"/run/current-system/sw"
|
"/run/current-system/sw"
|
||||||
];
|
];
|
||||||
environment = {
|
environment = {
|
||||||
SHELL = "${pkgs.bashInteractive}/bin/bash";
|
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;
|
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}";
|
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";
|
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;
|
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 {
|
// lib.optionalAttrs isManager {
|
||||||
# Standalone-eval fallbacks for `nixosConfigurations.manager`.
|
# Standalone-eval fallbacks; meta.rs overrides at deploy time.
|
||||||
# meta.rs overrides both via the per-agent generated
|
# HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100.
|
||||||
# `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.
|
|
||||||
HIVE_PORT = "8875";
|
HIVE_PORT = "8875";
|
||||||
HIVE_LABEL = "hm1nd";
|
HIVE_LABEL = "hm1nd";
|
||||||
};
|
};
|
||||||
|
|
@ -1323,20 +1378,11 @@ in
|
||||||
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
|
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 2;
|
RestartSec = 2;
|
||||||
# `/run/hive-config/` is a per-service runtime dir owned by
|
# Per-service runtime dir owned by `User=` below; the harness
|
||||||
# the agent user (`User=` below), auto-cleared by systemd on
|
# writes its regenerated claude-{mcp-config,settings,system-prompt}
|
||||||
# stop. The harness writes its regenerated
|
# files here (`paths::config_dir`). Separate from /run/hive,
|
||||||
# claude-{mcp-config,settings,system-prompt} files there
|
# which holds hive-c0re's mcp.sock.
|
||||||
# (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).
|
|
||||||
RuntimeDirectory = "hive-config";
|
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;
|
User = userName;
|
||||||
Group = userName;
|
Group = userName;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,66 +5,41 @@
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
# Optional Weston (the reference Wayland compositor) with the VNC
|
# Optional Weston (Wayland compositor) with the VNC backend,
|
||||||
# backend, surfaced as a per-agent hyperhive option. An agent turns
|
# surfaced as a per-agent `hyperhive.gui.enable` option. Imported
|
||||||
# it on from its own `agent.nix`:
|
# 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;
|
# Port allocation, weston bind-address quirk, PAM service name, the
|
||||||
#
|
# Type=simple choice, idle-time=0: all in
|
||||||
# Imported by `harness-base.nix`, so every sub-agent + the manager
|
# docs/gotchas.md::Weston VNC compositor.
|
||||||
# has the option available; only those that flip it on get the
|
# Harness-side WebSocket relay shape: docs/web-ui.md::Per-agent
|
||||||
# service. This is a flat per-agent option (evaluated inside that
|
# endpoints (`/screen` + `/screen/ws`).
|
||||||
# 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.
|
|
||||||
|
|
||||||
options.hyperhive.gui.enable = lib.mkOption {
|
options.hyperhive.gui.enable = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
description = ''
|
description = ''
|
||||||
Run Weston with the VNC backend as a systemd service, for
|
Run Weston with the VNC backend as a systemd service, for
|
||||||
in-browser GUI access via the harness WebSocket relay (see
|
in-browser GUI access via the harness `/screen/ws` WebSocket
|
||||||
issue #51). Renders in software (pixman) — no GPU, DRM,
|
relay. Renders in software (pixman) — no GPU, DRM, or VT
|
||||||
or VT access, so no extra container capabilities are needed.
|
access, so no extra container capabilities are needed.
|
||||||
|
|
||||||
The VNC port is deterministic: FNV-1a hash of the agent name
|
The VNC port is a deterministic FNV-1a hash of the agent name
|
||||||
(taken from the container hostname) mapped into [15900, 16799].
|
mapped into `[15900, 16799]`, written to
|
||||||
The port and auth mode are written to `/etc/hyperhive/gui.json`
|
`/etc/hyperhive/gui.json` at service start so the harness can
|
||||||
at service start so the harness can relay connections.
|
relay connections without a separate config flag. The unit is
|
||||||
|
`Type = "simple"` so a misconfigured weston degrades to a
|
||||||
The unit is deliberately built so enabling it can NEVER abort
|
restart loop instead of blocking `nixos-container update`.
|
||||||
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`.)
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf config.hyperhive.gui.enable {
|
config = lib.mkIf config.hyperhive.gui.enable {
|
||||||
# neatvnc 0.9 always calls the PAM auth callback (weston_authenticate_user)
|
# neatvnc ≥ 0.9 always calls the PAM auth callback for Apple-DH
|
||||||
# for Apple-DH (type 30), regardless of weston.ini auth-method=none.
|
# (type 30), regardless of weston.ini auth-method=none.
|
||||||
# pam_permit.so makes the PAM service accept any credentials so the
|
# pam_permit.so accepts the browser's empty Apple-DH credentials.
|
||||||
# browser's empty Apple-DH credentials always pass.
|
# Service name MUST be the literal `weston-remote-access` — that's
|
||||||
#
|
# the string libweston passes to pam_start() in libweston/auth.c.
|
||||||
# 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.
|
|
||||||
security.pam.services."weston-remote-access".text = ''
|
security.pam.services."weston-remote-access".text = ''
|
||||||
auth sufficient pam_permit.so
|
auth sufficient pam_permit.so
|
||||||
account sufficient pam_permit.so
|
account sufficient pam_permit.so
|
||||||
|
|
@ -76,29 +51,21 @@
|
||||||
after = [ "network.target" ];
|
after = [ "network.target" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
serviceConfig = {
|
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";
|
Type = "simple";
|
||||||
# Creates /var/lib/weston (0700 root) at start.
|
|
||||||
StateDirectory = "weston";
|
StateDirectory = "weston";
|
||||||
Environment = "XDG_RUNTIME_DIR=/run/user/0";
|
Environment = "XDG_RUNTIME_DIR=/run/user/0";
|
||||||
# Wrapper script: computes the deterministic VNC port, writes
|
# Wrapper script: computes the deterministic VNC port, writes
|
||||||
# /etc/hyperhive/gui.json for the harness (issue #51), then
|
# /etc/hyperhive/gui.json for the harness, then execs weston.
|
||||||
# execs weston. Using `exec` keeps the PID stable so systemd
|
# `exec` keeps the PID stable so systemd tracks the weston
|
||||||
# tracks the weston process correctly under Type=simple.
|
# process correctly under Type=simple.
|
||||||
# Any failure before the exec triggers Restart=on-failure
|
|
||||||
# (graceful degradation) rather than blocking the rebuild.
|
|
||||||
ExecStart = pkgs.writeShellScript "weston-vnc" ''
|
ExecStart = pkgs.writeShellScript "weston-vnc" ''
|
||||||
mkdir -p /run/user/0 && chmod 700 /run/user/0 || true
|
mkdir -p /run/user/0 && chmod 700 /run/user/0 || true
|
||||||
|
|
||||||
# --- Compute deterministic VNC port via FNV-1a ---
|
# --- Compute deterministic VNC port via FNV-1a ---
|
||||||
# Agent name = container hostname with leading "h-" stripped,
|
# Agent name = container hostname with leading `h-` stripped.
|
||||||
# mirroring lifecycle::agent_web_port in hive-c0re/src/lifecycle.rs.
|
|
||||||
# Read from /etc/hostname (always present in NixOS containers)
|
# Read from /etc/hostname (always present in NixOS containers)
|
||||||
# to avoid a dependency on the `hostname` binary (which lives in
|
# to avoid depending on `hostname` (lives in pkgs.inetutils,
|
||||||
# pkgs.inetutils, not pkgs.coreutils).
|
# not pkgs.coreutils).
|
||||||
# VNC_PORT_BASE=15900, VNC_PORT_RANGE=900 → [15900, 16799].
|
|
||||||
RAW_HOST=$(${pkgs.coreutils}/bin/cat /etc/hostname)
|
RAW_HOST=$(${pkgs.coreutils}/bin/cat /etc/hostname)
|
||||||
AGENT_NAME=$(${pkgs.coreutils}/bin/printf '%s' "$RAW_HOST" \
|
AGENT_NAME=$(${pkgs.coreutils}/bin/printf '%s' "$RAW_HOST" \
|
||||||
| ${pkgs.gnused}/bin/sed 's/^h-//')
|
| ${pkgs.gnused}/bin/sed 's/^h-//')
|
||||||
|
|
@ -111,30 +78,15 @@
|
||||||
done
|
done
|
||||||
VNC_PORT=$((15900 + hash % 900))
|
VNC_PORT=$((15900 + hash % 900))
|
||||||
|
|
||||||
# --- Write gui.json marker ---
|
# Marker file the harness reads at startup.
|
||||||
# The harness reads this at startup (issue #51) to know the
|
|
||||||
# VNC port and auth mode for the WebSocket relay.
|
|
||||||
${pkgs.coreutils}/bin/mkdir -p /etc/hyperhive
|
${pkgs.coreutils}/bin/mkdir -p /etc/hyperhive
|
||||||
${pkgs.coreutils}/bin/printf '{"vnc_port":%d,"auth":"none"}\n' \
|
${pkgs.coreutils}/bin/printf '{"vnc_port":%d,"auth":"none"}\n' \
|
||||||
"$VNC_PORT" > /etc/hyperhive/gui.json || true
|
"$VNC_PORT" > /etc/hyperhive/gui.json || true
|
||||||
|
|
||||||
# neatvnc ≥ 0.9 advertises RSA-AES and Apple-DH security types
|
# --disable-transport-layer-security: skips the VeNCrypt TLS
|
||||||
# when auth is compiled in. The browser client handles Apple-DH
|
# wrapper so plain auth types (incl. Apple-DH type 30) are
|
||||||
# (type 30) with empty credentials.
|
# advertised directly. [core] idle-time=0 disables the
|
||||||
#
|
# compositor's 300s idle/lock screen.
|
||||||
# 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.
|
|
||||||
WESTON_INI=$(${pkgs.coreutils}/bin/mktemp /tmp/weston-XXXXXX.ini)
|
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"
|
${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
|
# weston on the agent's interactive PATH so claude can run Wayland
|
||||||
# Wayland clients / `weston-info` against the compositor.
|
# clients / weston-info against the compositor.
|
||||||
environment.systemPackages = [ pkgs.weston ];
|
environment.systemPackages = [ pkgs.weston ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue