Compare commits

...
233 changed files with 46845 additions and 6426 deletions

8
.gitignore vendored
View file

@ -1,3 +1,5 @@
.directory
result
secrets
/target
/result
/result-*
/.tmp
/.claude/settings.local.json

243
CLAUDE.md Normal file
View file

@ -0,0 +1,243 @@
# hyperhive — claude entry point
Hey claude. This is your starting page. The detailed docs live in
[`docs/`](docs/) and are written for humans + you both — read them
when you need depth on a subsystem. This file is the index.
- High-level project intro: **[README.md](README.md)**.
- Open work + backlog: the **[forge issue
tracker](http://localhost:3000/hyperhive/hyperhive/issues)**.
- Operator/agent trust-boundary design:
**[docs/boundary.md](docs/boundary.md)** (`area:ops` issues
for the deployment/gateway/privsep work).
- Credential isolation + sandbox threat model:
**[docs/security.md](docs/security.md)**.
## File map
```
hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched)
src/main.rs clap setup; serve / spawn / kill / rebuild / list /
pending / approve / deny / destroy [--purge] /
request-spawn / set-parent (--parent / --root);
periodic vacuum tasks
src/server.rs host admin socket (HostRequest → dispatch)
src/client.rs admin-socket client
src/manager_server.rs manager-privileged socket (ManagerRequest)
src/agent_server.rs per-sub-agent socket listener (long-poll Recv)
src/broker.rs sqlite Message store + intra-process broadcast
channel (`MessageEvent`) for `recv_blocking_batch` +
the dashboard forwarder; hourly vacuum of acked>30d
src/dashboard_events.rs unified wire-facing event channel feeding
`/dashboard/stream`. Carries broker `Sent` /
`Delivered` (mirrored by the forwarder task
in main.rs) + mutation events
(`ApprovalAdded` / `ApprovalResolved`,
`QuestionAdded` / `QuestionResolved`,
`TransientSet` / `TransientCleared`,
`RebuildQueueChanged`). Each frame carries a
monotonic per-process `seq` clients use to
dedupe against snapshot reads.
src/approvals.rs sqlite Approval queue + kinds
src/operator_questions.rs sqlite question queue backing `ask` /
`answer` (both operator + agent-to-agent)
src/questions.rs shared dispatch for `Ask` / `Answer`
used by both agent + manager surfaces
src/reminder_scheduler.rs 5s poll loop: drains due reminders,
resolves file_path container→host, persists
payload + delivers pointer string
src/scheduled_prompts.rs sqlite layer for scheduled prompts —
schema, CRUD helpers (`cancel_all`,
`cancel_targets`, `reap_cancelled`), catch-up
clamp on resume (#444)
src/scheduled_prompts_worker.rs 5s poll loop: fires due rows,
fans out one Message per active target,
re-arms recurring rows, deletes fired one-shots
src/events_vacuum.rs host-side hourly sweep of every agent's
/state/hyperhive-events.sqlite
src/crash_watch.rs poll every 10s; fire HelperEvent::ContainerCrash
when a previously-running container disappears
without an operator-initiated transient (or a
RECENT_TRANSIENT_GRACE tombstone within the
last 30s, closes #425)
src/container_view.rs ContainerView struct + build_all helper;
shared between dashboard.rs (cold-load via
/api/state) and coordinator.rs's
rescan_containers_and_emit
src/coordinator.rs shared state (broker/approvals/operator_questions/
transient/sockets) + tombstone enumeration +
kick_agent + notify_agent (helper-event push) +
last_containers cache + rescan_and_emit diff helper
src/loose_ends.rs loose-ends aggregator (pending approvals +
unanswered questions + pending reminders) —
for_agent (filtered) and hive_wide (manager
surface). Backs AgentRequest::GetLooseEnds +
ManagerRequest::GetLooseEnds (the
get_loose_ends MCP tool).
src/rebuild_queue.rs global serialised queue for long-running ops
(rebuild / meta_update / spawn / destroy).
Single background worker drains FIFO; dedup
collapses re-enqueued still-queued entries.
`QueueEntry` carries id, agent, kind, state,
source, parent_id (cascade grouping), timing,
reason, error, step (current phase label while
Running; cleared on finish). Emits
`RebuildQueueChanged` snapshots on every
mutation.
src/actions.rs approve/deny/destroy (transient-aware)
src/auto_update.rs startup rebuild scan + ensure_manager +
meta::lock_update_hyperhive bump
src/lifecycle.rs `nixos-container` shellouts; per-agent applied
+ proposed git repo seeding; tag plumbing
src/meta.rs single hive-c0re-owned flake at /var/lib/
hyperhive/meta/ — sync_agents, two-phase
prepare/finalize/abort, lock_update_*
src/migrate.rs startup auto-migration from pre-meta layout
(idempotent, marker-guarded phase 4)
src/topology.rs agent parent/child storage at
/var/lib/hyperhive/meta/topology.json — sole
source of truth for who's the parent of whom
(single source the dashboard, render_flake,
and the eventual cap-enforcement plumbing all
read). Reconciled by `meta::sync_agents`;
operator/manager edits land via the
eventual write API (#361 follow-ups).
src/forge.rs optional Forgejo wiring: per-agent users +
tokens, the `agent-configs` org (`push_config`),
and meta read access; mirrors each applied repo
into `agent-configs/<n>` (core-only); agents are
read-only collaborators on `core/meta`
src/dashboard.rs axum HTTP: /api/state JSON + actions
+ journald viewer + bind-with-retry (SO_REUSEADDR)
+ deployed_sha chip per container +
/dashboard/{stream,history} subscribing to the
unified DashboardEvent channel. Static assets
(HTML/CSS/JS/favicon) served by
tower_http::ServeDir from $HIVE_STATIC_DIR
(= `${frontend}/dashboard` per the c0re module).
frontend/ npm workspaces (esbuild → static dist). Built
hermetically by `nix/frontend.nix`
(`packages.${system}.frontend`).
packages/shared/ @hive/shared: terminal pane + Catppuccin palette
+ base typography (was hive-fr0nt). ES module
exporting { create, linkify }; pure JS, no IIFE
globals; consumed by dashboard + agent.
packages/dashboard/ @hive/dashboard SPA: src/{index.html, tabs.js,
flow.html, flow.js, common.js, dashboard.css} +
build.mjs → dist/{index.html, flow.html,
static/{tabs.js, flow.js, dashboard.css,
stream-worker.js}}.
packages/agent/ @hive/agent default per-container UI: src/
{index, stats, screen}.html + {app, stats}.js
+ agent.css → dist/{*.html, static/*}.
hive-ag3nt/ in-container harness crate; produces TWO binaries
src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT
src/client.rs generic JSON-line request/response over unix socket
src/web_ui.rs per-container axum HTTP page (incl /api/cancel,
/api/compact, /api/model, /events/history,
/screen, /screen/ws)
src/turn_stats.rs per-turn analytics sink (one sqlite row per
turn at /state/hyperhive-turn-stats.sqlite);
schema + best-effort writer
src/stats.rs read-side aggregations over turn-stats.sqlite
backing the /stats page (bucketed Snapshot:
turns / duration / tokens / model mix)
src/events.rs LiveEvent + broadcast Bus + sqlite-backed history
(/state/hyperhive-events.sqlite) + TurnState +
model selection (persisted at /state/hyperhive-model)
src/turn.rs claude --print + stream-json pump; --compact retry;
proactive compaction + auto session-reset
src/mcp.rs embedded MCP server (rmcp): AgentServer + ManagerServer
src/login.rs probe /root/.claude/ for a valid session
src/login_session.rs drives `claude auth login` over stdio pipes
src/bin/hive-ag3nt.rs sub-agent main (Serve + Mcp subcommands)
src/bin/hive-m1nd.rs manager main (Serve + Mcp subcommands)
Static UI assets served by ServeDir from
$HIVE_STATIC_DIR (= hyperhive.frontend
.mergedDist — default agent dist + per-agent
extraFiles, set per the harness-base module).
prompts/ static role/tools/settings for claude (include_str!):
agent.md — sub-agent system prompt
manager.md — manager system prompt
claude-settings.json — --settings JSON
hive-forge/ Forgejo CLI wrapper (`hive-forge` binary)
src/main.rs clap dispatch over the verbs/
src/client.rs blocking reqwest client (Forgejo REST API)
src/body.rs body input resolution (--body / --body-file / piped stdin)
src/verbs/<verb>.rs one module per verb (view, issue, pr, comment,
comments, comment-show, comment-edit, issue-create,
issue-edit, pr-create, pr-reviews, assign,
close, labels, milestone, branches,
tree-sha, diff, subscription, attach-issue,
attach-comment, lint). Replaces the 600-line
hive-forge-tools.nix bash script (closes #280).
hive-sh4re/ wire types (HostRequest/Response, AgentRequest/Response,
ManagerRequest/Response, Message, Approval, HelperEvent)
nix/
modules/hive-c0re.nix systemd service + firewall + git wiring;
`contextWindowTokens` attrset (per-model,
injected as env vars into all containers);
imports hive-forge.nix
modules/hive-forge.nix optional in-container Forgejo
(`hyperhive.forge.enable`, default on);
Catppuccin Mocha theme via tmpfiles C+ copy
templates/harness-base.nix shared scaffolding for sub-agents + manager;
`hyperhive.model` option (HIVE_DEFAULT_MODEL)
templates/agent-base.nix sub-agent nixosConfiguration
templates/manager.nix manager nixosConfiguration
templates/weston-vnc.nix optional `hyperhive.gui.enable`
— weston + VNC backend systemd unit; writes
/etc/hyperhive/gui.json (vnc_port + auth) for
the harness WebSocket relay (/screen/ws)
forge-theme/theme-catppuccin-vibec0re.css Catppuccin Mocha forge theme
docs/
conventions.md naming, identity=socket, async forms, commit style
gotchas.md NixOS / nspawn quirks and lessons learned
web-ui.md dashboard + per-agent page layouts and endpoints
turn-loop.md claude invocation, wake prompt, MCP tool surface
approvals.md approval flow, manager policy, helper events
persistence.md sqlite dbs, retention, state dir layout
terminal-rendering.md per-agent terminal row taxonomy (as built)
boundary.md operator/agent trust model rationale
agent-hierarchy.md tree-shape topology design + manager-privilege audit (#361)
damocles-migration.md future migration plan for damocles → hyperhive
```
## Reading paths
Pick the doc that matches your task. None depend on the others —
read them à la carte.
- **"What does the dashboard look like?"** →
[`docs/web-ui.md`](docs/web-ui.md).
- **"How does the per-agent terminal classify + colour
events?"** → [`docs/terminal-rendering.md`](docs/terminal-rendering.md).
- **"How does claude get its prompt and what tools does it have?"** →
[`docs/turn-loop.md`](docs/turn-loop.md).
- **"How do config changes flow from manager to operator to
container?"** → [`docs/approvals.md`](docs/approvals.md).
- **"What state survives destroy / purge / restart?"** →
[`docs/persistence.md`](docs/persistence.md).
- **"Naming, commit style, wire protocol, the `data-async`
pattern."** → [`docs/conventions.md`](docs/conventions.md).
- **"Why does the nspawn flag look like that?"** →
[`docs/gotchas.md`](docs/gotchas.md).
## Conventions & process
The docs below own the details — this section just points at them.
- **Commit style, naming, identity, reconcile verb:**
[`docs/conventions.md`](docs/conventions.md).
- **NixOS / nspawn quirks** (bind mounts, conf flags, etc.): →
[`docs/gotchas.md`](docs/gotchas.md).
- **Turn loop, sentinels (rate-limit, auth-failed), context
window:** → [`docs/turn-loop.md`](docs/turn-loop.md).
- **Two-step spawn, approval flow, flake.lock validation:**
[`docs/approvals.md`](docs/approvals.md).

2375
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

48
Cargo.toml Normal file
View file

@ -0,0 +1,48 @@
[workspace]
resolver = "3"
members = ["hive-ag3nt", "hive-c0re", "hive-forge", "hive-sh4re"]
[workspace.package]
edition = "2024"
version = "0.1.0"
[workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 }
# Tolerated stylistic pedantic lints (noisy, not actionable).
missing_errors_doc = "allow"
missing_panics_doc = "allow"
module_name_repetitions = "allow"
must_use_candidate = "allow"
[workspace.dependencies]
anyhow = "1"
axum = { version = "0.8", features = ["ws"] }
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
hive-sh4re = { path = "hive-sh4re" }
tower-http = { version = "0.6", features = ["fs"] }
rmcp = { version = "1.7", default-features = false, features = [
"server",
"macros",
"transport-io",
] }
rusqlite = { version = "0.37", features = ["bundled"] }
schemars = "1.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
similar = "2"
tokio = { version = "1", features = [
"fs",
"io-util",
"macros",
"net",
"process",
"rt-multi-thread",
"signal",
"sync",
"time",
] }
tokio-stream = { version = "0.1", features = ["sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

339
LICENSE
View file

@ -1,339 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

155
README.md
View file

@ -1,46 +1,143 @@
# nixos-configuration
# <img src="branding/hyperhive.svg" alt="" width="38" align="top"> hyperhive
Personal NixOS configuration for all machines. Devices are declared in `devices.nix`, per-device configs live in `nixosConfigurations/<name>/`, and shared modules in `nixosModules/`.
> a swarm of claude-code agents, each in its own nspawn cage, gossiping
> over unix sockets. config changes flow as git commits, the operator
> approves them in a browser, every deploy is a tag. cyberpunk-themed
> dashboard included. 💜⚡
## Distributed builds
Claude code is great in one window, *exponentielle* across many — but
only if you can keep the agents from stepping on each other, give them
durable identity, and stop them from eating production. hyperhive is
the substrate.
Machines are configured to act as build servers / binary caches for each other in devices.nix.
- identity = unix socket
- communication = sqlite-backed broker (`send` / `recv` / `ask` /
`answer` / `remind`)
- config = git (manager proposes, operator approves, deploys land as
tagged commits)
- blast radius = container
### Onboarding a device as a build client
```
host (NixOS, runs hive-c0re.service)
├── operator
│ ├── browser → :7000 hive-c0re dashboard
│ ├── browser → :8000 / :8100-8999 per-agent web UIs
│ └── CLI → /run/hyperhive/host.sock admin protocol
├── hive-c0re (Rust daemon: lifecycle / broker / approvals /
│ auto-update / dashboard / sockets)
└── nixos-containers
├── hm1nd manager agent (privileged MCP surface)
└── h-<name> sub-agent (vanilla MCP surface + per-agent extras)
```
1. Generate a key pair on the device:
Depth lives in [`docs/`](docs/) — pick the one matching your task:
```sh
sudo ssh-keygen -t ed25519 -f /etc/nix/distributed-build-key -N "" -C "$(hostname)-nix-builds" && sudo cat /etc/nix/distributed-build-key.pub
```
| reading path | doc |
| --- | --- |
| dashboard layout + endpoints | [`docs/web-ui.md`](docs/web-ui.md) |
| claude turn loop + MCP tools | [`docs/turn-loop.md`](docs/turn-loop.md) |
| config-edit + approval state machine | [`docs/approvals.md`](docs/approvals.md) |
| what survives destroy / purge / restart | [`docs/persistence.md`](docs/persistence.md) |
| naming, wire protocol, commit style | [`docs/conventions.md`](docs/conventions.md) |
| NixOS / nspawn gotchas | [`docs/gotchas.md`](docs/gotchas.md) |
2. Add the public key to the device entry in `devices.nix`:
## Host config
```nix
distributedBuilds.clientPublicKey = "ssh-ed25519 AAAA... <hostname>-nix-builds";
```
Minimal `flake.nix` for a host that runs hive-c0re:
3. Rebuild all build machines so they pick up the new authorized key.
```nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
hyperhive.url = "git+https://git.berlin.ccc.de/vinzenz/hyperhive";
};
### Adding a build server
outputs = { nixpkgs, hyperhive, ... }: {
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
hyperhive.nixosModules.default # hive-c0re + hive-forge in one import
({ ... }: {
services.hive-c0re.enable = true;
# services.hive-c0re.operatorPronouns = "they/them"; # default: "she/her"
1. Add to its entry in `devices.nix`:
# ... rest of your host config
system.stateVersion = "25.11";
})
];
};
};
}
```
```nix
distributedBuilds.isBuilder = true;
distributedBuilds.hostPublicKey = "ssh-ed25519 AAAA..."; # from: ssh-keyscan -t ed25519 "$(hostname)"
```
hive-c0re opens its admin socket + dashboard, auto-creates the
manager container, and auto-rebuilds any container whose hyperhive
rev goes stale. `claude-code` is unfree — hyperhive scopes the
whitelist to itself, nothing for the operator to set.
2. Generate a store signing key on the builder:
Optional: set `services.hive-c0re.preBuildAgentTemplates = true;`
to pre-fetch the per-container system closures into your host's
/nix/store as part of `nixos-rebuild`. First-agent-spawn then
completes in seconds instead of minutes (no nixpkgs/claude-code
fetch on the critical path), at the cost of a few GB extra in your
system closure. Off by default (the toplevels are pinned to
`x86_64-linux`, so non-x86 hosts would otherwise force a cross-build).
Alternatively warm the store manually:
`nix build github:vinzenz/hyperhive#agent-base-toplevel`.
```sh
sudo nix key generate-secret --key-name "$(hostname)" | sudo tee /etc/nix/signing-key.sec | sudo nix key convert-secret-to-public
```
## Agent configuration
3. Add the printed public key to `devices.nix`:
Per-agent settings live in each agent's `agent.nix` and are synced to
the container as environment variables. Common options:
```nix
distributedBuilds.storeSigningPublicKey = "<hostname>:<base64...>";
```
- **`hyperhive.model`** — Claude model for this agent (default: `"haiku"`).
Sets `HIVE_DEFAULT_MODEL` in the container; the harness applies it at
boot and it takes priority over any persisted runtime override. The
operator can still switch the model at runtime via the per-agent web UI,
but that choice is reset by any rebuild that changes this option.
- **`hyperhive.allowedRecipients`** — List of agent names this agent can
message (via `send`). If unset, all agents are allowed. Useful to
restrict an agent to talking only to the manager.
- **`hyperhive.forge.url`** — Base URL of the hyperhive-managed Forgejo
(default: `"http://localhost:3000"`). Used to configure the agent's
tea login at boot; no-op if `/state/forge-token` is missing.
- **`hyperhive.forge.keepSubscriptions`** — Boolean. If `true`, the agent's
forge repo subscriptions are never auto-cleaned during rebuild; useful
for agents that want to watch specific repos. Rendered as
`HIVE_FORGE_KEEP_SUBSCRIPTIONS`.
- **`hyperhive.forge.skipNotifyReasons`** — List of forge notification
`reason` values to suppress (e.g. `[ "subscribed" "participating" ]`).
Notifications matching these reasons are silently dropped; all others
including direct mentions and reviews are delivered. Empty list (default)
delivers all notifications. Rendered as `HIVE_FORGE_NOTIFY_SKIP_REASONS`
(comma-separated).
- **`hyperhive.frontend.dist`** — Override the default frontend package
(`pkgs.hyperhive-frontend`, built by `nix/frontend.nix`). Set to a custom
derivation to ship a fully custom per-agent SPA. The JSON contract
(`/api/state`, `/events/stream`, action endpoints) is the source of truth
for any replacement.
- **`hyperhive.frontend.extraFiles`** — Attrset of extra files/directories
to layer on top of the default agent dist. Each entry has a `source` (nix
path) and an optional `target` (URL prefix in the static tree, defaults to
the attribute name). Example: `{ bitburner.source = ./bitburner-dist; }`
serves that dist at `/bitburner/`. Pure additions only — overwriting an
existing default file is a hard eval-time error; use `frontend.dist` to
replace the whole dist. Paths with leading `/` or `..` segments are
rejected at eval time.
4. Rebuild all machines so they trust the new signing key.
See `nix/templates/harness-base.nix` for the full list of options and
their descriptions.
## Build / deploy
```sh
nix develop -c cargo check
nix flake check # rust + nix + toml fmt + clippy
# deploy from a host config that imports hyperhive.nixosModules.hive-c0re
nix flake update --update-input hyperhive
sudo nixos-rebuild switch --flake .#<host>
```

6
TODO.md Normal file
View file

@ -0,0 +1,6 @@
# Hyperhive TODOs
The backlog moved to the forge issue tracker:
<http://localhost:3000/hyperhive/hyperhive/issues>
Operator/agent trust-boundary design rationale: [`docs/boundary.md`](docs/boundary.md).

116
branding/agent-configs.svg Normal file
View file

@ -0,0 +1,116 @@
<svg width="300" height="300" viewBox="0 0 300 300" role="img" xmlns="http://www.w3.org/2000/svg">
<title>HyperHive · agent-configs</title>
<desc>HyperHive agent-configs org icon — stacked config files, amber on dark, same frame as the main hyperhive mark</desc>
<defs>
<clipPath id="clipH"><circle cx="150" cy="150" r="140"/></clipPath>
</defs>
<g clip-path="url(#clipH)">
<!-- same dark base + faint horizontal scanlines as the parent
hyperhive mark, so the agent-configs org reads as a sibling -->
<rect x="0" y="0" width="300" height="300" fill="#0a0600"/>
<g stroke="#ffb300" stroke-width="0.35" opacity="0.07">
<line x1="0" x2="300" y1="10" y2="10"/> <line x1="0" x2="300" y1="20" y2="20"/>
<line x1="0" x2="300" y1="30" y2="30"/> <line x1="0" x2="300" y1="40" y2="40"/>
<line x1="0" x2="300" y1="50" y2="50"/> <line x1="0" x2="300" y1="60" y2="60"/>
<line x1="0" x2="300" y1="70" y2="70"/> <line x1="0" x2="300" y1="80" y2="80"/>
<line x1="0" x2="300" y1="90" y2="90"/> <line x1="0" x2="300" y1="100" y2="100"/>
<line x1="0" x2="300" y1="110" y2="110"/> <line x1="0" x2="300" y1="120" y2="120"/>
<line x1="0" x2="300" y1="130" y2="130"/> <line x1="0" x2="300" y1="140" y2="140"/>
<line x1="0" x2="300" y1="150" y2="150"/> <line x1="0" x2="300" y1="160" y2="160"/>
<line x1="0" x2="300" y1="170" y2="170"/> <line x1="0" x2="300" y1="180" y2="180"/>
<line x1="0" x2="300" y1="190" y2="190"/> <line x1="0" x2="300" y1="200" y2="200"/>
<line x1="0" x2="300" y1="210" y2="210"/> <line x1="0" x2="300" y1="220" y2="220"/>
<line x1="0" x2="300" y1="230" y2="230"/> <line x1="0" x2="300" y1="240" y2="240"/>
<line x1="0" x2="300" y1="250" y2="250"/> <line x1="0" x2="300" y1="260" y2="260"/>
<line x1="0" x2="300" y1="270" y2="270"/> <line x1="0" x2="300" y1="280" y2="280"/>
</g>
<!-- containment rings, same as parent (visual family) -->
<circle cx="150" cy="150" r="118" fill="none" stroke="#ffb300" stroke-width="1" opacity="0.4"/>
<circle cx="150" cy="150" r="104" fill="none" stroke="#ff8f00" stroke-width="0.5" opacity="0.25" stroke-dasharray="4 6"/>
<!-- stack of three offset config sheets. each sheet is a
dark-fill rectangle outlined in amber, with thin "content
lines" inside to read as a file. offset diagonally so the
stack reads top-down: back / mid / front. front sheet is
centred on the canvas (150, 150) so the curly-brace glyph
at canvas-centre lands at the visual middle of the front
sheet; back and mid peek out top-LEFT so the stack reads
as a pile. -->
<!-- BACK sheet (x=70, y=56 — top-left peek) -->
<g opacity="0.55">
<rect x="70" y="56" width="120" height="148" rx="6" ry="6"
fill="#150c00" stroke="#ffb300" stroke-width="1.4"/>
<g stroke="#ffb300" stroke-width="0.7" opacity="0.55">
<line x1="82" y1="78" x2="164" y2="78"/>
<line x1="82" y1="94" x2="144" y2="94"/>
<line x1="82" y1="110" x2="158" y2="110"/>
<line x1="82" y1="126" x2="132" y2="126"/>
<line x1="82" y1="142" x2="164" y2="142"/>
<line x1="82" y1="158" x2="144" y2="158"/>
<line x1="82" y1="174" x2="154" y2="174"/>
</g>
</g>
<!-- MID sheet (x=80, y=66) -->
<g opacity="0.8">
<rect x="80" y="66" width="120" height="148" rx="6" ry="6"
fill="#1a0f00" stroke="#ffb300" stroke-width="1.6"/>
<g stroke="#ffb300" stroke-width="0.8" opacity="0.7">
<line x1="92" y1="88" x2="174" y2="88"/>
<line x1="92" y1="104" x2="154" y2="104"/>
<line x1="92" y1="120" x2="168" y2="120"/>
<line x1="92" y1="136" x2="142" y2="136"/>
<line x1="92" y1="152" x2="174" y2="152"/>
<line x1="92" y1="168" x2="154" y2="168"/>
<line x1="92" y1="184" x2="164" y2="184"/>
</g>
</g>
<!-- FRONT sheet (x=90, y=76, width=120, height=148 — centred
at canvas (150, 150)). Folded top-right corner + curly-
brace glyph. The brace text is anchored at canvas-centre
which now coincides with the front-sheet centre. -->
<g>
<!-- main rectangle with folded top-right corner cut: draw as a
path so the corner triangle reads as a tab -->
<path d="M 90 76
L 192 76
L 210 94
L 210 224
L 90 224
Z"
fill="#1f1200" stroke="#ffb300" stroke-width="1.8"/>
<!-- folded-corner triangle (lighter fill) -->
<path d="M 192 76 L 210 94 L 192 94 Z"
fill="#2a1900" stroke="#ffb300" stroke-width="1.2"/>
<!-- curly braces — the universal "config file" glyph.
dominant-baseline=central centres the glyph block on the
text y-coordinate so it lands at the front-sheet middle.
font-size dropped 78→56 + tighter letter-spacing (#424
mara: "braces cross the boundaries of the page") so the
glyphs sit comfortably inside the 120-wide sheet with
clear breathing room on the left/right edges. -->
<text x="150" y="150" text-anchor="middle" dominant-baseline="central"
font-family="ui-monospace, 'JetBrains Mono', monospace"
font-size="56" font-weight="700" letter-spacing="-3"
fill="#ffb300" opacity="0.95">{ }</text>
</g>
<!-- thin centerline crosshair, same flourish as the parent -->
<rect x="0" y="143" width="300" height="2" fill="#ffb300" opacity="0.07"/>
</g>
<!-- same outer frame + corner brackets as the parent hyperhive
mark so the two orgs read as a visual family -->
<circle cx="150" cy="150" r="140" fill="none" stroke="#ffb300" stroke-width="2.5"/>
<circle cx="150" cy="150" r="145" fill="none" stroke="#ff8f00" stroke-width="0.5" opacity="0.4" stroke-dasharray="8 4"/>
<g stroke="#ffb300" stroke-width="1.5" fill="none" opacity="0.8">
<path d="M44,44 L20,44 L20,70"/>
<path d="M256,44 L280,44 L280,70"/>
<path d="M44,256 L20,256 L20,230"/>
<path d="M256,256 L280,256 L280,230"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
branding/hyperhive.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

97
branding/hyperhive.svg Normal file
View file

@ -0,0 +1,97 @@
<svg width="300" height="300" viewBox="0 0 300 300" role="img" xmlns="http://www.w3.org/2000/svg">
<title>HyperHive</title>
<desc>HyperHive icon — hexagonal hive, amber on dark</desc>
<defs>
<clipPath id="clipH"><circle cx="150" cy="150" r="140"/></clipPath>
</defs>
<g clip-path="url(#clipH)">
<rect x="0" y="0" width="300" height="300" fill="#0a0600"/>
<g stroke="#ffb300" stroke-width="0.35" opacity="0.07">
<line x1="0" x2="300" y1="10" y2="10"/> <line x1="0" x2="300" y1="20" y2="20"/>
<line x1="0" x2="300" y1="30" y2="30"/> <line x1="0" x2="300" y1="40" y2="40"/>
<line x1="0" x2="300" y1="50" y2="50"/> <line x1="0" x2="300" y1="60" y2="60"/>
<line x1="0" x2="300" y1="70" y2="70"/> <line x1="0" x2="300" y1="80" y2="80"/>
<line x1="0" x2="300" y1="90" y2="90"/> <line x1="0" x2="300" y1="100" y2="100"/>
<line x1="0" x2="300" y1="110" y2="110"/> <line x1="0" x2="300" y1="120" y2="120"/>
<line x1="0" x2="300" y1="130" y2="130"/> <line x1="0" x2="300" y1="140" y2="140"/>
<line x1="0" x2="300" y1="150" y2="150"/> <line x1="0" x2="300" y1="160" y2="160"/>
<line x1="0" x2="300" y1="170" y2="170"/> <line x1="0" x2="300" y1="180" y2="180"/>
<line x1="0" x2="300" y1="190" y2="190"/> <line x1="0" x2="300" y1="200" y2="200"/>
<line x1="0" x2="300" y1="210" y2="210"/> <line x1="0" x2="300" y1="220" y2="220"/>
<line x1="0" x2="300" y1="230" y2="230"/> <line x1="0" x2="300" y1="240" y2="240"/>
<line x1="0" x2="300" y1="250" y2="250"/> <line x1="0" x2="300" y1="260" y2="260"/>
<line x1="0" x2="300" y1="270" y2="270"/> <line x1="0" x2="300" y1="280" y2="280"/>
</g>
<circle cx="150" cy="150" r="118" fill="none" stroke="#ffb300" stroke-width="1" opacity="0.4"/>
<circle cx="150" cy="150" r="104" fill="none" stroke="#ff8f00" stroke-width="0.5" opacity="0.25" stroke-dasharray="4 6"/>
<!-- RING 2 — 6 between-axis hexes, centered on (150,150) instead of (170,170) -->
<g fill="#0e0800" stroke="#ffb300" stroke-width="0.7" opacity="0.4">
<polygon points="254,105 241,127.5 215,127.5 202,105 215,82.5 241,82.5"/>
<polygon points="98,105 85,127.5 59,127.5 46,105 59,82.5 85,82.5"/>
<polygon points="176,60 163,82.5 137,82.5 124,60 137,37.5 163,37.5"/>
<polygon points="254,195 241,217.5 215,217.5 202,195 215,172.5 241,172.5"/>
<polygon points="98,195 85,217.5 59,217.5 46,195 59,172.5 85,172.5"/>
<polygon points="176,240 163,262.5 137,262.5 124,240 137,217.5 163,217.5"/>
</g>
<!-- RING 1 — 6 hexes -->
<g fill="#150c00" stroke="#ffb300" stroke-width="1.2" opacity="0.8">
<polygon points="228,150 215,172.5 189,172.5 176,150 189,127.5 215,127.5"/>
<polygon points="202,105 189,127.5 163,127.5 150,105 163,82.5 189,82.5"/>
<polygon points="150,105 137,127.5 111,127.5 98,105 111,82.5 137,82.5"/>
<polygon points="124,150 111,172.5 85,172.5 72,150 85,127.5 111,127.5"/>
<polygon points="150,195 137,217.5 111,217.5 98,195 111,172.5 137,172.5"/>
<polygon points="202,195 189,217.5 163,217.5 150,195 163,172.5 189,172.5"/>
</g>
<!-- CENTER hex -->
<polygon points="176,150 163,172.5 137,172.5 124,150 137,127.5 163,127.5"
fill="#1a0f00" stroke="#ffb300" stroke-width="1.8"/>
<!-- connections -->
<g stroke="#ffb300" stroke-width="1" opacity="0.6">
<line x1="176" y1="150" x2="202" y2="150"/>
<line x1="124" y1="150" x2="98" y2="150"/>
<line x1="163" y1="128" x2="176" y2="105"/>
<line x1="137" y1="128" x2="124" y2="105"/>
<line x1="163" y1="173" x2="176" y2="195"/>
<line x1="137" y1="173" x2="124" y2="195"/>
</g>
<!-- CENTER node -->
<circle cx="150" cy="150" r="10" fill="#ffb300" opacity="0.95"/>
<circle cx="150" cy="150" r="5" fill="#0a0600"/>
<!-- ring 1 nodes -->
<g fill="#ff8f00" opacity="0.9">
<circle cx="202" cy="150" r="6"/>
<circle cx="176" cy="105" r="6"/>
<circle cx="124" cy="105" r="6"/>
<circle cx="98" cy="150" r="6"/>
<circle cx="124" cy="195" r="6"/>
<circle cx="176" cy="195" r="6"/>
</g>
<g fill="#0a0600">
<circle cx="202" cy="150" r="2.5"/>
<circle cx="176" cy="105" r="2.5"/>
<circle cx="124" cy="105" r="2.5"/>
<circle cx="98" cy="150" r="2.5"/>
<circle cx="124" cy="195" r="2.5"/>
<circle cx="176" cy="195" r="2.5"/>
</g>
<rect x="0" y="143" width="300" height="2" fill="#ffb300" opacity="0.07"/>
</g>
<circle cx="150" cy="150" r="140" fill="none" stroke="#ffb300" stroke-width="2.5"/>
<circle cx="150" cy="150" r="145" fill="none" stroke="#ff8f00" stroke-width="0.5" opacity="0.4" stroke-dasharray="8 4"/>
<g stroke="#ffb300" stroke-width="1.5" fill="none" opacity="0.8">
<path d="M44,44 L20,44 L20,70"/>
<path d="M256,44 L280,44 L280,70"/>
<path d="M44,256 L20,256 L20,230"/>
<path d="M256,256 L280,256 L280,230"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View file

@ -1,80 +0,0 @@
{ self }:
let
nixos-raspberrypi = self.inputs.nixos-raspberrypi;
in
{
# keep-sorted start block=yes
aur0ra = {
system = "aarch64-linux";
nixosSystem = nixos-raspberrypi.lib.nixosSystem;
};
aur0ra-installer = {
# build with nix build .\#nixosConfigurations.aur0ra-installer.config.system.build.sdImage
system = "aarch64-linux";
nixosSystem = nixos-raspberrypi.lib.nixosInstaller;
};
damocles = {
system = "x86_64-linux";
distributedBuilds.maxJobs = 0;
};
damocles-lab = {
system = "x86_64-linux";
distributedBuilds.maxJobs = 0;
};
epimetheus = {
system = "aarch64-linux";
};
forgejo-runner-1 = {
system = "aarch64-linux";
publicFqdn = "forgejo-runner-1.dev.zerforschen.plus";
distributedBuilds = {
isBuilder = true;
speedFactor = 1;
clientPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK0NLgg0sFobBWz/bjYs9WkrMvlcvJC5F6+3jQ/b+AnD forgejo-runner-1-nix-builds";
hostPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIANGC89GiT5xCsFICwrharrbV3q7acWHqk6ZwOUXbtGT";
storeSigningPublicKey = "forgejo-runner-1:ln1FVLL8G5+IveQuBi/Kn3SaqFZ1gaiQrE3yPlMhCMA=";
};
};
hetzner-vpn2 = {
system = "aarch64-linux";
};
hyperforge = {
system = "aarch64-linux";
};
muede-lpt2 = {
system = "x86_64-linux";
isDesktop = true;
home-manager-users = {
inherit (self.homeConfigurations) muede;
};
distributedBuilds = {
isBuilder = true;
speedFactor = 2;
hostPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHGKoZ68wwyVRmPB0SkvpJUyUMDWeFbC5Je9zukyEOh7";
clientPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKAbojdhb3PfazSRmudvo381Y+zUFVLMa7AbWbfK/Zp2 muede-lpt2-nix-builds";
storeSigningPublicKey = "muede-lpt2:3csut7FW6oZK/ztRLBRC80LSBfFE3qzl+aIYgOixB6U=";
};
};
muede-pc2 = {
system = "x86_64-linux";
isDesktop = true;
home-manager-users = {
inherit (self.homeConfigurations) muede;
};
distributedBuilds = {
isBuilder = true;
speedFactor = 4;
hostPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKEQQS5XNoj62Oj85xQfIuLORwoBRwfqjvfBHHsiI+RH";
clientPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHmnyhP6L+kGHV15cb/d31AQr50wSEaQhkUBwy2+OEKk muede-pc2-nix-builds";
storeSigningPublicKey = "muede-pc2:fqQO0E0y65MjUWlQnrgWt5ZsmQKlKCv4jls3CmUXDEQ=";
};
};
ronja-pc = {
system = "x86_64-linux";
isDesktop = true;
home-manager-users = {
inherit (self.homeConfigurations) ronja;
};
};
# keep-sorted end
}

204
docs/agent-hierarchy.md Normal file
View file

@ -0,0 +1,204 @@
# Agent hierarchy & privileges
Design + audit doc for milestone #6 (the
[issue](http://localhost:3000/hyperhive/hyperhive/issues/361) tree).
The implementation lands in pieces; this doc tracks what's done, what's
planned, and what currently special-cases the manager.
## Current state (as of this PR)
Topology lives in the hive-c0re-owned **meta repo**, alongside
`flake.nix`, at `/var/lib/hyperhive/meta/topology.json`:
```json
{
"manager": null,
"alice": "manager",
"bob": "alice"
}
```
`null` = root-level agent. Today only the manager qualifies by default.
Other agents land under `"manager"` on first sync. Re-parenting is
operator-driven (#486 / #487):
- CLI: `hive-c0re set-parent <child> --parent <new>` (or `--root` to
promote). Exactly one of `--parent` / `--root` is required.
- Dashboard: `POST /api/topology/set-parent` (form fields `child`,
optional `new_parent` — absent / empty ⇒ promote to root).
- Wire: `HostRequest::SetParent { child, new_parent: Option<String> }`.
All three converge on `topology::set_parent`, which delegates the
validation rules to a pure `apply_set_parent` helper. Refuses:
- reparenting the manager (structurally root),
- unknown `child` / `new_parent` (typo guard),
- self-parenting,
- cycles (32-hop ancestor walk, mirroring `is_descendant_of`).
Idempotent no-op fast path skips the disk write when the parent is
already what's requested. After a successful write the surfaces call
`Coordinator::rescan_containers_and_emit` so connected dashboard
viewers see the tree repaint without polling
(`ContainerView.parent` is sourced from `topology.json`).
**Today's caveat (#361 follow-up):** the move is purely a JSON edit.
Only the top-level manager (`hm1nd`) gets `/var/lib/hyperhive/agents`
bind-mounted at `/agents` in its container, so sub-agents don't yet
see their would-be children's state. Once sub-manager bind mounts
land alongside #361 enforcement, `set_parent` grows a companion
umount-old / mount-new / restart-cascade step (tracked via the
cross-ref comment on #361).
### Why meta, not per-agent `agent.nix`
An agent shouldn't be able to claim a parent without that parent's
consent, and operator-driven re-parenting shouldn't require touching
the moved agent's config. Topology IS a system-level concern; meta is
where system-level facts live.
### Flow
1. **Read**: `topology::read()` parses `topology.json` into a
`BTreeMap<String, Option<String>>`. Missing / unparsable file →
empty map → every agent treated as root (safe degradation for
fresh installs that haven't run `meta::sync_agents` yet).
2. **Reconcile**: `meta::sync_agents` calls `topology::reconcile`
alongside its `flake.nix` regeneration. New agents land at their
default position (manager as parent, manager itself as root);
removed agents drop. Existing entries are preserved as-is so
operator overrides stick across regenerations.
3. **Inject**: `meta::render_flake` looks up each agent's parent and
passes it to `mkAgent`. When non-null, the mkAgent body sets
`HIVE_PARENT = parent` in the agent's systemd service environment
so the harness / claude prompts can see it.
4. **Surface**: `container_view::build_all` reads `topology.json` and
populates `ContainerView.parent: Option<String>` on every rescan.
The dashboard renders the field as a tree (#363 follow-up).
## Target topology semantics
Once enforcement lands the rules collapse into:
| operation | who can do it |
|---|---|
| `kill` / `start` / `restart` / `update` (any descendant) | any ancestor |
| `request_init_config` (spawn a new child) | any agent, child added under self |
| `request_apply_commit` (any descendant's config) | any ancestor |
| `get_logs` (any descendant) | any ancestor |
| moderate questions / reminders (cancel any open thread of a descendant) | any ancestor |
| `send` / `recv` routing | parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else |
| `request_update_meta_inputs` (bump meta lock) | root agents only (today: just `manager`) |
"Ancestor" walks `ContainerView.parent` chains; cycles are guarded by a
visited-set at dispatch time (a malformed topology.json can't lock the
dispatcher into a loop).
## Current manager special-casings — the audit
What currently makes the manager different from every other agent, and
which axis the post-milestone version reads each special-case along:
### A — naming + bootstrap
- `MANAGER_AGENT = "manager"` (broker recipient name) and
`MANAGER_NAME = "hm1nd"` (container name). ~28 grep hits across
`hive-c0re/src/`. **Just a name** — the rename plan is `manager`
`root`, executed via the one-shot migration script in
`migrate.rs` (idempotent, marker-guarded).
- `auto_update::ensure_manager` runs at hive-c0re boot and spawns
`hm1nd` if missing. Becomes "ensure the root agent exists" once any
agent can be at the root. **Topology**: root has no parent, so
hive-c0re itself owns its lifecycle (no parent to delegate to).
### B — wire-protocol privileges
The `ManagerRequest::*` variants in `hive-sh4re/src/lib.rs` are
operations the manager flavour socket can make that sub-agent sockets
can't:
| variant | semantic | post-milestone |
|---|---|---|
| `RequestInitConfig` | seed an agent's proposed config repo | **topology** — descendants only |
| `RequestApplyCommit` | submit a commit sha for operator approval | **topology** — descendants only |
| `Kill` / `Start` / `Restart` / `Update` | container lifecycle on an existing agent | **topology** — descendants only |
| `RequestUpdateMetaInputs` | bump meta `flake.lock` | **per-agent cap** (root-only today; a future "let coder bump its own input" might grant it) |
| `GetLogs` | journalctl scrape of a sub-agent | **topology** — descendants only |
| `Wake` | inject a `from: <X>` message into self's inbox | **not really privileged** — the wire surface exists because daemon co-processes (e.g. `forge_notify`) need it. Sub-agents have the same via their own socket. |
### C — storage / mounts (`hive-c0re::lifecycle`)
The manager container's nspawn bind set:
- `HOST_AGENTS_ROOT (/var/lib/hyperhive/agents) → /agents` RW — so the
manager can edit any agent's proposed config repo
- `HOST_APPLIED_ROOT (/var/lib/hyperhive/applied) → /applied` RO — so
the manager can diff against what's deployed
- `HOST_META_ROOT (/var/lib/hyperhive/meta) → /meta` RO — so the
manager can read the system-wide deploy log
Tree-shape version:
- Each agent gets RW to `/agents/<descendant>/` for every descendant in
its subtree. The root agent (today: manager) gets RW to the full
forest as a special case of "the root has every other agent as a
descendant".
- RO `/meta` access if the agent holds a "meta read" cap.
- `request_update_meta_inputs` is the only path that actually writes
`flake.lock`, gated by the cap; everyone else stays RO.
### D — drop legacy `/state` for manager
`lifecycle.rs::notes_mount` currently ternary's `/state` for the
manager and `/agents/<name>/state` for everyone else (because the
manager pre-dates the per-agent state-dir layout). Milestone bullet:
unify on `/agents/<name>/state` for everyone. One-time `mv` of
`/var/lib/hyperhive/manager/state``/var/lib/hyperhive/agents/manager/state`
in `migrate.rs` (idempotent, marker-guarded).
### E — prompt + tools
- `prompts/manager.md` vs `prompts/agent.md` — two separate system
prompts. **Per-agent cap list** of what the agent can do, rendered
into a single parametrised prompt at boot.
- `mcp.rs::Flavor::{Agent, Manager}` controls which MCP tools claude
sees. Already structured this way internally — the per-flavour
allow-list becomes a per-cap-set lookup.
### F — drive-by checks across c0re
(`grep -n MANAGER_AGENT` produced ~28 hits)
- `loose_ends.rs`: manager sees hive-wide loose-ends, sub-agents only
their own. **Topology** — every agent sees its own + its
descendants'.
- `operator_questions.rs` + `broker.rs`: "manager can cancel any
question" override on the owner check. **Topology** — agents can
moderate threads of their descendants. (per mara's
https://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3344)
- `reminder_scheduler.rs`: same override pattern for reminder cancel.
**Topology** — descendants only.
- `actions.rs`: `destroy` refuses to act on `MANAGER_NAME` (no
foot-shooting). **Topology** — agents can destroy descendants but
never themselves or ancestors.
- `crash_watch.rs`: skips `ContainerCrash` for the manager (it
auto-restarts via systemd). **Topology** — the root container has
different recovery semantics, every other agent falls into the same
watch loop.
### G — sub-agents inside the same container
Future work mentioned in #361: when enabled for an agent, it can spawn
temporary "sub-agents" that run inside its own container. Lighter than
a full nspawn agent. Open questions, not yet wired:
- Inherit caps from parent, or take an explicit narrower set?
- Survive container restart, or always ephemeral?
- Inbox: separate from parent, or shared?
- Filesystem: share parent's `/state` RW, or a sub-dir?
- Identity: distinct broker recipient name, or address the parent?
## Cross-references
- Milestone: [#361 "Agent privileges and sub-agents"](http://localhost:3000/hyperhive/hyperhive/issues/361)
- Dashboard render: [#363 "show agent topology in container list"](http://localhost:3000/hyperhive/hyperhive/issues/363)
- Audit table source: [comment 3335 on #361](http://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3335)
- Operator/agent trust boundary (orthogonal axis): [`boundary.md`](boundary.md)

471
docs/approvals.md Normal file
View file

@ -0,0 +1,471 @@
# Approvals + manager + helper events
The approval queue is hyperhive's pivot: nothing that changes the
shape of an agent (its config, whether it exists) happens without an
operator click. The manager (`hm1nd`) is the policy gate in front of
that queue; helper events are how it stays informed about what
happens after a decision lands.
## End-to-end approval flow
1. Manager edits files under `/agents/<name>/config/` (any tracked
path, but `agent.nix` is the contract entry point) and commits
with its own git identity.
2. Manager submits the commit sha via `request_apply_commit(agent,
commit_ref)`. `commit_ref` must be a commit **sha** (7-40 hex
chars, short or full) — a branch or tag name is rejected so the
approval pins an immutable commit.
3. **hive-c0re immediately fetches that commit from the proposed
repo into the applied repo and tags it `proposal/<id>`.** It
resolves the sha locally against the proposed repo, fetches all
of proposed's heads into applied's object db, then tags the
resolved commit — `git fetch <remote> <sha>:<dst>` can't fetch
by a bare sha (the left side of a refspec is a remote *ref
name*), so the resolution happens on hive-c0re's side. The
approval row stores both the manager-supplied sha and the
canonical hive-c0re-vouched sha. From here on the proposed
repo is irrelevant for this approval — the manager can amend,
force-push, or `rm -rf` the proposed repo and the queued
approval still points at an immutable git object inside
applied.
3a. **Flake validation (ApplyCommit only):** after the proposal tag
is planted, hive-c0re reads `proposal/<id>:flake.lock` and
runs two checks (closes #317). If either check fails, no
pending approval is created for the operator — the row is
marked failed and surfaces on the dashboard with the
validation message:
- **Stale lock** — materialises the commit in a temp worktree,
runs `nix flake lock` (no `--update-input` flags, so it only
fills missing entries), and rejects if the committed
`flake.lock` differs from the result. Triggered when the
manager added or removed `inputs` in `flake.nix` without
re-running `nix flake lock`. Fix: run `nix flake lock` in
the config repo, commit, and re-submit.
- **Duplicate inputs** — groups lock nodes by their canonical
`original` field; rejects if two or more nodes share the same
source. This usually means an input is missing
`inputs.<x>.inputs.nixpkgs.follows = "nixpkgs"`. Fix: add
the `follows` directive, re-lock, and re-submit.
Both checks only flag *new* violations — agents whose lock
already carried duplicates before this check was added are
unaffected until a coordinated config-change pass via manager.
4. Operator sees the proposal as a card on the dashboard — a
full multi-file diff, toggleable between three bases (vs the
running tree / vs the last approved proposal / vs the
previous queued proposal) — and clicks ◆ APPR0VE (or
`hive-c0re approve <id>` on the CLI).
5. hive-c0re moves the working tree to `proposal/<id>` and runs
the build under a sequence of tags (see below). On success,
`applied/main` fast-forwards to the proposal commit. On
failure, main stays put and the working tree resets back to
the previous deployed commit.
6. `HelperEvent::ApprovalResolved` (and `Rebuilt` for the
ApplyCommit kind) land in the manager's inbox, carrying both
the canonical sha and the terminal tag.
### Withdrawing a pending approval
The manager can call `cancel_loose_end(kind: "approval", id)` to
withdraw an approval that hasn't been acted on yet (closes #250).
The row transitions to `ApprovalStatus::Cancelled` (distinct from
`Denied`/`Failed`), the dashboard pulls the card out of the
pending pane, and `ApprovalResolved { status: "cancelled" }` fires
on the manager + dashboard channels. Approvals that have already
been approved/denied/failed return an error — the resolution is
final once the operator (or a lifecycle failure) acted on the row.
Sub-agent surface refuses the `approval` kind with a clear error:
sub-agents don't submit approvals, so they have nothing of their
own to withdraw. Manager-only.
`InitConfig` approvals are the first step in a two-step spawn
flow. On approve, hive-c0re seeds the proposed config repo with
a default `agent.nix` template and sends the manager
`HelperEvent::ConfigReady { agent }`. The manager then reviews,
edits, and commits the template before calling `request_apply_commit`
to proceed to an `ApplyCommit` approval. The first `ApplyCommit`
creates the container; subsequent ones rebuild it with new config.
This gives the manager (and operator) an explicit review gate on the
initial configuration before any container is created.
## Meta flake
The hive-c0re-owned repo at `/var/lib/hyperhive/meta/`
declares one flake input per agent (`agent-<n>.url =
"git+file:///var/lib/hyperhive/applied/<n>"`) and one
`nixosConfigurations.<n>` output per agent. Each output wraps
`inputs.agent-<n>.nixosModules.default` with the identity +
`HIVE_PORT` / `HIVE_LABEL` / `HIVE_DASHBOARD_PORT` injection
module that `setup_applied` used to generate inline.
Containers run against `--flake /var/lib/hyperhive/meta#<n>`.
Per-deploy lock flow (two-phase, owned by
`actions::run_apply_commit` → `meta::{prepare,finalize,abort}
_deploy`):
1. `meta::prepare_deploy(name)` runs
`nix flake lock --update-input agent-<n>` without
committing. Working tree of meta now points the input at
`applied/<n>/main` (which `run_apply_commit` already
fast-forwarded to `proposal/<id>`).
2. `lifecycle::rebuild_no_meta` runs
`nixos-container update <c> --flake meta#<name>`. Nix
evaluates against the staged lock.
3. On success — `meta::finalize_deploy(name, sha, "deployed/
<id>")` stages `flake.lock` and commits with
`deploy <n> deployed/<id> <sha12>`. Meta's git log gains
one entry per successful deploy.
4. On failure — `meta::abort_deploy()` runs
`git restore flake.lock` so the meta history shows only
successes; the failure stays as an annotated `failed/<id>`
tag in `applied/<n>`.
Single-phase variants exist for paths without
rollback semantics: `meta::lock_update_for_rebuild(name)` for
the manual `↻ R3BU1LD` button (commits if the lock changed)
and `meta::lock_update_hyperhive()` for the
auto-update flake-rev bump (one shot before per-agent
rebuilds, commits if the lock changed).
`meta::sync_agents(hyperhive_flake, dashboard_port, &agents)`
is the idempotent reconciler called by `spawn`, `destroy`,
`rebuild`, and the startup migration. Renders `flake.nix`
from the agent list; if it differs from disk, runs
`nix flake lock` + commits as `regenerate meta flake` (or
`seed meta from N agent(s)` on the very first call).
The manager has `/meta` RO-bound inside its container:
`git -C /meta log --oneline` is the swarm-wide deploy log,
`cat /meta/flake.lock | jq '.nodes["agent-<n>"].locked'`
resolves which sha each agent is pinned at right now.
Dashboard surfaces the same info as a `deployed:<sha12>` chip
per container row.
## Two repos per agent
```
/var/lib/hyperhive/agents/<name>/config/ proposed — manager RW
└── <anything> # any files the manager
# wants in the commit.
# agent.nix is the
# convention entry
# point; flake.nix is
# tracked boilerplate
# (manager doesn't edit
# it).
/var/lib/hyperhive/applied/<name>/ applied — core-only
├── .git/ # tag-rich history
├── flake.nix # tracked, fixed
│ # boilerplate exporting
│ # nixosModules.default
├── agent.nix # working tree of main
└── <other manager files> # also tracked
/var/lib/hyperhive/meta/ swarm-wide flake — core
├── .git/ # one commit per successful
│ # deploy
├── flake.nix # generated from agent set
└── flake.lock # pins each agent's sha
```
Why two physical repos: the manager's `/agents/<n>/config/` is
RW — a buggy or hostile agent can `git clean -fdx` its own
proposed tree. The applied repo is never bind-mounted (except
the read-only `.git` exposure described below) so a destructive
move inside the container cannot reach it.
The container's `--flake` ref is `/var/lib/hyperhive/meta#<name>`
(see "Meta flake" above). The agent's own `applied/<n>/flake.nix`
is a fixed boilerplate that exports `nixosModules.default =
import ./agent.nix`; the meta flake imports that module and
wraps it with identity + `HIVE_PORT` / `HIVE_LABEL` /
`HIVE_DASHBOARD_PORT`.
### Tag state machine
Every approval id walks through a fixed set of tags on the
underlying commit inside the applied repo:
| Tag | When | Annotated? |
|---|---|---|
| `proposal/<id>` | request_apply_commit, after fetch | no |
| `approved/<id>` | operator approve | no |
| `building/<id>` | rebuild started | no |
| `deployed/<id>` | rebuild succeeded — `main` ff's here | no |
| `failed/<id>` | rebuild failed | yes (body = error) |
| `denied/<id>` | operator deny | yes (body = operator note) |
`applied/main` is always the latest `deployed/*`. `denied/` and
`failed/` are terminal; the manager submits a new commit + new
approval id to retry. Because tags are first-class git objects,
rejected and failed trees stay browsable forever — `git log
--tags` in the applied repo is the audit trail.
### Dispatch via `rebuild_queue` (#441)
Long-running approval work — `ApplyCommit`, `UpdateMetaInputs`,
`Spawn` — no longer runs inline inside `actions::approve`. Instead
the approval handler enqueues a `QueueEntry` into the global
`rebuild_queue`:
| `ApprovalKind` | `QueueKind` queued | `QueueSource` |
|---|---|---|
| `ApplyCommit` | `Rebuild` | `Approval` |
| `UpdateMetaInputs` | `MetaUpdate` | `Approval` |
| `Spawn` | `Spawn` | `Approval` |
| `InitConfig` | — runs inline (sub-second git seed) | — |
| `SchedulePrompt` | — runs inline (single sqlite insert) | — |
Each queue entry carries the originating `approval_id` so the
worker can re-fetch the approval row when it dispatches, run the
kind-specific pipeline (`run_approval_apply_commit` /
`run_approval_update_meta_inputs` / `run_approval_spawn`), and
fire the matching `HelperEvent::*` on completion via
`finish_approval`.
Two visible consequences:
- **Operator dashboard**: after clicking APPR0VE the work-in-progress
shows up on the *rebuild queue* card (`POST /api/state.rebuild_queue`
+ live `rebuild_queue_changed` events), not on the approvals panel
(which already moved the row to "approved"). A long meta-update
cascade renders as a parent entry with one child per per-agent
rebuild — see `docs/web-ui.md` for the layout.
- **Cancellation**: the dashboard's *× cancel* button on a `Queued`
entry calls `POST /api/rebuild-queue/{id}/cancel`, which flips the
entry to `Cancelled` before the worker dispatches it. Returns
`{"cancelled": true}` on success, `{"cancelled": false}` if the
entry already left `Queued` (running / done / failed) — terminal
states can't be retroactively rewritten.
`QueueSource::Approval` carries the `approval_id` so a tail-end
build failure surfaces back as a failed approval row, not just a
silent queue entry. `QueueSource::Manual` (dashboard ↻ R3BU1LD)
and `QueueSource::AutoUpdate` (boot-time sweep) use the same
queue but skip the approval row plumbing.
### Forge mirror
When the bundled `hive-forge` container is running — on by
default, `hyperhive.forge.enable` — hive-c0re mirrors every
agent's applied repo into a private `agent-configs` Forgejo
org. `forge::push_config(<name>)` pushes `applied/main` plus
every tag to `agent-configs/<name>` after each ref mutation:
the spawn that seeds `deployed/0`, every `request_apply_commit`
(which plants `proposal/<id>`), every approve / deny, and a
sweep at startup. Pushes are best-effort — a missing or stopped
forge never blocks a deploy.
The org is private and agents are not members, so only the
`core` user (a Forgejo site admin) can read it: an agent can't
reach another agent's config — or even its own — through the
forge. The tokenised push URL is passed inline to `git push`,
never written into `applied/<n>/.git/config`; that repo is
RO-bind-mounted into the manager, and a stored token would leak
core's admin credential to an agent.
The dashboard deep-links into this org — a `config repo` link
per container row and a `commit on forge` link per approval
card. See `docs/web-ui.md`.
### Manager view of applied + meta
The manager container gets three host-side bind mounts via
`set_nspawn_flags`:
- `/var/lib/hyperhive/agents/``/agents/` (RW) — proposed
repos. Manager edits + commits per-agent config here.
- `/var/lib/hyperhive/applied/``/applied/` (RO) — every
agent's authoritative applied repo, including `.git`.
- `/var/lib/hyperhive/meta/``/meta/` (RO) — the swarm-wide
deploy flake.
Each proposed repo (`/agents/<n>/config/`) is pre-configured
with `applied` as a git remote pointing at
`/applied/<n>/.git`. Useful incantations from inside the
manager:
```sh
git -C /agents/<n>/config fetch applied
git -C /agents/<n>/config log applied/main --oneline
git -C /agents/<n>/config show applied/refs/tags/deployed/<id>
git -C /agents/<n>/config show applied/refs/tags/failed/<id> # body = build error
git -C /agents/<n>/config show applied/refs/tags/denied/<id> # body = operator note
git -C /agents/<n>/config rebase applied/main # base in-flight work on what's deployed
git -C /meta log --oneline # swarm-wide deploy history
cat /meta/flake.lock | jq '.nodes | with_entries(select(.key | startswith("agent-")))'
```
The RO binds block push at the kernel level, so the manager
can only fetch / read — git plumbing inside the container
cannot corrupt either authoritative repo.
## Migration from the pre-tag / pre-meta schemes
Both overhauls (tag-driven flow + meta flake) ship in-place
migrations that run on every hive-c0re startup. Idempotent;
each phase is a no-op once already applied. Behaviour:
- Tag-driven phase: assumes the operator ran the one-shot
`git tag deployed/0 main` script (see commit history /
earlier docs revisions) once per agent. Tagging is
non-destructive: it doesn't touch live containers, state
dirs, or claude creds.
- Meta-flake phase: rewrites each `applied/<n>/flake.nix` to
the module-only boilerplate, wires the `applied` remote in
each proposed repo, bootstraps the meta repo from the
current agent list, and `nixos-container update`s every
container at `meta#<n>`. The expensive last step is
guarded by `/var/lib/hyperhive/.meta-migration-done` so
it only runs once across hive-c0re restarts. Set
`HIVE_SKIP_META_MIGRATION=1` on the service to defer.
No state loss in either migration. claude creds, /state/
notes, the events DB, proposed history, and applied history
all survive. The manager keeps its session; sub-agents stay
logged in.
## Manager (`hm1nd`) is hive-c0re-managed
The manager container runs through the **same lifecycle as
sub-agents**. On `hive-c0re serve` startup, if `hm1nd` is missing,
hive-c0re creates it. The manager's flake lives at
`/var/lib/hyperhive/applied/hm1nd/`; its proposed config at
`/var/lib/hyperhive/agents/hm1nd/config/`. Manager can edit its own
`agent.nix` (visible inside the container at `/agents/hm1nd/config/`)
and submit `request_apply_commit("hm1nd", <sha>)` for operator
approval.
Differences from sub-agents:
- `flake.nix` extends `hyperhive.nixosConfigurations.manager`
(vs `agent-base`).
- Container name is `hm1nd` (no `h-` prefix).
- Fixed web UI port (`MANAGER_PORT = 8000`).
- `set_nspawn_flags` adds two extra binds: `/var/lib/hyperhive/agents`
`/agents` (RW) so the manager can edit per-agent proposed repos,
and `/var/lib/hyperhive/applied``/applied` (RO) so the manager
can `git fetch` deployed/failed/denied tags from any agent's
authoritative applied repo (see "Manager view of applied" below).
- First-deploy spawn bypasses the approval queue (manager is
required infrastructure).
- Per-agent socket lives at `/run/hyperhive/manager/`, owned by
`manager_server::start`.
**Migration note** (for older hosts): drop any `containers.hm1nd =
{ ... }` block from your host NixOS config. hyperhive creates and
updates the manager itself.
## Manager policy
From `hive-ag3nt/prompts/manager.md`: the manager does NOT
rubber-stamp sub-agent config requests. It verifies (role match,
package legitimacy, cheaper alternative, blast radius) before
committing and calling `request_apply_commit`.
For ambiguous cases or anything that needs human signal, the
manager calls `ask(question, options?, multi?, ttl_seconds?, to?)`
queues the question and returns the id immediately. When `to` is
omitted (or `"operator"`) the question shows up on the dashboard;
when `to` is a sub-agent's name, the recipient receives a
`HelperEvent::QuestionAsked` and answers via their own `answer`
tool. Either way the answer arrives back as
`HelperEvent::QuestionAnswered { id, question, answer, answerer }`
in the asker's inbox. Storage is `hive-c0re::operator_questions`
(sqlite) — same table, with a nullable `target` column
(NULL = operator). Dispatch goes through
`hive-c0re/src/questions.rs::{handle_ask, handle_answer}` so both
the agent + manager surfaces stay aligned. The answer flow is:
```
POST /answer-question/{id} agent: Answer { id, answer }
→ OperatorQuestions::answer(_, _, "operator") → questions::handle_answer
→ notify_agent(asker, QuestionAnswered { → OperatorQuestions::answer(_, _, agent)
answerer: "operator", ... }) → notify_agent(asker, QuestionAnswered {
answerer: agent, ... })
```
Two more paths resolve a pending question with a sentinel answer:
- `POST /cancel-question/{id}` (✗ CANC3L button on the dashboard)
resolves with `[cancelled]`. The manager sees a terminal state
and can fall back.
- `ttl_seconds` deadline: a tokio watchdog spawned at submit time
fires `answer(id, "[expired]")` once the ttl runs out. Already-
resolved races no-op. The dashboard surfaces a `⏳ MM:SS` chip
on each pending question with a deadline.
## Helper events to the manager
`Coordinator::notify_manager(&HelperEvent)` enqueues an inbox
message from sender `system` with the event JSON in the body. The
manager harness no longer short-circuits these — they drive a
regular claude turn so the manager can react. Variants
(`hive_sh4re::HelperEvent`):
- `ApprovalResolved { id, agent, commit_ref, status, note }`
fired by `actions::approve` + `actions::deny` whenever an
approval transitions to its terminal state.
- `Spawned { agent, ok, note }``actions::approve` (first-time
ApplyCommit-kind) + admin `HostRequest::Spawn` (deprecated).
- `Rebuilt { agent, ok, note }``auto_update::rebuild_agent`
(covers startup scan + manual `/rebuild` from dashboard) +
`actions::approve` (ApplyCommit).
- `Killed { agent }` — admin `HostRequest::Kill` + dashboard
`/kill` + manager `Kill` MCP tool.
- `Destroyed { agent }``actions::destroy`.
- `ContainerCrash { agent, note }``crash_watch`: a previously-
running container went away with no operator-initiated transient
state (Stopping / Restarting / Destroying / Rebuilding) AND no
such transient was cleared in the last 30s (`RECENT_TRANSIENT_GRACE`
tombstone, three `POLL_INTERVAL`s — closes the race where a
lifecycle op finishes between two crash-watch polls and the
container shows briefly as "stopped without transient" before the
next start, #425). Manager can `start` it again or escalate.
- `NeedsLogin { agent }` — sub-agent has no claude session yet.
Manager can't act directly (interactive OAuth); typically flags
the operator.
- `LoggedIn { agent }` — sub-agent just completed login. Manager
often greets the agent on this event.
- `ConfigReady { agent }` — a new agent's proposed config repo was
just seeded (post-`InitConfig` approval). The manager can now
edit `/agents/<agent>/config/agent.nix`, commit the changes,
and submit `request_apply_commit` with the commit sha to create
the container (first ApplyCommit also triggers spawn bookkeeping).
- `NeedsUpdate { agent }` — sub-agent's recorded flake rev is
stale. Manager calls `update(name)` to rebuild — idempotent,
no approval required.
- `QuestionAnswered { id, question, answer, answerer }`
dashboard `/answer-question/{id}` (answerer = `"operator"`),
peer `Answer` request (answerer = agent name), or ttl watchdog
expiry (answerer = `"ttl-watchdog"`, answer = `"[expired]"`).
- `QuestionAsked { id, asker, question, options, multi }`
fired when an agent calls `Ask { to: Some(<this-agent>), ... }`.
The recipient responds via `Answer { id, answer }` and the
asker sees the matching `QuestionAnswered`.
To add a new event: new `HelperEvent` variant + call sites + update
`prompts/manager.md` so the manager knows the new shape.
## Auto-update on startup
`hive-c0re serve` runs `auto_update::run` in a background task right
after opening the coordinator. It enumerates managed containers and
rebuilds any whose recorded hyperhive rev differs from the current
one — sub-agents and manager go through the same `lifecycle::rebuild`
path.
"Rev" = canonical filesystem path of `cfg.hyperhiveFlake`. Marker
file: `/var/lib/hyperhive/applied/.<name>.hyperhive-rev`. If the
flake input has no canonical path (e.g. a `github:` URL),
auto-update is a no-op — rebuild manually.
The dashboard surfaces pending updates per agent: a clickable
"needs update ↻" badge appears whenever the marker differs from
current rev. The badge POSTs `/rebuild/<name>`, calling the same
`auto_update::rebuild_agent` path so manual triggers and the
startup scan can't drift. When at least one container is stale, a
top-level `↻ UPD4TE 4LL` button appears that loops over every
stale container.

59
docs/boundary.md Normal file
View file

@ -0,0 +1,59 @@
# The operator/agent boundary
Design rationale for hyperhive's two-principal trust model. The
*implementation* work — container network isolation, the unifying
gateway, core-daemon privsep — is tracked as `area:ops` issues on
the forge.
Today "the operator surface" and "the agent surface" are a
*convention*, not a boundary — nothing stops a container from
curling the core daemon on `localhost:<port>`, or another agent's
web UI. Network isolation, the gateway, and privsep together turn
that convention into an enforced boundary.
## Two principals, two paths
- **Operator** — reaches every UI (the dashboard + every
per-agent page) through the gateway, on one origin.
Operator-authority actions (approve / deny, answer-as-operator,
lifecycle POSTs) are served by the core daemon and only
reachable via the gateway.
- **Agent** — speaks only for itself, only over its per-agent
unix socket. The socket's identity *is* the agent (see
`docs/conventions.md`, "identity = socket"). An agent must not
be able to reach the core daemon's HTTP surface, another
agent's socket, or another agent's web UI.
## Design rule
**Operator-authority actions never get a per-agent-socket entry
point.** They live on the core backend.
Worked example — answering an operator-targeted question is a
`POST /answer-question/{id}` on the core dashboard, *never* an
`AgentRequest` variant. If it were a per-agent-socket request, an
agent could `curl` its own socket and spoof an operator answer.
The per-agent web UI POSTs cross-origin to the core for these
(see the inline-answer feature — the loose-ends section on each
agent page).
## Why network isolation is the load-bearing step
Containers currently share the host network namespace, so a
container can reach `localhost:<core-port>`, the dashboard, and
every other agent's web port. Until that changes, the
operator/agent split is on the honour system — every boundary
claim above is aspirational. Network isolation is what makes the
boundary *real*; the gateway and privsep are ergonomics and
defence-in-depth layered on top.
Suggested sequencing of the `area:ops` issues:
1. **Gateway** first — pure ergonomics win, unblocks same-origin
(lets the cross-origin CORS shim on `/answer-question/{id}` go
away), no behavioural risk.
2. **Network isolation** next — the step that makes the boundary
real. Everything before it is honour-system.
3. **Privsep** last — defence in depth on the core process
itself; valuable independent of the other two, but the
biggest refactor.

79
docs/conventions.md Normal file
View file

@ -0,0 +1,79 @@
# Conventions
Code-style and process expectations across the workspace. Most of these
exist because something already went wrong without them.
## Naming
- Containers are length-bounded by `nixos-container` (≤ 11 chars).
- Sub-agents are `h-<name>` with `<name>` ≤ 9 chars.
- The manager is `hm1nd` (no `h-` prefix, fixed name).
- `MAX_AGENT_NAME` in `lifecycle.rs` enforces the cap.
- Per-agent web UI port = `WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE`
(8100..8999); manager fixed at 8000; dashboard `cfg.dashboardPort`
(default 7000).
## Identity = socket
There are no auth tokens on the per-agent unix sockets. The socket
*path* identifies the principal; perms come from "who has the
bind-mount." A sub-agent only sees its own `/run/hive/mcp.sock`; the
manager has access to its privileged socket; hive-c0re owns the host
admin socket.
## Wire protocol
JSON line-delimited over unix sockets in both directions (host admin
/ manager / agent). SSE streams (`/dashboard/stream` on hive-c0re,
`/events/stream` on the per-agent web UIs) are `text/event-stream`;
each frame carries a `seq` field for the snapshot-dedupe dance
(see `docs/web-ui.md`). Request/response types live in `hive-sh4re`
— change them in one place. The dashboard event vocabulary lives
in `hive-c0re::dashboard_events::DashboardEvent`.
## Async forms
Dashboard + per-agent mutating forms carry `data-async`; a delegated
`submit` listener in `assets/tabs.js` intercepts, shows a spinner,
POSTs `application/x-www-form-urlencoded` (axum's `Form` extractor
rejects multipart), calls `refreshState()` on success. New mutating
forms should add `data-async` and optionally `data-confirm` (for a
JS-side `confirm()` prompt) or `data-prompt="…"` (for a
`window.prompt()` whose answer goes into a hidden input named by
`data-prompt-field`, default `note`).
`refreshState` defers automatically when `document.activeElement`
sits inside a managed section so the operator's typing isn't lost;
collapsible `<details data-restore-key=…>` survive the re-render
via `snapshotOpenDetails` / `restoreOpenDetails`.
## `rebuild` is the reconcile verb
`lifecycle::rebuild` idempotently rewrites
`/etc/nixos-containers/<C>.conf` (`PRIVATE_NETWORK=0`, clears
`HOST_ADDRESS` / `LOCAL_ADDRESS`, sets `EXTRA_NSPAWN_FLAGS`),
regenerates `applied/<name>/flake.nix`, writes the systemd limits
drop-in, then `nixos-container update` + stop + start.
Anything that changes per-container state on the host should be
re-applied here so a manual `↻ R3BU1LD` from the dashboard is
sufficient to recover.
## Actions are factored
`approve` / `deny` / `destroy` (and the lifecycle helper) live in
`actions.rs` / `dashboard.rs`. The admin socket and the dashboard
POST handlers both call into them so the two surfaces never drift.
## Commit messages
Short, lowercase, no `Co-Authored-By` trailer. Imperative mood, no
period. Body explains *why* if non-obvious; otherwise the subject
alone is fine. Wrap at ~72 cols.
## Commit before test
Stage and commit when work *looks* ready, then run validation
(`cargo check`, `nix flake check`, real deploy). Failures get a
follow-up commit rather than an amend. The commit history is the
work log; rewriting it loses signal.

View file

@ -0,0 +1,73 @@
# Migrating damocles onto hyperhive
The plan calls out damocles → hyperhive as a future migration. This doc lays
out the options + recommended path. Not yet executed.
## Current state (separate from hyperhive)
`damocles` is a declarative nixos-container on `muede-lpt2`:
- Declared in `nixosConfigurations/muede-lpt2/containers.nix`
- Built from `nixosConfigurations/damocles/` (claude-container.nix + extras)
- Bind-mounts (RO unless noted):
- `/etc/nix/distributed-build-key`
- `/persist/damocles-ssh`
- `/persist/damocles-lab` (RW — persistent work dir)
- `privateNetwork = false`
- Has its own systemd-services override (`TimeoutStopSec = 60s`, `RestartSec = 5s`)
- Hosts the user's primary day-job Claude Code session, not part of the swarm
## Options
### A. Sub-agent under hive-c0re
Make damocles a `hive-agent-damocles` (or whatever short name fits the 9-char cap).
hive-c0re owns its lifecycle; its config flake is the manager-editable
`/var/lib/hyperhive/agents/damocles/config/`.
Pros: uniform — message broker, dashboard, approval flow apply to damocles too.
Cons: a lot of damocles-specific state (bind-mounts, ssh keys, build keys) has to
be modeled as per-agent config. Today's `agent.nix` schema doesn't support
declaring bind-mounts; would need to extend. And the user's day-job session
becoming subject to hive-c0re lifecycle (restarts on rebuild) is invasive.
### B. Peer container (broker-integrated, lifecycle-independent)
Keep damocles declarative; have its harness install `hive-ag3nt` and connect to
the broker via a bind-mounted socket. damocles can send/recv messages with
other sub-agents but is not managed by hive-c0re.
Pros: low blast radius. damocles keeps its bind-mounts + its own restart policy.
Manager can route messages to it (it's just another inbox key on the broker).
Cons: two lifecycle mechanisms coexist forever; "damocles" doesn't appear in the
dashboard's container list (it filters `hive-` and `hm1nd`).
### C. Don't migrate
damocles stays out of hyperhive. The two systems coexist; the user's day-job
Claude and the swarm are deliberately separate.
Pros: zero work; aligns with the actual usage pattern (day-job vs. experiment).
Cons: no message routing between damocles and the swarm.
## Recommended
**C for now, B once cross-pollination is wanted.** Hyperhive's invariants
(11-char container names, manager-driven lifecycle, sealed `applied/` config)
fit poorly with damocles's role as the user's working Claude. Wait until there's
a concrete reason to wire them together (e.g. "I want to ask hm1nd from inside
damocles") and then do B — extend the broker socket bind into damocles and
install `hive-ag3nt` there. No need to subsume damocles under hive-c0re.
If/when option B is taken:
1. Add `${hyperhive.packages.${system}.default}/bin/hive-ag3nt` (or just
`pkgs.hyperhive`) to `nixosConfigurations/damocles/claude-container.nix`.
2. Bind-mount `/run/hyperhive/agents/damocles/` into damocles at `/run/hive/`.
On the host, this is just another dir hive-c0re needs to know about —
maybe expose a "peer agents" registration mechanism in the broker.
3. Run `hive-ag3nt serve` as a systemd unit inside damocles (separate from
the user's interactive claude session — broker peer, not turn-loop).
4. Optionally: add a `hive-c0re register-peer damocles` admin verb so the
container appears in `list()` and the dashboard. (Or just hard-list it.)
A is on the table only if the user's workflow shifts toward "the swarm IS the
day-job environment" — at which point damocles dissolves into a `hive-agent-*`
naturally.

149
docs/gotchas.md Normal file
View file

@ -0,0 +1,149 @@
# Gotchas
NixOS + nspawn quirks and lessons we hit the hard way. If something
here looks unmotivated in the code, there's usually a story underneath.
## `nixos-container` doesn't expose `--bind` on the CLI
The CLI doesn't accept `--bind`. Path is via `EXTRA_NSPAWN_FLAGS` in
`/etc/nixos-containers/<NAME>.conf` — the start script
(`/nix/store/.../container_-start`) expands it unquoted into the
`systemd-nspawn` invocation. `lifecycle::set_nspawn_flags()` rewrites
this line.
## `/run/systemd/nspawn/*.nspawn` overrides are ignored
`nixos-container`'s start script builds the nspawn command line
directly. Dropping a `.nspawn` file under `/run/systemd/nspawn/`
looks like the obvious extension point and does nothing. Use
`EXTRA_NSPAWN_FLAGS` (above).
## `boot.isNspawnContainer = true`
Not `boot.isContainer = true`. Renamed in nixos-25.11+.
## `nixos-container create` auto-assigns `HOST_ADDRESS` / `LOCAL_ADDRESS`
…in the `.conf`. The start script's `if HOST_ADDRESS set →
--network-veth` branch then forces a private netns — silently fatal
for our web UIs (the bind is invisible from the host). We
force-clear `HOST_ADDRESS` / `LOCAL_ADDRESS` / `HOST_ADDRESS6` /
`LOCAL_ADDRESS6` / `HOST_BRIDGE` and set `PRIVATE_NETWORK=0`.
## systemd service PATH ≠ host PATH
The hive-c0re service sets `path = [ pkgs.git "/run/current-system/sw" ]`.
In-container harness services do the same so anything an agent adds
to its own `agent.nix` (`environment.systemPackages`) is visible to
claude's Bash tool without editing the service definition.
`environment.HYPERHIVE_GIT` bakes git's absolute path in (read by
`lifecycle::git_command()`) for the host.
## `RuntimeDirectoryPreserve = "yes"`
…keeps `/run/hyperhive/` (and the per-agent sub-dirs) across
hive-c0re restarts. Without it, every restart wipes bind sources and
existing containers can't be started.
## `register_agent` is idempotent
Drops any prior socket task before rebinding. Required so a
hive-c0re restart followed by `rebuild alice` recreates the agent's
socket without needing a clean reinstall.
## `claude-code` is unfree
The flake pins it to **nixpkgs-unstable** via
`overlays.claude-unstable` (stable lags too far). The overlay sets
`config.allowUnfreePredicate` on its unstable import to whitelist
`claude-code` specifically — scoped, only this one package.
`harness-base.nix` does the same at the container level because
each per-agent `nixosConfiguration` evaluates its own nixpkgs
instance and the operator's host-level `allowUnfree` does **not**
propagate in. Operators don't need to set anything on their side.
## Claude credentials are per-agent
`/var/lib/hyperhive/agents/<name>/claude/` bind-mounts to
`/root/.claude` (RW). Sharing one dir across agents is NOT viable —
OAuth refresh tokens rotate, so any sibling refresh invalidates all
the others. Login flow runs from the per-agent web UI; creds persist
across `destroy`/recreate (`--purge` wipes them).
## Persistent notes dir per agent
`/var/lib/hyperhive/agents/<name>/state/` bind-mounts to `/state`
(RW). System prompts tell agents to keep durable knowledge here
(`/state/notes.md`, anything else under `/state/`). The harness also
writes its events log here (`/state/hyperhive-events.sqlite`).
Survives `destroy`/recreate alongside the claude dir.
## Web UI ports collide on hash
Sub-agent web UI ports are deterministic FNV-1a of the agent name
modulo 900 (range 8100..8999). With ~30 agents the birthday-paradox
collision rate gets meaningful; at 23 agents you can still get
unlucky. Operator resolves a collision by renaming the offending
agent (different hash → different port) and rebuilding. No state
file, no probing, no port-allocation drift — the value is
reproducible from just the name. Manager is fixed at 8000;
dashboard at `cfg.dashboardPort` (default 7000).
## Restart races on TCP bind
Both the dashboard and per-agent web UI use `tokio::net::TcpSocket`
with `SO_REUSEADDR` plus a retry-on-`AddrInUse` loop (12 tries,
exponential backoff capped at 2s, ~22s total). REUSEADDR handles
the `TIME_WAIT` case from a clean previous exit; retry covers the
genuine "previous process is still alive during a systemd restart
overlap" case. REUSEADDR does **not** allow two simultaneous
`LISTEN` sockets on the same port (that would be `SO_REUSEPORT`,
which we don't use) — exclusivity is preserved.
## Orphan approvals
If state dirs are wiped out from under a pending approval (test
scripts, manual `rm -rf`), the dashboard's next render marks them
`failed` with note `"agent state dir missing"` so they fall out of
`pending`. They stay in sqlite for audit.
## Nix store `cp -r` preserves read-only bits
Copying a nix store path with `cp -r src/. $out/` inside a
`pkgs.runCommand` derivation preserves the read-only permissions of
store files. Any subsequent write into the copied tree (adding new
files in subdirectories) fails with `EPERM`. Fix: pass
`--no-preserve=mode,ownership` so the output tree is writable.
## `hive-forge`: prefer over raw curl pipelines
Every agent container has `hive-forge` in PATH (installed via
`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary
since #280). Use it instead of ad-hoc curl pipelines:
```bash
hive-forge view 42 # title + body + comments
hive-forge comments 42 # list all comments (human-readable)
hive-forge --json comments 42 # same as above, JSON array (global flag, closes #421)
hive-forge comment 42 --body "..." # post comment (inline body)
hive-forge comment 42 --body-file - <<EOF # ...or pipe a HEREDOC
multi-line body
EOF
hive-forge assign 42 damocles
hive-forge close 42
hive-forge labels 42 add feature
hive-forge pr 42 # PR metadata as JSON
hive-forge diff 42 # unified diff (lockfile hunks collapsed by default)
hive-forge diff 42 --full # include unfiltered lockfile hunks
hive-forge branches deployed/ # filter branches by pattern
hive-forge -r other-org/other-repo pr 7 # target a different repo
hive-forge lint unassigned # open issues/PRs with no assignee
hive-forge lint no-reviewer --reviewer argus # PRs missing a reviewer comment from argus
hive-forge lint stale-branches --days 14 # branches with no recent activity
hive-forge lint assignments # per-assignee open item count
```
`hive-forge <verb> --help` prints the full signature for any verb.
Credentials come from `$HYPERHIVE_STATE_DIR/forge-token`; default
repo from `$HIVE_FORGE_REPO`, overridden per-invocation by the
global `-r/--repo` flag.

167
docs/persistence.md Normal file
View file

@ -0,0 +1,167 @@
# Persistence + retention
Where state lives, what survives what, and how it's bounded.
## Two sqlite databases
### `/var/lib/hyperhive/broker.sqlite` (host)
Three tables, all in one file:
- `messages` — every inter-agent / operator-bound message.
`sender / recipient / body / sent_at / delivered_at / acked_at /
in_reply_to`. `in_reply_to` links a reply to its parent row id;
the dashboard and per-agent inbox render these as threaded rows.
- `approvals` — the queue. `agent / kind (apply_commit | spawn) /
commit_ref / requested_at / status / resolved_at / note`.
- `operator_questions``ask` / `answer` queue (despite the
file name, stores both operator-targeted + agent-to-agent
questions since the `ask` rename).
`asker / question / options_json / multi / asked_at /
deadline_at (ttl) / answered_at / answer / target`. `target IS
NULL` = operator path (dashboard); `target = '<agent>'` = peer
Q&A (`HelperEvent::QuestionAsked` pushed into target's inbox,
answered via `Answer` request). Migrated via `ALTER TABLE ADD
COLUMN` against `pragma_table_info`.
Retention:
- `Broker::vacuum_delivered` runs hourly via a tokio task in
`hive-c0re::main`. Drops delivered rows older than 30 days.
Undelivered rows are always kept (still in flight).
- Approvals and questions are kept indefinitely — both are
audit trails. `actions::destroy` and answered questions stay
visible to anything that queries by id.
### `/state/hyperhive-events.sqlite` (per agent)
Lives inside each container's bind-mounted `/state/` dir (host
path: `/var/lib/hyperhive/agents/<name>/state/hyperhive-events.sqlite`).
One table:
- `events(id, ts, kind, payload_json)` — every `LiveEvent` the
harness emits during turn loop execution.
The harness writes; the host vacuums. `hive-c0re::events_vacuum`
runs hourly and sweeps every existing agent state dir, deleting
rows older than 7 days. Age-only — no row cap — so a chatty turn
doesn't lose history sooner than a quiet one; disk pressure on a
sustained burst is the cheaper problem to have. Centralising
retention on the host means a misbehaving harness can't disable
its own vacuum and agents don't need any cleanup wiring of their
own.
Path overridable via `HYPERHIVE_EVENTS_DB` (for dev / no-`/state`
setups). On open failure the `Bus` falls back to no-store mode
rather than crashing the harness — events still broadcast over SSE,
just nothing persisted.
### `/state/hyperhive-turn-stats.sqlite` (per agent)
Per-turn analytics sink. One row per claude turn captures
identity (`model`, `wake_from`, `result_kind`), timing
(`started_at`, `ended_at`, `duration_ms`), cost (input / output /
cache_read / cache_creation token counts), behaviour
(`tool_call_count` + `tool_call_breakdown_json`), and post-turn
snapshot metrics (`open_threads_count`,
`open_reminders_count` — fetched via the same socket the harness
already uses for `GetOpenThreads` + `CountPendingReminders`).
Bin-loop helpers `build_row` + `record` land each row at
`turn_end`; writes are best-effort, a sqlite hiccup logs + lets
the turn loop continue.
No host-side vacuum yet — tracked as forge issue
[#10](http://localhost:3000/hyperhive/hyperhive/issues/10)
(target retention ~90 days, age-only sweep like events_vacuum).
### `/state/hyperhive-rate-limited` (per agent)
Sentinel file written by `Bus::emit_status("rate_limited")` when the
harness detects a 429 / rate-limit response from the Claude API, and
removed when the retry sleep expires (any subsequent status emit
clears it). The file's presence is checked by hive-c0re's
`container_view::is_rate_limited` on each `build_all` sweep (~10s) to
populate `ContainerView.rate_limited` for the dashboard. Survives a
harness restart (the Bus reads it back at boot and restores the flag),
so the badge remains accurate if hive-c0re restarts while the harness
is mid-sleep.
### `/state/hyperhive-model` (per agent)
Single-line text file holding the claude model name currently
selected for this agent (default `haiku` when absent). Written by
`Bus::set_model` whenever the operator flips it via `/model
<name>` in the web terminal. Read once at harness boot in
`Bus::new`. Path overridable via `HYPERHIVE_MODEL_FILE`.
Survives destroy/recreate, gone on `--purge`.
## State dirs (per agent)
Under `/var/lib/hyperhive/agents/<name>/`:
- `config/` — the proposed nix repo (manager-editable). Bind-mounted
**read-only** to `/agents/<name>/config` inside the sub-agent's own
container so the agent can inspect what defines it and request
precise changes from the manager; RW into the manager via the
`/agents` tree bind.
- `claude/` — claude OAuth credentials, bind-mounted RW to
`/root/.claude` inside the container.
- `state/` — durable notes, the events.sqlite db, and the
turn-stats sqlite db. Bind-mounted to `/agents/<name>/state`
inside the container (the manager still uses the legacy
`/state` mount point — same host path either way).
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
applied repo. Tracks `flake.nix` (module-only boilerplate; never
edited after first spawn) + `agent.nix` (the actual config; the
manager's edits land here via the approval flow) + any other
files the manager committed. `.git/` carries the proposal /
approved / building / deployed / failed / denied tag history.
Under `/var/lib/hyperhive/meta/` — the swarm-wide deploy flake.
Single repo for the whole host; `flake.nix` declares one input
per agent + one `nixosConfigurations.<n>` output per agent;
`flake.lock` is the canonical "what's deployed where." The git
log is the deploy audit trail (one commit per successful
deploy or hyperhive bump). Manager has this RO-mounted at
`/meta/`.
Marker file `/var/lib/hyperhive/.meta-migration-done` is
written by the startup migration after every container has
been repointed at `meta#<n>`. Removing it forces a re-run on
next hive-c0re start (idempotent — only the actual repoint
step would re-fire).
## Destroy vs purge
- `DESTR0Y` (default) — stops + removes the nspawn container,
drops the systemd drop-in, fails any pending approvals. State
dirs stay put; the agent appears in the dashboard's K3PT ST4T3
section as a tombstone with `⊕ R3V1V3` and `PURG3` actions.
`R3V1V3` queues a Spawn approval that reuses the kept state on
approve (no re-login).
- `PURG3` (opt-in via the dashboard button or
`hive-c0re destroy --purge <name>`) — DESTR0Y plus wipes
`/var/lib/hyperhive/{agents,applied}/<name>/`. Config history,
claude creds, /state/ notes, and the events db are all gone.
No undo.
The manager is non-destroyable from both paths (declarative
container; would fight with the host's NixOS config).
## Run-time dirs
`/run/hyperhive/` is tmpfs-backed (systemd `RuntimeDirectory=`) but
preserved across hive-c0re restarts via `RuntimeDirectoryPreserve=yes`.
Without that, every restart wipes bind sources and existing
containers can't be started.
- `/run/hyperhive/host.sock` — admin socket (host-side CLI).
- `/run/hyperhive/manager/mcp.sock` — manager-privileged socket.
- `/run/hyperhive/agents/<name>/mcp.sock` — per-sub-agent socket
(bind-mounted into the container as `/run/hive/mcp.sock`).
On startup, `Coordinator::register_agent` drops any prior socket
task before rebinding — idempotent so a hive-c0re restart followed
by `rebuild alice` recreates the agent's socket without a clean
reinstall.

33
docs/security.md Normal file
View file

@ -0,0 +1,33 @@
# Security model
## Nix builds and credential isolation (issue #240)
### Background
Agent containers bind-mount the host's `nix-daemon` socket. The host daemon may
have `sandbox-fallback = false` (strict NixOS defaults), which causes `nix build`
inside nspawn containers to fail — containers lack kernel user namespaces, so nix
cannot set up its build sandbox. `harness-base.nix` sets `sandbox-fallback = true`
so that builds fall back to unsandboxed execution rather than failing outright.
### Threat model
Unsandboxed nix builds run as `nixbld` users (non-root, typically UIDs 30001-30010).
Without sandbox isolation, a build derivation's builder script has read access to
any file in the container that the nixbld user can read.
**What is NOT exposed**:
- `/root/.claude/` — mode `0700`, owned by root. nixbld users cannot read it.
- `/state/forge-token` — written at mode `0600` by `hive-c0re/src/forge.rs`.
nixbld users cannot read it.
**Policy**: all credential files written to agent state directories MUST be mode
`0600` or stricter. Do not create world-readable secret files in agent state dirs.
### Long-term fix
The proper fix is to enable user namespaces inside nspawn containers
(`--private-users=inherit` in `EXTRA_NSPAWN_FLAGS`) so nix can set up its real
sandbox and `sandbox-fallback` becomes a true last resort. This requires verifying
bind-mount compatibility with user namespace UID mapping and is tracked as a TODO.

114
docs/terminal-rendering.md Normal file
View file

@ -0,0 +1,114 @@
# Per-agent terminal: row taxonomy (as built)
Snapshot of how the per-agent web UI's live pane renders each
event kind today. Source of truth lives in
`frontend/packages/agent/src/app.js` (`renderStream`, `fmtToolUse`,
`renderRichToolUse`, `renderToolResult`, `renderTaskEvent`,
`mdNode`, `detailsOpenMd`, `fmtArgsGeneric`) +
`frontend/packages/shared/src/terminal.css` (the shared
`.live .<class>` styling) + the `marked` npm package (markdown).
## Layout contract
Every row — flat `<div class="row …">` and expandable
`<details class="row …">` alike — shares one prefix column.
The mechanism is `padding-left + negative text-indent` on
`.live .row`: the row's first character (the prefix glyph)
gets pulled back into the column at ~0.5em, and wrapped
continuation lines hang under the body, not under the glyph.
`<details>` summaries inherit those metrics. The disclosure
marker (`▸` / `▾`) is supplied by CSS `summary::before` so it
lands in the same column as flat-row glyphs. To make that
work the JS-side summary text **does not** include a
directional `→` / `←` — the row's colour (cyan = outbound,
muted = inbound) carries the direction, and the prefix
column never has to fit two glyphs side-by-side.
Child blocks inside a row (the `.md` markdown wrapper, an
inner `<details>`) get `text-indent: 0` so their content
lays out from the body column instead of inheriting the
parent's negative pull.
## Row taxonomy
| CSS class | Prefix glyph | Color | Triggered by | Source |
|---|---|---|---|---|
| `.turn-start` | `◆ TURN ← <from>` | amber, left rule | `LiveEvent::TurnStart` | harness wake |
| `.turn-body` | (child div under turn-start) | fg | same | the wake-prompt body |
| `.turn-end-ok` | `✓ turn ok` | green, left rule | `LiveEvent::TurnEnd { ok: true }` | harness |
| `.turn-end-fail` | `✗ turn fail — note` | red, left rule | `LiveEvent::TurnEnd { ok: false }` | harness |
| `.text` | (no prefix; markdown body) | fg | claude `assistant.content[].text` | stream-json |
| `.thinking` | `· thinking …` | muted, italic | claude `assistant.content[].thinking` | 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 open>` | `send → to · NL`, `ask → to`, `answer #id` | cyan, body is markdown | rich renderer for send / ask / answer | 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 open>` | `recv ← <txt>` | muted, body is markdown | `tool_result` correlated to a prior `recv` tool_use via id | stream-json |
| `.tool-use` | `⌁ task <id> started · <desc> [type]` | cyan | claude Task-tool subagent start | `renderTaskEvent` |
| `.turn-end-ok` / `.turn-end-fail` / `.tool-result` | `⌁ task <id> ✓/✗/◌ <status> · <desc> · → <output_file>` | green / red / muted | claude Task-tool result | `renderTaskEvent` |
| `.note` | `· <text>` | muted | harness chatter | `LiveEvent::Note` |
| `.note.stderr` | `! stderr: <line>` | amber/orange | stderr lines off claude | `LiveEvent::Note` (`text` starts `stderr:`) |
| `.note.op` | `· operator: <text>` | mauve italic | operator-initiated notes (/cancel, /compact, /model, new-session) | `LiveEvent::Note` (`text` starts `operator:`) |
| `.sys` | `! {json…}` | amber/orange | catch-all for stream shapes `renderStream` didn't classify | catch-all |
| Banner shimmer | mauve | turn in flight (ref-counted) | — | `setBannerActive` |
## Renderer dispatch
`renderStream(v, api)` walks each stream-json line:
1. Drops `system/init`, `rate_limit_event`, `result` (noise /
handled elsewhere — `result` powers the `cost` badge).
2. `subtype == "task_started" | "task_notification"`
`renderTaskEvent` (subagent activity gets the `⌁` glyph).
3. `type == "assistant"` → walk `message.content[]`:
- `text``.text` row with a markdown body via `mdNode`.
- `thinking``.thinking` row.
- `tool_use` → record `id → name` in `toolNameById`, try
`renderRichToolUse` (Write/Edit/send/ask/answer get
custom renderings); on miss fall through to a flat
`.tool-use` row with `fmtToolUse → fmtArgsGeneric`.
`fmtToolUse` surfaces the salient arg per built-in tool —
e.g. `recv` shows `wait <N>s` / `max <N>` when set (bare
`recv()` otherwise), `Bash` flags `[bg]` for
`run_in_background` commands.
4. `type == "user"` → walk `message.content[]` for
`tool_result`; `renderToolResult` correlates via
`tool_use_id → toolNameById` to default-open `recv`
results with a markdown body, else short = flat /
long = collapsed details.
5. Unrecognised shape → `.sys` row (amber, `!` glyph).
## Markdown
`mdNode(text)` wraps `marked.parse(text)` (the `marked` v4.x npm
dep, bundled by esbuild into the page's `app.js`) in a `<div
class="md">`. CSS in `terminal.css` scopes paragraph / code /
list / blockquote / link styling under `.live .row .md` so
the markdown body doesn't bleed into the row's own
text-indent. Falls back to plain text if `marked` didn't
load. Applied to `text` rows and to send / ask / answer /
recv message bodies.
## Extra-MCP tools
`fmtArgsGeneric(name, input)` is the fallback when a tool
isn't in the built-in `fmtToolUse` switch:
- single string field → `name k: "v"`
- single number/bool field → `name k: v`
- multi-field → first 4 pairs trimmed to `k: "v"` /
`k: [N]` / `k: {…}` with a `…+N` overflow
This keeps `mcp__matrix__send_message` and similar from
dumping raw JSON.
## Dashboard side (not covered here)
The main dashboard's message-flow pane is a different
shape: broker messages render as `.msgrow` grid lines (ts /
arrow / from / → / to / body) with their own styling.
`.live .msgrow` explicitly resets `text-indent: 0` so the
per-agent terminal's hanging-indent metrics don't leak into
the flex-grid broker rows.

423
docs/turn-loop.md Normal file
View file

@ -0,0 +1,423 @@
# Turn loop + MCP
How the harness wakes up, what it asks claude to do, and what tools
claude has access to in return.
## The loop
Each agent harness (`hive-ag3nt serve` or `hive-m1nd serve`) runs:
1. Long-poll `Recv` on its socket. The host-side broker
(`broker.rs::recv_blocking_batch`) returns immediately if there's
a pending message, otherwise waits up to 30 s for a broker `Sent`
event for this recipient.
2. Pop one message. Peek the remaining inbox depth with `Status`.
3. Emit `LiveEvent::TurnStart { from, body, unread }` onto the SSE
bus.
4. Spawn claude (one process per turn) and pipe the wake prompt
over stdin.
5. Stream stdout (JSON lines) into the bus as
`LiveEvent::Stream(value)`. Pump stderr as `Note`.
6. Wait for claude to exit. Compaction is two-pronged — *reactive*
on `Prompt is too long` and *proactive* on a context watermark
(see [Compaction](#compaction) below). **Rate-limit detection**:
on stderr the harness does a raw-line match for `429` /
`rate_limit` markers; on stdout it only fires on parsed
`{"type":"error"}` JSON events (avoiding false positives when
agents discuss `rate_limit_error` in conversation text). On
detection the harness sets the `rate_limited` sentinel
(`Bus::emit_status("rate_limited")`), sleeps
`HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), then retries.
The dashboard and per-agent page show a `⊘ rate limited` badge
while the harness is parked. **Auth-failed detection** (closes
#419): both stdout and stderr pumps also match
`AUTH_FAIL_MARKERS` (`"authentication_failed"`, `401`, etc.).
On match the harness writes `{state_dir}/hyperhive-needs-login`,
emits `needs_login_idle` status, requeues the inflight message
(so it replays after re-auth), and parks in `wait_for_login`
the same path used at boot. The operator re-authenticates via
the per-agent web UI login flow; on success the sentinel is
cleared and the queued message drives the next turn normally.
7. Emit `LiveEvent::TurnEnd { ok, note }`. Sleep `poll_ms` to avoid
tight loops on transient failures.
## The claude invocation
```
claude --print --verbose --output-format stream-json --model <name> \
--continue --settings /run/hive/claude-settings.json \
--system-prompt-file /run/hive/claude-system-prompt.md \
--mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \
--tools <builtins> --allowedTools <builtins+mcp>
# wake prompt piped over stdin
```
`<name>` is read from `Bus::model()` on each turn. The initial
default is set by `hyperhive.model` in the agent's `agent.nix`
(NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls
back to `"haiku"` if unset). The operator can flip it at runtime
with `/model <name>` in the web terminal — the next turn picks it
up. The choice is persisted to `/state/hyperhive-model` so it
survives restart; override path: `HYPERHIVE_MODEL_FILE` env var
for tests.
Context-window size is looked up per-model via
`events::context_window_tokens(model)`. Resolution order (first
match wins):
1. `HIVE_CONTEXT_WINDOW_TOKENS_<KEY>` env var, where `KEY`
(lowercased) is a substring of the active model name. Injected
by the meta flake from `services.hive-c0re.contextWindowTokens`
(host-level NixOS option, defaults: haiku=200k, sonnet=1M,
opus=1M). Override these for all agents at once without a
per-agent config change.
2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override for any
model (useful in dev / test).
3. Hard fallback: `200_000` (conservative; only reached outside
NixOS where the env vars aren't set).
The effective window drives watermarks and is exposed at runtime
via `/api/state.context_window_tokens` so the UI can show a
percentage-of-window ctx badge.
`--continue` keeps a persistent session per agent (claude stores
sessions in `~/.claude/projects/`, which is bind-mounted
persistently). Auto-compact and auto-memory are disabled via
`--settings` because hyperhive owns compaction — see
[Compaction](#compaction) below.
A one-shot `--continue` suppression is available via
`POST /api/new-session` (or `/new-session` slash command in the
per-agent terminal) — `Bus::take_skip_continue()` flips an
`AtomicBool` once per turn, the next claude invocation drops
`--continue`, every subsequent turn resumes normal behaviour.
### Compaction
claude's own in-session auto-compact is off (`--settings`); hyperhive
owns it explicitly in `turn::drive_turn`. There are two triggers:
- **Reactive** — claude-code prints `Prompt is too long` (the
`PROMPT_TOO_LONG_MARKER`). The session is *already* past the context
window, so no turn can run on it — `drive_turn` runs `/compact`
straight away and retries the same wake-up prompt once. No
notes-checkpoint turn is possible here: the detail is gone.
- **Proactive** — a turn finishes cleanly but the last inference's
context size (`Bus::last_ctx_usage().context_tokens()`) is at or
above a watermark. While the session is still healthy, `drive_turn`
injects one synthetic *notes-checkpoint* turn (`CHECKPOINT_PROMPT`
— "context is filling up, flush durable state into `/state` now")
and *then* runs `/compact`. This gives the agent a chance to
persist in-flight task state, decisions, and file paths before the
conversation detail collapses into a summary.
The compact watermark defaults to **75% of `context_window_tokens(model)`**
(dynamically derived — 150k for haiku, 750k for sonnet/opus). Override
with `HIVE_COMPACT_WATERMARK_TOKENS` (absolute token count); set to `0`
to disable proactive compaction entirely (the reactive path always
applies). The proactive path is best-effort — a failed checkpoint turn
or `/compact` is surfaced as a `Note` but never fails the turn that
already succeeded. The operator can also force a compaction any time
via `/api/compact`.
- **Auto session-reset** — a third path that fires when both
conditions hold: context is ≥ a watermark (`HIVE_AUTO_RESET_WATERMARK_TOKENS`,
default **50% of `context_window_tokens(model)`**) AND the time since
the last turn exceeds the assumed prompt-cache TTL
(`HIVE_CACHE_TTL_SECS`, default `3600`).
Claude's prompt cache lives ~5 minutes; if the cache is already
cold, resuming with `--continue` pays the full re-upload cost of
the current context with no benefit over starting fresh. So:
`drive_turn` injects one `AUTO_RESET_CHECKPOINT_PROMPT` notes turn
("flush state to files, cache is cold") then arms
`Bus::take_skip_continue()` for the real turn — the next turn runs
without `--continue`, starting a fresh session. Unlike proactive
compaction the session is dropped entirely, not compacted. Set
`HIVE_AUTO_RESET_WATERMARK_TOKENS=0` to disable.
The child runs with `cwd = /state` (when the bind exists; falls
back to the parent's cwd in dev), so any relative path in a tool
call (`Read foo.md`, `Bash ls`, `Write notes.md`) lands in the
agent's durable bind-mounted dir. CLAUDE.md auto-load walks
upward from `/state` — drop a per-agent CLAUDE.md there if you
want long-term hints that survive destroy/recreate.
The wake prompt is intentionally minimal: just the popped message's
`from`/`body`, plus an inline `({unread} more pending — drain via
…)` hint when `unread > 0`. Claude drives any further `recv`/`send`
itself via the embedded MCP server.
Whenever hive-c0re starts / restarts / rebuilds a container, it
also drops a `system` message into the agent's inbox via
`Coordinator::kick_agent` — a one-line "you were just (re)started,
check /state/ for your notes, --continue session is intact". The
next turn picks it up like any other inbox message.
### On-boot files
`hive_ag3nt::turn::write_*` writes three files next to the per-agent
socket at `/run/hive/` once at startup:
- `claude-mcp-config.json` — re-invokes the running binary as `mcp`
child (so the same binary serves as harness + as claude's MCP
child process).
- `claude-settings.json` — the `--settings` blob (auto-compact and
auto-memory off, effortLevel medium).
- `claude-system-prompt.md` — rendered from
`hive-ag3nt/prompts/{agent,manager}.md` with `{label}` and
`{operator_pronouns}` substituted. Pronouns come from
`HIVE_OPERATOR_PRONOUNS` env (set by the meta flake from
`services.hive-c0re.operatorPronouns`, default `she/her`).
Passed via `--system-prompt-file`.
The shared per-turn plumbing lives in `hive_ag3nt::turn::{write_mcp_config,
write_settings, write_system_prompt, run_turn, drive_turn,
emit_turn_end, wait_for_login, compact_session}` so the two binaries
can't drift.
## MCP surface
The harness ships an embedded MCP server (rmcp 1.7). Claude launches
it as a stdio child via `--mcp-config`. The hyperhive socket name is
`hyperhive`, so the tools land in claude as `mcp__hyperhive__<tool>`.
### Sub-agent tools
- `send(to, body, in_reply_to?)` — message a peer (logical agent
name), another agent, or the operator (recipient `operator`,
surfaces in the dashboard inbox). Optional `in_reply_to: i64`
links this message to a prior message id for thread rendering
in the dashboard message flow and the per-agent inbox.
- `recv(wait_seconds?, max?)` — drain inbox messages. Without
`wait_seconds` (or with `0`) returns immediately, a cheap
"anything pending?" peek. Positive value parks the turn up
to that many seconds (cap 180) — incoming messages wake
instantly, otherwise returns empty at the timeout. `max`
(default 1, server-side cap 32) drains up to N popped rows
in one round-trip; `wait_seconds` applies to the *first*
message, then the call drains up to `max` total.
- `ask(question, options?, multi?, ttl_seconds?, to?)`
surface a structured question. Same shape as the manager's;
recipient defaults to the operator (dashboard) but can be set
to a peer agent name via `to: "<agent>"`. Answer routes back
to the asker's own inbox as `HelperEvent::QuestionAnswered`
via `coord.notify_agent`. For peer questions the recipient
sees a `HelperEvent::QuestionAsked` event and replies with
`answer(id, answer)`.
- `answer(id, answer)` — respond to a `question_asked` event
routed to this agent. Authorisation is strict: only the
declared target (or the operator via the dashboard) can
answer.
- `get_loose_ends()` — list everything still pending against
this agent: unanswered questions it asked / was asked, plus
reminders it scheduled. Each row carries an id + kind for
`cancel_loose_end`.
- `cancel_loose_end(kind, id)` — withdraw a `question`
(posts `[cancelled by <self>]` to unblock the asker), a
`reminder` (hard-delete before fire), or (manager only) an
`approval` (transitions to `Cancelled`; sub-agents refused with a
clear error). Sub-agents may only cancel rows they own.
- `remind(message, due)` — schedule a reminder that lands in
this agent's own inbox at a future time (sender shows as
`reminder`). Large payloads spill to
`/agents/<self>/state/reminders/` with the inbox message a
short pointer. Each agent's pending-reminder count is capped
(default 50, override via `HIVE_REMIND_MAX_PENDING_PER_AGENT`);
scheduling a new one fails if the cap is already hit.
- `set_status(text)` — set a free-text status string visible on
the operator dashboard. Persisted to
`{state_dir}/hyperhive-status`; survives harness restarts. Pass
an empty string to clear.
- `get_agent_meta(name?)` — fetch identity + status metadata for
an agent: `{ name, role, hyperhive_rev, running, status_text,
status_set_at }`. Pass `name` to query a peer (e.g. check
whether a sub-agent is idle before sending it work). Omit
`name` to get your own identity stamp — replaces the previous
`whoami` tool. `running` is `true` when the container is up.
When `running` is `false` the host clears `status_text` /
`status_set_at` (they would be stale snapshots from before the
container stopped) before serving the response. Status fields are
also `None` when the target has never called `set_status` or
has cleared it.
- `request_next_turn()` — ask the harness to start another turn
immediately after this one ends, even if the inbox is empty. Use for
multi-turn tasks (long builds, sequential steps) where you want to
continue without waiting for an external message. The next turn starts
with `from: "self"` and `body: "continue"`. No-op if new inbox
messages arrive before this turn ends. No args.
### Waking the agent from inside the container
External MCP servers (and any other in-container process) can
inject a wake-up event into the agent's inbox via the per-agent
socket at `/run/hive/mcp.sock`. Two equivalent paths:
- **Shell out to `hive-ag3nt wake --from <label> --body <text>`**
(use `--body -` to read body from stdin). Already on the
container's `PATH` since the harness binary is in
`systemPackages`. Convenient for shell-script integrations.
- **Speak the wire protocol directly** — JSON-line over the
unix socket: `{"cmd":"wake","from":"matrix","body":"new dm
from @alice"}\n`. Same shape any other AgentRequest uses;
see `hive-sh4re::AgentRequest::Wake`.
The wake event lands in the broker as `{from:<label>,
to:<agent>, body}`, which wakes whatever `recv` call the
harness is currently blocked on. Next turn fires with the
wake prompt formed from that message — claude sees "from:
matrix" (or whatever label) and reacts.
Identity = socket: anything that can connect to
`/run/hive/mcp.sock` is implicitly trusted to inject these,
which is fine because the bind-mount is the agent's own
container only.
### Extra MCP servers (per-agent)
Each agent's NixOS config can declare additional MCP servers via
`hyperhive.extraMcpServers.<key> = { command, args, env,
allowedTools }`. The module writes the map to
`/etc/hyperhive/extra-mcp.json`; the harness reads it at boot and
merges every entry into `--mcp-config` (under `mcpServers.<key>`)
and `--allowedTools` (as `mcp__<key>__<pattern>`). The agent's
flake.nix forwards every flake input to `agent.nix` as the
`flakeInputs` module arg, so external MCP-server flakes are pulled
in by adding them to `inputs.*` and referenced as
`flakeInputs.<name>.packages.${pkgs.system}.default` — the
resolved sha lands in the agent's own `flake.lock` and rolls up to
meta's.
### Manager tools (in addition to send/recv)
- `request_init_config(name, description?)` — first step of a
two-step spawn. Queues an `InitConfig` approval (≤9 char name);
on operator approve, hive-c0re seeds the proposed config repo
with a default `agent.nix` template and sends the manager a
`HelperEvent::ConfigReady { agent }`. The manager then edits
`agent.nix`, commits the changes, and calls `request_apply_commit`
with the commit sha — the first ApplyCommit on a freshly-init'd
config creates the container. Fails if a proposed repo for this
name already exists. (The pre-#442 path through a separate
manager-side `request_spawn` was removed; operator can still
direct-spawn an empty agent from the dashboard's `◆ R3QU3ST SP4WN`
button which routes via `HostRequest::RequestSpawn`.)
- `kill(name)` — graceful stop. No approval required.
- `start(name)` — start a stopped sub-agent. No approval.
- `restart(name)` — stop + start. No approval.
- `update(name)` — rebuild (re-applies the current hyperhive flake
+ agent.nix, restarts). No approval, idempotent. Manager calls
this on receipt of a `needs_update` system event.
- `request_apply_commit(agent, commit_ref)` — submit a config
change for any agent (`hm1nd` for the manager's own config) for
operator approval.
- `request_update_meta_inputs(inputs?, description?)` — queue an
approval to run `nix flake update [inputs...]` on the meta flake.
Pass specific input names (e.g. `["bitburner-agent"]`) or omit
for all. Returns immediately; lock update runs on operator
approval. Does NOT trigger rebuilds — call `update(name)` on
affected agents after approval resolves.
- `ask(question, options?, multi?, ttl_seconds?, to?)`
surface a structured question to the operator (default) or a
sub-agent (`to: "<agent>"`). Non-blocking — returns the
queued question id; the answer arrives later as
`HelperEvent::QuestionAnswered { id, question, answer,
answerer }` in the asker's inbox. Options always render
alongside a free-text fallback; `multi=true` renders options
as checkboxes. `ttl_seconds` auto-cancels with answer
`[expired]` (and `answerer: "ttl-watchdog"`) after the
deadline (useful for time-sensitive decisions that become moot
if no one has responded). The operator can also manually
cancel with `[cancelled]` via the dashboard.
- `answer(id, answer)` — respond to a `question_asked` event
that was routed to the manager (a sub-agent did
`ask(to: "manager", ...)`). Surfaces in the asker's inbox as
the same `question_answered` event.
- `get_logs(agent, lines?)` — fetch recent journal lines for a
sub-agent container (diagnose MCP-registration failures,
startup crashes, etc.). Pass the plain logical agent name;
hive-c0re resolves the machine name (`h-<name>`, manager
`hm1nd`). `lines` defaults to 50, host-capped at 500.
- `request_schedule_prompt(targets, body, first_fire_at_unix, interval_seconds?, description?)`
queue an operator-approval for a scheduled prompt. On approve,
`body` is fanned out to each `targets` agent at
`first_fire_at_unix`; recurring if `interval_seconds` is set,
one-shot otherwise. Even self-targeted schedules go through
approval (use `remind` for unapproved self-wake). Long downtime
fires once per recurring row on resume (catch-up clamp).
- `edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)`
partial-update a schedule (#474/#478). Pass only the fields to
change; absent fields are left alone. `targets_add` / `targets_remove`
mutate the recipient list in the same transaction; re-adding a
previously-cancelled target drops its tombstone + history (fresh
start). Clearing a scalar (e.g. `interval_seconds: null`) flips
recurring→one-shot. Refuses cancelled rows. Same authorization as
`cancel_schedule`.
- `cancel_schedule(id, targets?)` — cancel a schedule. Omit
`targets` / pass empty to cancel the whole schedule; pass a list
to cancel just those recipients (auto-cancels when every target
is removed). Authorization: manager can cancel schedules it owns
or any owned by a sub-agent in its topology subtree.
- `fire_schedule_now(id)` — fire a scheduled prompt out of band.
Runs the per-target fan-out once immediately. Recurring schedules
keep their cadence (the manual fire is additive); one-shot
schedules are consumed by the fire and cancelled afterwards. Same
authorization rules as `cancel_schedule`.
- `list_schedules()` — snapshot every schedule (active +
cancelled-but-not-reaped): id, owner, body, per-target
`last_fired_at` + `last_result`, `next_fire_at_unix`,
`interval_seconds`. Use to look up an id before cancelling or to
audit upcoming wake-ups across the swarm.
- `remind` / `get_loose_ends` / `cancel_loose_end` / `set_status`
/ `get_agent_meta` — same as the sub-agent tools above.
`get_loose_ends` scopes to the manager's own items by default;
pass `agent: "*"` for a hive-wide view, or `agent: "<name>"`
to inspect one agent.
`cancel_loose_end` may cancel any agent's row.
The boundary: lifecycle ops on *existing* sub-agents
(`kill`/`start`/`restart`) are at the manager's discretion — no
operator approval. Creating a new agent (`request_init_config`
`request_apply_commit` for the first sha) and changing any agent's
config (`request_apply_commit`) still go through the approval queue.
### Authoritative state
`hive_ag3nt::events::Bus` carries the current turn-loop state in
addition to the broadcast channel and the events history. Variants:
- `Idle` — sitting on `Recv` waiting for mail.
- `Thinking``claude --print` is running for a turn.
- `Compacting` — operator-triggered `/compact` is in flight.
The harness flips state at the relevant transitions
(`set_state(Thinking)` before `drive_turn`, `set_state(Idle)`
after; `set_state(Compacting)` around `compact_session`). Exposed
via `/api/state.turn_state` + `turn_state_since` (unix seconds);
the agent page renders this rather than deriving from SSE events.
### Tool envelope
`mcp::run_tool_envelope`: every MCP tool handler logs the request,
runs the body, logs the result. Pre-/post-log only — the inbox
status hint moved to the wake prompt + UI header.
### Tool whitelist (`mcp::ALLOWED_BUILTIN_TOOLS`)
- Allowed built-ins: `Bash`, `Edit`, `Glob`, `Grep`, `Read`, `Write`.
- Denied by omission: `WebFetch`, `WebSearch`, `Task`,
`NotebookEdit`, `TodoWrite`.
- Allowed MCP tools: as listed above per flavor.
By default `Bash` is approved wholesale — any shell command runs
without confirmation. To restrict an agent to specific command
families, set `hyperhive.allowedBashPatterns` in its `agent.nix`:
```nix
hyperhive.allowedBashPatterns = [ "git *" "ls *" ];
```
The harness reads `/etc/hyperhive/bash-allow.json` and replaces
`Bash` in `--allowedTools` with `Bash(git *)` + `Bash(ls *)` etc.
Commands outside the pattern list require confirmation — which in
`--print` mode means they will not run. An empty list (default) keeps
the current wholesale `Bash` entry.

821
docs/web-ui.md Normal file
View file

@ -0,0 +1,821 @@
# Web UI
Two web surfaces share the same skeleton: the dashboard (port 7000)
and the per-agent UIs (manager on :8000, sub-agents on a hashed
:8100-8999). Both are SPAs — `GET /` returns a static shell,
`/api/state` returns JSON, JS renders. No full-page reloads.
## Shape (shared by both)
- `GET /``index.html` from the bundled frontend dist (see
`frontend/`). Both binaries' routers declare their dynamic
endpoints first and then `fallback_service(ServeDir::new(...))`
pointed at `HIVE_STATIC_DIR` — anything not matched by an API or
action route is served from the dist. Dashboard dist lives at
`${frontend}/dashboard`; per-agent dist is the merged
`hyperhive.frontend.mergedDist` (default agent dist + per-agent
`extraFiles` overlay).
- `GET /static/*` → bundled CSS + JS produced by esbuild
(`frontend/packages/{dashboard,agent}/build.mjs`). Both pages
pull the shared terminal pane + Catppuccin palette + typography
from `@hive/shared` (was `hive-fr0nt`); the CSS bundle inlines
`base.css` + `terminal.css` via esbuild's `@import` resolution.
`terminal.js` exports `{ create, linkify }` as ES module
members (no more `window.HiveTerminal` global outside the
back-compat shim the IIFE bodies still use). The dashboard's
`#msgflow` and the per-agent `#live` log are both backed by
this terminal — sticky-bottom auto-scroll, "↓ N new" pill,
history backfill, SSE plumbing all live there. Each page
registers a kind→renderer map; unknown kinds fall through to
a JSON-dump note row. Bare `http(s)://` URLs in row text are
turned into clickable new-tab links by `linkify` (text-node
based, no `innerHTML` — XSS-safe); markdown bodies get the
same treatment via `marked`'s autolink (npm dep, replacing the
vendored UMD bundle), with the rendered `<a>`s rewritten to
`target="_blank"` (issue #233).
- `GET /api/state` → JSON snapshot the JS app renders into the
DOM. Includes a top-level `seq` (the dashboard event channel's
high-water mark at the moment the snapshot was assembled);
clients use it to dedupe their buffered SSE traffic against
the snapshot (drop frames with `seq <= snapshot.seq`).
- `GET /dashboard/stream` (dashboard) / `GET /events/stream`
(per-agent) → `text/event-stream` SSE for live updates. The
dashboard stream carries broker `Sent` / `Delivered` (mirrored
by a forwarder task from the broker's intra-process channel)
plus mutation events (`approval_added` / `approval_resolved`,
`question_added` / `question_resolved`, `transient_set` /
`transient_cleared`). Each frame carries a `seq`. The
matching backfill endpoint is `GET /dashboard/history` (last
~200 broker messages wrapped in `{ seq, events }`) on the
dashboard and `GET /events/history` (last 2000 `LiveEvent`s
also wrapped in `{ seq, events }`) on the agent.
**SSE multiplexing** (#448): the dashboard uses a
`SharedWorker` (`stream-worker.js`) to hold one upstream
`EventSource` per URL. All same-origin tabs share this worker
— a second dashboard tab joins the existing connection rather
than opening a duplicate. The worker fans SSE events out to
each subscribed tab via `MessagePort`; on bfcache restore the
page re-subscribes (gets a synthetic `open` event immediately
if the upstream is already connected). Falls back gracefully
when `SharedWorker` is unavailable (e.g. some private-mode
browsers).
The JS app handles all `form[data-async]` submissions via a delegated
listener: read `data-confirm`, swap the button to a spinner, POST
`application/x-www-form-urlencoded`, re-enable the button on success
(refreshState may keep the form mounted, so we don't rely on a
re-render), call `refreshState()`. State shapes live in
`dashboard.rs::StateSnapshot` and `web_ui.rs::StateSnapshot` — when
adding state fields, plumb through the snapshot struct and the
relevant `assets/tabs.js` render function.
**Focus preservation:** `refreshState` checks whether
`document.activeElement` sits inside one of the managed sections
and, if so, skips the refresh (defers 2s). The operator never has
the form yanked out from under them mid-type; the update lands as
soon as they blur.
**`<details>` open-state preservation:** any collapsible element
tagged with `data-restore-key="<stable-key>"` survives the
refresh. `snapshotOpenDetails()` walks managed sections before
render, `restoreOpenDetails()` re-applies after. Long-content
drill-ins (file previews, diffs, journald logs) now open in the
**side panel** (see below) rather than expanding inline, so the
only restore-keyed `<details>` left is the answered-questions
history list.
**Side panel (dashboard):** long content opens in a drawer that
swipes in from the right — a singleton `#side-panel` with a
titled header, a close button, and a scrollable body. Closes on
the button, a backdrop click, or `Escape`. `Panel.open(title,
node)` swaps the body; the JS builders for file previews,
approval diffs, and journald logs all render into it. **The
drawer width is drag-to-resize** (#451): a thin 6px hit-strip on
the left edge captures pointer events, resizes the drawer in
real-time (pointer capture keeps dragging even if the cursor
outpaces the handle), and persists the chosen width to
`localStorage` (key `hyperhive:side-panel-width`) so it
survives page reload. Width is clamped to CSS `min-width: 320px`
/ `max-width: 96vw`; the viewport-resize handler re-clamps
persisted values after a window shrink. File
previews are type-aware:
- **Markdown** (`.md` / `.markdown`) — a `rendered` / `plain`
tabbed view: `rendered` (default) is the vendored `marked`
bundle (`GET /static/marked.js`), `plain` is the raw source.
- **SVG** (`.svg`) — a `rendered` / `source` tabbed view;
`rendered` shows the image via an `<img>` `data:` URI (the
browser's secure static mode, so an untrusted SVG can't run
scripts), `source` shows the raw markup.
- **Raster images** (`.png` / `.jpg` / `.gif` / `.webp` /
`.bmp` / `.ico` / `.avif`) — render as an `<img>` pointed at
`/api/state-file`, which serves them as binary with their
real content-type (text files stay UTF-8-lossy `text/plain`).
- **Everything else** — raw text in a `<pre>`.
Both bind their listeners with `SO_REUSEADDR` via
`tokio::net::TcpSocket` plus a retry loop on `AddrInUse` (12 tries,
exponential backoff capped at 2s) so an nspawn restart that races
the previous process's socket release resolves itself.
## Dashboard layout
The dashboard (`/`) has a fixed chrome header at the top and a
`<main>` that shows exactly one tab pane at a time. The URL hash
(`#swarm`, `#call`, `#system`, `#schedules`) drives which pane is
active; hash changes don't reload the page. FL0W is a separate
full-page terminal at `/flow.html` — its tab-strip entry is a
cross-page link (`◆ FL0W ◆ →`), not a pane swap.
**Chrome header** (fixed, overlays the active tab pane):
- **Tab strip**: `◆ SW4RM ◆`, `◆ Y3R C4LL ◆`, `◆ SYST3M ◆`,
`◆ SCH3DUL3S ◆`, and `◆ FL0W ◆ →` (page link). Count pills on
SW4RM (container count), Y3R C4LL (pending approvals +
questions), and SCH3DUL3S (active schedules); FL0W pill mirrors
the operator inbox length (hidden when zero).
- **Notification controls**: `🔔 enable notifications` when
permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted.
Always visible in the chrome regardless of active tab.
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
— sits below the tab strip.
### SW4RM tab
**C0NTAINERS** — live containers rendered as a depth-first
tree using `ContainerView.parent` (populated by `topology.rs`).
Each container's row is prefixed with ASCII tree glyphs (`├─`,
`└─`, `│ ` continuation columns) showing the agent
parent/child hierarchy. When every container has `parent = null`
(flat topology) the tree collapses to a plain list with no
glyphs. Children are sorted alphabetically within each parent;
roots likewise. Cycles in the parent graph are tolerated —
orphaned containers (not reachable from any root) are appended
as roots so no agent disappears. Pulsing red banner at the top
of this section if any two sub-agents hash to the same port
(`port_conflicts` from `/api/state`): the operator must rename
one of them and rebuild. `lifecycle::{spawn,rebuild}` also
preflight this and refuse with a clear error message naming the
conflicting agent.
`↻ UPD4TE 4LL` button appears above the containers list when any
agent is stale.
### Y3R C4LL tab
Things blocked on operator decision — approvals and questions
share a tab because they're the same concept ("something is
waiting on you").
**P3NDING APPR0VALS** — the queue (see "Approval card" below).
The R3QU3ST SP4WN form lives at the top of this section.
**M1ND H4S QU3STI0NS** — pending operator-targeted `ask`
questions (amber pulsing border). Free-text fallback always
rendered alongside any option list; `multi=true` renders options
as checkboxes; submit merges selections + free text
comma-joined. Each row has a `✗ CANC3L` button. Questions with
a `ttl_seconds` show a `⏳ MM:SS` chip; the host-side watchdog
auto-cancels with `[expired]` when the deadline fires.
### SYST3M tab
Passive / rare-interaction state.
**M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can
selectively `nix flake update`, rendered as an indented tree:
every fetched input at every depth (`hyperhive`,
`hyperhive/nixpkgs`, `agent-<n>`, `agent-<n>/mcp-<x>`, …), each
shown once at its shallowest path. `read_meta_inputs` walks the
lock graph with a `visited` set — `follows` aliases and rev-less
nodes are skipped (issue #275). A `select all / select none`
control sits above the tree. Checking inputs + submitting bumps
the lock in `/meta/` and rebuilds the selected agents in
sequence; each outcome reaches the manager as a `rebuilt`
system event. `POST /meta-update`. While a lock-bump ripple runs,
the panel shows a pulsing "⏳ meta-update running" banner and the
update button is disabled (snapshot field `meta_update_running`,
live event `meta_update_running`).
**R3BU1LD QU3U3** — pending and recently-completed container
operations: rebuilds, meta-update cascades, and first-spawns.
One operation runs at a time; the worker drains FIFO. Each row
shows a state glyph (`⏸` queued / `▶` running / `✔` done /
`✖` failed / `⊘` cancelled), kind glyph + verb (`↻ rebuild`,
`◆ meta_update`, `✨ spawn`, `🗑 destroy`), agent name, source
chip (`manual | meta_update | auto_update | crash_recover | approval`
— green for operator-approved config changes; #436),
timing, and an optional reason / error. Meta-update cascade
rebuilds nest under their parent entry (`parent_id` grouping;
`rqe-child` CSS class). Dedup: re-enqueueing a still-queued op
for the same agent collapses into the existing entry. Running
entries tick elapsed seconds live, and when the worker has
annotated the current phase (#437) a cyan `↳ <step>` sub-line
appears under the main row showing the in-flight step name
(e.g. `↳ meta prepare_deploy``↳ nixos-container update`
`↳ finalize deploy`). Terminal transitions clear `step` on the
backend so Done / Failed rows don't render stale labels.
Cold-loaded from `/api/state.rebuild_queue`; live updates via
`rebuild_queue_changed` snapshot event.
**K3PT ST4T3** — destroyed-but-state-kept tombstones (size +
age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a
Spawn approval; existing state is reused), `PURG3` (wipes
state + applied dirs; `POST /purge-tombstone/{name}`).
### SCH3DUL3S tab
Anything that fires at a future time. Operator-set schedules
go through the creation form at the top; agent self-paced
reminders surface at the bottom as a sibling list (#460
they share enough conceptual ground to live together).
**N3W SCH3DUL3 / QU3U3D SCH3DUL3S** — operator-managed
scheduled prompts (#444 / #459). Lists every schedule with
its description, targets, body, recurrence interval, next-fire
time, and per-target last-result. Per-row controls: a
`↯ fire now` button sends an out-of-band manual pulse to
every active target (#467 — recurring schedules keep their
cadence; one-shots are consumed after the manual fire), an
`✎ edit` button opens an inline edit form (#474 — body /
description / interval / next-fire / targets all editable;
targets are a multi-select diff'd against the original active
set so unchecked-was-active = `targets_remove`, checked-not-
originally-active = `targets_add`; submit PATCHes
`/api/schedules/{id}`), and a
`CANC3L` button cancels the whole schedule
(`POST /api/schedules/{id}/cancel`). Individual target chips
have their own cancel links. An inline creation form lets
the operator queue a new schedule directly:
targets (multi-select checkboxes drawn from live container
names + `operator` + `manager`), prompt body (textarea),
first-fire datetime-local (pre-filled to 5 minutes from now),
an interval composer (#466 — preset chips for common
durations + separate d/h/m/s number fields with a live
"↻ every …" preview; all-zero = one-shot),
and an optional human-readable description. On submit the
form POSTs to `/api/schedules` as JSON; the tab pill shows
the count of active schedules (at least one live target not
yet cancelled). Refreshed on tab activation and after each
submit/cancel. Backed by `GET /api/schedules`.
**QU3U3D R3M1ND3RS** — reminders agents have scheduled for
themselves (via the `remind` tool) but not yet delivered.
Each row shows the owner, due time, and message; a `CANC3L`
button hard-deletes (`POST /cancel-reminder/{id}`) and a
`R3TRY` button re-arms one whose delivery failed
(`POST /retry-reminder/{id}`). Backed by `GET /api/reminders`.
Lives in the SCH3DUL3S tab alongside operator schedules so the
operator has one place for everything time-fired (#460).
### FL0W page (`/flow.html`)
A dedicated full-page terminal (not a tab pane — a separate HTML
page). Reuses the same `<header class="dashboard-chrome">` chrome
as the dashboard so the tab strip remains visible; SW4RM / Y3R
C4LL / SYST3M / SCH3DUL3S are cross-page links back to `/#<tab>`,
and the FL0W entry is marked active (`aria-current="page"`).
**0PER4T0R 1NB0X** — recent messages addressed to `operator`,
derived client-side from the dashboard event stream. Cold load
seeds from `/dashboard/history`'s 200-message backfill; subsequent
`sent` events with `to == "operator"` are appended live. Cap 50,
newest-first.
**MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`.
Cold load backfills the last ~200 messages from `/dashboard/history`;
live frames arrive on `/dashboard/stream`. Each row is one broker
event — `sent` or `delivered` — with `from → to: body`. Sticky-
bottom auto-scroll + "↓ N new" pill. Below the stream sits a
terminal-style compose box: `@name` picks the recipient (sticky via
localStorage; auto-complete from the live container list, Tab/Enter
to confirm; `@*` broadcasts). `POST /op-send` drops
`{from:"operator", to, body}` into the broker; the resulting SSE
frame re-renders both the terminal row and the inbox section.
Manager is addressed as `@manager` (the broker recipient string),
not `@hm1nd` (the container name).
### Container row
A full-height **square agent icon** (5em, capped) on the left. The
icon is the **selection toggle**: click (or Enter/Space) adds/removes
the agent from the selection set; `aria-pressed` reflects the state;
the tooltip says "select … for bulk actions" or "deselect … (or press
Esc to clear all)". The `<img>` points at `<url>/icon`; load failure
falls back to the dimmed hyperhive mark (`/favicon.svg`). The card
body sits to the right with three stacked lines
(`assets/tabs.js::renderContainers`).
- Line 1: agent name (link → new tab), m1nd/ag3nt chip, an
**icon-only nav strip** populated async from the agent backend
(`📊 stats`, `🖥 screen` when GUI is enabled, `⬡ forge profile`,
`↳ agent-configs mirror`, plus any agent-declared
`dashboardLinks` extras — issue #262). The dashboard JS fetches
`GET /api/agent/{name}/links`, a same-origin passthrough proxy
that forwards the agent's own link list; the agent backend is
the single source of truth. The frontend resolves each
`AgentLink.kind` (`container``http://host:<container.port>`,
`forge``http://host:3000`, `external` → already absolute).
**When the container is stopped** (`ContainerView.running = false`),
the host clears live-only fields before emitting the state, so
the dashboard never renders stale data: the badge chain is
replaced by a single muted `■ not running` badge, the nav-strip
fetch is skipped (the agent web server is down), and the
self-reported status text is suppressed. The agent icon goes
straight to the dimmed `/favicon.svg` fallback instead of
attempting a doomed load from the container's URL. Static fields
`needs_update`, `deployed_sha`, `pending_reminders`, `parent`,
`config` link — remain visible regardless of run state.
When the container is running, status badges follow — `⊘ rate
limited` (red, while the harness is parked after a 429), `needs
login`, `needs update` — in-flight `◐ pending-state…` pill
(replaces buttons during operator-initiated start / stop /
restart / rebuild / destroy). Additionally, when a rebuild-queue
entry for this agent is `queued` or `running` but no
operator-initiated transient is set, the card surfaces a
`building…` / `meta-updating…` badge sourced from
`rebuildQueueState` (#398) — so the SW4RM tab shows the same
rebuild progress visible on the SYST3M tab's R3BU1LD QU3U3.
Container name + port, and a `ctx · Nk` chip showing the
agent's last-turn context size (from `ContainerView.ctx_tokens`,
read from the turn-stats sqlite on each `build_all` sweep;
absent until the first turn). The chip colour (green / yellow /
red) is keyed off the model's real context window: `build_all`
resolves the last turn's model against the host's per-model
`contextWindowTokens` config and exposes it as
`ContainerView.context_window_tokens`; the badge goes yellow
≥ 50% and red ≥ 75% of that window (the harness compaction
watermarks). When the window can't be resolved the badge falls
back to fixed 100k / 150k thresholds. (issue #66)
- Line 2: status badges only (no per-card action buttons — actions
moved to the **selection bar**, see below).
- Line 3: drill-in triggers —
- `↳ logs · <container>` — opens the side panel and lazy-
fetches journald via `GET /api/journal/{name}?unit=&lines=`
(`journalctl -M <container> -b --no-pager --output=short-iso`).
A unit dropdown (harness service / full machine journal) and
a refresh button live in the panel.
- Plain navigation links (config repo, forge profile,
`dashboardLinks` extras) now live in the icon-only nav strip
on Line 1 — see above (issue #262). The agent's `config` link
goes to the repo root; the deployed sha shows separately on
Line 1 as the `deployed:<sha>` chip, since the agent harness
can't know its own deployed commit.
`↻ UPD4TE 4LL` button appears above the containers list when any
agent is stale. Banner pulses on each broker SSE event
(`pulseBanner` with a 4s grace timer).
### Selection bar
When one or more agents are selected (via icon click), a sticky
frosted-mauve bar slides up from the bottom of the viewport
(`#selection-bar`, `position: fixed; bottom: 0`). It shows:
- **Count + names** — "N agents selected · name1, name2, …"
- **Bulk action buttons** — only enabled when ALL selected agents
support the action; disabled with a tooltip naming the blockers
when the selection is mixed:
- `↺ R3ST4RT` — running agents only
- `■ ST0P` — running agents only (manager included; no special-case)
- `▶ ST4RT` — stopped agents only
- `↻ R3BU1LD` — always available
- `DESTR0Y` / `PURG3` — sub-agents only (disabled if manager selected)
- **`✕ clear`** button + `Esc` key clear the entire selection.
Stale selections (agents destroyed while selected) are pruned on
every render before the bar appears.
### Approval card
Each pending approval renders as a card (`assets/tabs.js::
renderApprovals`) with three stacked sections:
- **identity header** — glyph, `#id`, agent, kind chip, (for
`apply_commit`) the short proposal sha as `<code>`, and a
right-aligned `requested <N> ago` relative time from
`ApprovalView.requested_at` — amber once the request has been
pending ≥ 1h so a stale approval stands out (issue #272).
- **what-changed body** — the manager's description, then
drill-in triggers: `↳ view diff` opens the diff in the side
panel; `↳ commit on forge ↗` deep-links the proposal commit
into `agent-configs/<agent>` (shown only when `forge_present`).
Spawn approvals show a one-line "container will be created"
note instead.
- **decision actions**`◆ APPR0VE` and `DENY`. Deny pops a
`prompt()` for an optional reason carried to the manager as
`HelperEvent::ApprovalResolved.note`.
The diff panel has a 3-way base toggle — **vs applied** (the
running tree, served instantly from the diff already on the
approval), **vs last-approved**, **vs previous proposal** — the
latter two fetched on click from `GET /api/approval-diff/{id}
?base=approved|previous`. Each line is classified client-side
(`+` / `-` / `@@` / `--- ` / `+++ ` → add / del / hunk / file).
A `pending · N` / `history · N` tab pair switches the section
between the live queue and the last 30 resolved approvals.
### Browser notifications
Pure frontend (`Notification` API). Three signals trigger them:
- new pending approval (per id, delta on `/api/state`)
- new pending operator question (per id)
- new broker message sent `to: "operator"` (live via SSE)
First `/api/state` after page load seeds "seen" sets without
firing — only items that arrive while the page is open count.
Per-event tags (`hyperhive:approval:<id>`, `hyperhive:question:<id>`,
`hyperhive:msg:<at>:<rand>`) so distinct events stack in the OS
notification center instead of overwriting each other.
`console.debug` logs at every block point (unsupported,
permission ungranted, muted) for in-browser debugging. Click
focuses the dashboard tab. localStorage-backed mute toggle
silences without revoking the OS permission. Requires a secure
context (HTTPS or localhost); on other origins the controls hide
themselves. Browsers typically suppress notifications while the
originating tab is focused — that's a browser-level decision,
not ours.
### Dashboard endpoints
- `POST /approve/{id}` — approve a pending approval. Fires
`ApprovalResolved` on the dashboard event channel; client
updates derived approvals state from the event.
- `POST /deny/{id}` (`note=<reason>`, optional) — deny a pending
approval with an optional operator-supplied reason. The reason
travels to the manager as `HelperEvent::ApprovalResolved.note`
and also rides on the dashboard's `ApprovalResolved` event.
Dashboard prompts via `window.prompt()` on click.
- `POST /{rebuild,kill,restart,start,destroy}/{name}` — lifecycle.
`destroy` accepts `purge=on` to also wipe state dirs.
- `POST /purge-tombstone/{name}` — wipe a tombstone's state dirs.
- `POST /answer-question/{id}` — answer a pending operator question.
- `POST /cancel-question/{id}` — cancel a pending question with
the sentinel `[cancelled]`. Same code path as a real answer.
- `POST /request-spawn` — queue a Spawn approval.
- `POST /update-all` — rebuild every stale container.
- `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry
(#447). Refuses `Running` / terminal-state entries (in-flight
rebuilds can't be safely interrupted). Always 200; body is
`{"cancelled": true}` on a successful flip or
`{"cancelled": false}` when the entry was not in `Queued` state.
- `POST /op-send` (`to=<name>`, `body=<text>`) — drop an
operator-authored message into `<name>`'s inbox. `to=*` fans
out to every registered agent. Returns 200; the broker
`Sent` event re-renders both the message-flow terminal and
the operator inbox without a snapshot refetch. Used by the
compose textbox under MESS4GE FL0W.
- `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for
a managed container; rendered in the side panel.
- `GET /api/approval-diff/{id}?base=applied|approved|previous`
on-demand unified diff for an `ApplyCommit` approval against
the chosen base (running tree / last approved proposal /
previous queued proposal). Raw diff text, classified
client-side. `GET /static/marked.js` serves the vendored
`marked` bundle the side panel uses for markdown previews.
- `GET /api/state-file?path=<host-or-container-path>` — bounded
text read of a file under the per-agent `state/` subtree or
the shared `/var/lib/hyperhive/shared/`. Accepts the
container-view forms (`/agents/<n>/state/...`, `/shared/...`)
and the host form. Canonicalises + verifies the path stays
inside the allow-list, refuses anything but a regular file,
refuses `/agents/<n>/claude` / `config` subtrees, truncates
bodies at 1 MiB. Click-time backing for the inline path-link
preview.
Detection of which tokens *are* path links is done
**server-side at broker-message ingest**, not client-side:
the broker forwarder calls `scan_validated_paths(body)`
same allow-list helper the read endpoint uses — and attaches
the verified file tokens to the event as `file_refs: Vec<String>`.
The client trusts that list and linkifies only those tokens,
so directories, missing files, and forbidden subtrees never
become anchors. No probe endpoint, no client-side regex
heuristics. Historical messages get the same treatment on
`/dashboard/history` backfill.
- `GET /api/reminders` — list pending reminders for the
dashboard's queued-reminders panel.
- `POST /cancel-reminder/{id}` — hard-delete a pending reminder.
- `POST /retry-reminder/{id}` — re-arm a reminder whose delivery
failed (clears the failure state so the scheduler retries).
- `GET /api/schedules` — list all schedules (active and
recently cancelled) for the SYST3M scheduled-prompts panel.
- `POST /api/schedules` — operator-direct schedule create:
`{ targets, body, first_fire_at_unix, interval_seconds?, description? }`.
Agent-initiated schedules go through the approval queue instead
(manager MCP `request_schedule_prompt`).
- `PATCH /api/schedules/{id}` — partial edit (#474). JSON body
`{ body?, description?, interval_seconds?, next_fire_at_unix?,
targets_add?, targets_remove? }`.
Missing key = "leave alone"; explicit `null` on
`description` / `interval_seconds` clears the field (so a
recurring schedule flips to one-shot when `interval_seconds`
is sent as `null`). `targets_add` is replace-on-conflict:
re-adding a previously-cancelled target drops the tombstone
and the target starts fresh (operator intent on re-add =
"this target is active again"). `targets_remove` delegates
to the same path as `cancel_targets` — tombstones preserve
audit, parent schedule auto-cancels when no active targets
remain. Refuses cancelled rows; returns the updated
`WireSchedule` on success.
- `POST /api/schedules/{id}/cancel` — cancel a schedule. Body
`{ targets?: ["name", …] }` cancels just those recipients;
absent or empty body cancels the whole schedule.
- `POST /api/schedules/{id}/fire-now` — out-of-band manual
pulse (#467). Fires the schedule body once immediately to
every active target. Recurring schedules: `next_fire_at_unix`
is untouched; the regular cadence continues. One-shots: the
schedule is consumed (cancelled) after the manual fan-out.
Per-target `last_result` is annotated as a manual fire so
the audit trail distinguishes scheduled fires from operator-
triggered ones.
- `POST /meta-update``nix flake update` the selected
`meta/flake.lock` inputs, then rebuild the affected agents.
- `GET /dashboard/stream` — unified live event channel:
broker `sent` / `delivered`, plus the mutation events listed
below. Each frame carries `seq`.
- `GET /dashboard/history` — last ~200 broker messages
(wrapped as `{ seq, events }`) for the message-flow
terminal's backfill on page load.
### Dashboard event channel
Wire vocabulary on `/dashboard/stream` (kind tag is in the JSON
payload):
- `sent` / `delivered` — broker traffic, mirrored from the
intra-process channel by a forwarder task. Both carry `id: i64`
(the broker row id) and `in_reply_to: Option<i64>` for thread
rendering. The dashboard message-flow terminal renders reply
rows with a `↳ reply` tag that scroll-highlights the parent
row on click. Used by the message-flow terminal renderer and
the operator-inbox derived state.
- `approval_added` (id, agent, approval_kind, sha_short, diff,
description) / `approval_resolved` (id, agent, approval_kind,
sha_short, status, resolved_at, note, description) — pending
queue + history mutations. Client mutates a derived store and
re-renders only the approvals section.
- `question_added` (id, asker, question, options, multi,
asked_at, deadline_at, target) / `question_resolved` (id,
answer, answerer, answered_at, cancelled, target) — both
operator-targeted and peer (agent-to-agent) threads fire
these. The dashboard's questions pane surfaces both, with
filter chips (all / @operator / @peer / per-participant) and
an `0V3RR1D3` button on peer rows so the operator can
answer when an agent is stuck. The ttl watchdog fires
`question_resolved` with `answerer = "ttl-watchdog"` on
expiry.
- `transient_set` (name, transient_kind, since_unix) /
`transient_cleared` (name) — lifecycle action spinners. The
client ticks the elapsed-seconds badge off `since_unix`
client-side, no polling.
- `container_state_changed` (container: ContainerView) /
`container_removed` (name) — per-row container mutations,
emitted by `Coordinator::rescan_containers_and_emit` from
every mutation site (`actions::approve` post-spawn,
`actions::destroy`, the lifecycle_action wrapper,
`auto_update::rebuild_agent`) and from the 10s
`crash_watch` poll. Client upserts/removes by name; the
pending overlay is read from `transientsState` since the
payload doesn't carry it.
- `rebuild_queue_changed` (seq, queue: `Vec<QueueEntry>`) —
full snapshot of the rebuild queue on every mutation (enqueue,
state transition, dedup collapse, terminal-history trim).
Same snapshot-over-diff rationale as `tombstones_changed` /
`meta_inputs_changed`: the list is small and the client's
`parent_id` grouping is most naturally re-derived from the
full list. Cold-loaded from `/api/state.rebuild_queue`.
`/api/state` is **only fetched on cold-load and on the few
forms that mutate non-event-derived state** (PURG3 +
meta-update, since tombstones + meta_inputs aren't event-
shaped yet). Every other section — approvals, questions,
transients, containers, operator inbox, message flow —
derives from `/dashboard/stream` after the initial snapshot,
maintaining its own client-side store and applying events on
top. The 5s periodic poll is gone.
Generalised form helpers: `form[data-confirm="…"]` pops
`confirm()` before submit; `form[data-prompt="…"]` pops
`prompt()` and stashes the answer in a hidden input named by
`data-prompt-field` (default `note`).
## Per-agent page
Three fixed-position layers frame a full-viewport terminal:
**Fixed-overlay header** (`<header class="agent-header">`): frosted
glass — `backdrop-filter: blur` lets scrolled terminal rows show
through. Three flex columns (#394 redesign):
- **Agent icon** (`<img class="agent-icon">`): fixed-size square
identity anchor (5em, `width: 5em; aspect-ratio: 1;
align-self: flex-start` — capped so a tall state-row doesn't
inflate the icon, #411). Falls back to the dimmed hyperhive mark
on load error.
- **Main column** (`.agent-header-main`): two rows.
- Row 1 (`.agent-header-title-row`): title (`<h2 id="title">`) +
meta-nav (`<nav id="meta-links">`). Meta-nav renders
backend-supplied `StateSnapshot.links` as icon-only anchors — always
`📊 stats` (`kind = Container`); `🖥 screen` when VNC is enabled;
`⬡ forge` (profile) + `↳ config` (agent-configs mirror) when the
agent has a forge account; any `hyperhive.dashboardLinks` extras
(`kind = External`). A `↑ dashboard` link is prepended by the JS
so the host dashboard is one click away. `GET /api/agent/{name}/links`
is the single source of truth.
- Row 2 (`.agent-state-row`): alive badge + state badge + model chip
+ ctx badge + cost badge + last-turn chip + cancel button.
- Alive badge: `● alive` (green) / `⊘ rate limited` (red) /
`◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`.
Driven by `LiveEvent::StatusChanged`.
- State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` /
`○ offline` / `… booting` + age suffix. Driven by
`LiveEvent::TurnStateChanged ({ state, since_unix })`.
- Model chip: `model · <name>`. Driven by `LiveEvent::ModelChanged`.
- Ctx badge: `ctx · 142k` — last inference's prompt size.
Tooltip shows % of window when `context_window_tokens` is known.
- Cost badge: `cost · 1.3M` — cumulative tokens billed across every
inference in the last turn (tool-heavy turns rebill the cached
prefix per call — cost signal, not size signal).
- Both driven by `LiveEvent::TokenUsageChanged { ctx, cost }` at
turn-end.
- `■ cancel turn` (visible while thinking) → `POST /api/cancel`.
- **Right cluster** (`.agent-header-pills`): flyout pills + overflow.
- **Inbox pill** (`📬 inbox · N`): hidden when empty; click opens
the inbox flyout in the side panel.
- **Loose-ends pill** (`🪢 loose ends · N`): hidden when empty;
click opens the loose-ends flyout.
- **Overflow button** (`⋯`): always visible. Opens a frosted popover
(`#overflow-menu`, positioned outside the header to escape any
stacking context) with three rows: `↑ dashboard` (link), `↻ rebuild
container` (POST confirm, same action as the dashboard R3BU1LD
button), `↻ new claude session` (POST confirm → `POST
/api/new-session`; next turn drops `--continue`). Both destructive
actions require one extra click to acknowledge (#394 — rare ops
shouldn't live in the primary state strip).
`/api/state` is fetched once on cold load (+ while
`status === 'needs_login_in_progress'`); all other updates arrive via
SSE. Snapshot includes `context_window_tokens` for the ctx badge tooltip.
**Main content** (`<main class="agent-main">`): fills the viewport
and scrolls behind the fixed header + footer.
- `#status` overlay: empty when online; shows the login form / OAuth
URL when `status` is `needs_login_*`.
- Terminal-wrap: live event tail (sticky-bottom auto-scroll +
`↓ N new` pill when not at bottom).
**Fixed-overlay footer** (`<footer class="agent-composer">`): frosted
glass, symmetric with the header. Contains the operator-input
textarea (`#term-input`) — multi-line, Enter sends, Shift+Enter
newlines, Tab-completes slash commands (see "Terminal-embedded
prompt" below).
**Side panel** (slide-in from right): singleton shared with the
dashboard's side panel shape. Carries inbox and loose-ends flyouts
(opened via the header pills) as well as long content (file previews,
diffs, journald logs). Inbox flyout: last 30 messages addressed to
this agent (`AgentRequest::Recent { limit: 30 }`); reply messages
indented with `↳ reply ·` in amber. Loose-ends flyout: questions,
approvals, and reminders pending against this agent (`GET /api/loose-ends`);
question rows carry an inline answer form that POSTs cross-origin to
the core dashboard's `/answer-question/{id}` so the operator answers
*as operator* (see `docs/boundary.md`).
### Live view
Each agent runs an `events::Bus`: a `tokio::sync::broadcast<LiveEvent>`
plus a sqlite-backed history at `/state/hyperhive-events.sqlite`.
The harness emits `TurnStart { from, body, unread }`,
`Stream(value)` (one per parsed stream-json line), `Note`,
`TurnEnd { ok, note }`. The web UI:
- fetches `GET /events/history` on page load and replays the last
2000 events (oldest first, with `.no-anim` so they don't
stagger);
- then subscribes to `GET /events/stream` (SSE) for live tail;
- shows a granular state badge above the terminal, driven
authoritatively from `/api/state.turn_state`. SSE turn_start /
turn_end still flip the badge instantly between renders;
- sticky-bottom auto-scroll: scrolling up parks the view; new rows
surface a "↓ N new" pill instead of yanking;
- terminal-themed: phosphor mauve glow, Crust bg,
backdrop-filter blur, row fade-in slide-up.
Per-stream rendering:
- `Stream` `tool_use`
- `Write` / `Edit`: collapsed `<details>` with a +/- diff body
(`-` lines from `input.old_string`, `+` lines from
`input.new_string` or every line of `input.content`).
Summary carries the path + line counts.
- others (`Read /path`, `Bash $ cmd`, `mcp__hyperhive__send →
operator: "..."`, etc.): flat one-line per-tool format.
- `Stream` `tool_result` short → flat `← ...`; long → collapsed
`<details>` `▸ ← Nl · headline` (click to expand full body).
- `Stream` `thinking` → text content if claude provided one,
otherwise the bare `· thinking …` indicator.
- `Stream` `system init`, `result`, `rate_limit_event` are
dropped — too noisy.
- `Note``· text`.
- `TurnEnd``✓ turn ok` / `✗ turn fail — note`, triggers a
`refreshState()`.
### Terminal-embedded prompt
The operator input lives *inside* the terminal-wrap as a
prompt-style textarea below the live tail: multi-line (Enter
sends, Shift+Enter newlines), tab-completes slash commands.
Slash commands today:
- `/help` — list commands locally.
- `/clear` — wipe the local terminal view (server history kept).
- `/cancel``POST /api/cancel` → host shellouts `pkill -INT
claude`, emits a Note. Also surfaces as a `■ cancel turn`
button in the state row while state=thinking.
- `/compact``POST /api/compact` → host spawns
`turn::compact_session` in the background; output streams into
the live panel.
- `/model <name>``POST /api/model` flipping `Bus::set_model`.
Takes effect on the next turn; persisted to
`/state/hyperhive-model` so the override survives harness
restart / rebuild.
- `/new-session``POST /api/new-session` (confirms first).
Arms a one-shot on the Bus; next turn runs without
`--continue`, dropping the resume session entirely.
Unknown `/foo` shows an error row instead of being silently sent.
### Per-agent endpoints
All POSTs return 200 (no 303 redirects). The matching mutations
fire `LiveEvent` variants on the per-agent bus, so the client
doesn't refetch `/api/state` on submit — the SSE stream
delivers the new state faster anyway. Only the login flow still
polls (session output streams in updates that aren't event-
shaped).
- `POST /send` — operator-injected message into this agent's inbox.
- `POST /login/{start,code,cancel}` — claude OAuth login flow.
Start/cancel emit `LiveEvent::StatusChanged` to flip the
badge to/from `needs_login_in_progress`.
- `POST /api/cancel` — SIGINT the in-flight claude turn. Emits a
`LiveEvent::Note`.
- `POST /api/compact` — run `/compact` on the persistent session
(same MCP config + system prompt + allowed tools as a normal
turn — only the stdin payload differs). Flips state to
`Compacting` via `Bus::set_state`, which emits
`TurnStateChanged`.
- `POST /api/model` (`model=<name>`) — switch the model for
future turns. `Bus::set_model` emits `ModelChanged`.
- `POST /api/new-session` — arm a one-shot for the next turn to
drop `--continue`. Emits a `LiveEvent::Note`.
- `GET /events/history` — replay buffer for the terminal.
- `GET /screen` — VNC viewer page (minimal RFB-over-WebSocket
renderer). Only accessible when `hyperhive.gui.enable = true`
in the agent's `agent.nix`; the harness shows a 🖥 screen link
in the state row when `gui_vnc_port` is present. Toolbar:
`⤢ fit` CSS-downscales the canvas to the window; `⤡ match size`
sends an RFB `SetDesktopSize` request so the server (weston)
changes its real output resolution to the window dimensions —
enabled once the server advertises the `ExtendedDesktopSize`
pseudo-encoding (issue #133).
- `GET /screen/ws` — raw RFB byte relay: proxies WebSocket
frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
Transparent to any RFB variant. VNC port comes from
`/etc/hyperhive/gui.json` (written by the weston startup
script in `weston-vnc.nix`).
Bus events (new vocabulary on `/events/stream`):
- `status_changed { status }``online` / `rate_limited` /
`needs_login_idle` / `needs_login_in_progress`. Drives the
alive-badge. `rate_limited` is set when the harness detects a
429 response and cleared when the retry sleep expires.
- `model_changed { model }` — drives the model chip.
- `token_usage_changed { ctx: TokenUsage, cost: TokenUsage }`
— drives the ctx + cost badges. Emitted from
`Bus::record_turn_usage` at turn-end; `ctx` is the last
inference's usage (current context size), `cost` is the
cumulative across every inference (the `result` event's
totals).
- `turn_state_changed { state, since_unix }` — drives the
state badge (`idle`/`thinking`/`compacting`).
### Stats page
`GET /stats` is a separate per-agent page (served by the
harness, linked from the per-agent page's `📊 stats →` and from
each dashboard container row). Turn analytics, read-only, from
`/state/hyperhive-turn-stats.sqlite`. `GET /api/stats?window=
24h|7d|30d` returns a time-bucketed `Snapshot`; the page renders
it with Chart.js (vendored from a CDN). Charts: turns,
duration (p50 · p95 · avg), context tokens, token cost per
bucket, a **turns-by-model** stacked bar (model choice drives
token cost, so it sits directly under the cost chart), and
doughnuts for tool / wake-source / result mix. A summary chip
row carries window totals. `stats.rs` opens the sqlite db
read-only and degrades to an empty snapshot on any error — the
page is decorative, never authoritative.

1102
flake.lock generated

File diff suppressed because it is too large Load diff

307
flake.nix
View file

@ -1,178 +1,243 @@
{
description = "hyperhive multi-Claude-Code-agent orchestration on nixos-containers";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
home-manager = {
url = "github:nix-community/home-manager/release-25.11";
inputs.nixpkgs.follows = "nixpkgs";
};
#keep-sorted start block=yes
flake-parts = {
url = "github:hercules-ci/flake-parts";
#inputs.nixpkgs.follows = "nixpkgs";
};
hyperhive = {
url = "git+https://git.berlin.ccc.de/vinzenz/hyperhive.git";
inputs = {
nixpkgs.follows = "nixpkgs";
nixpkgs-unstable.follows = "nixpkgs-unstable";
};
};
lanzaboote = {
url = "github:nix-community/lanzaboote/v0.4.3";
inputs.nixpkgs.follows = "nixpkgs";
};
naersk = {
url = "github:nix-community/naersk";
inputs.nixpkgs.follows = "nixpkgs";
};
niri = {
url = "github:sodiboo/niri-flake";
inputs = {
nixpkgs.follows = "nixpkgs";
nixpkgs-stable.follows = "nixpkgs";
};
};
nix-filter.url = "github:numtide/nix-filter";
nix-vscode-extensions = {
url = "github:nix-community/nix-vscode-extensions";
inputs.nixpkgs.follows = "nixpkgs";
};
nixos-generators = {
url = "github:nix-community/nixos-generators";
inputs.nixpkgs.follows = "nixpkgs";
};
nixos-raspberrypi = {
url = "github:nvmd/nixos-raspberrypi/main";
};
nova-shell = {
url = "git+https://git.berlin.ccc.de/vinzenz/nova-shell";
inputs.nixpkgs.follows = "nixpkgs-unstable";
};
nur = {
url = "github:nix-community/NUR";
inputs = {
nixpkgs.follows = "nixpkgs";
flake-parts.follows = "flake-parts";
};
};
servicepoint-cli = {
url = "git+https://git.berlin.ccc.de/servicepoint/servicepoint-cli.git";
inputs = {
nixpkgs.follows = "nixpkgs";
naersk.follows = "naersk";
nix-filter.follows = "nix-filter";
treefmt-nix.follows = "treefmt-nix";
};
};
servicepoint-simulator = {
url = "git+https://git.berlin.ccc.de/servicepoint/servicepoint-simulator.git";
inputs = {
# TODO: update flake to 25.11
# nixpkgs.follows = "nixpkgs";
naersk.follows = "naersk";
nix-filter.follows = "nix-filter";
};
};
servicepoint-tanks = {
url = "git+https://git.berlin.ccc.de/vinzenz/servicepoint-tanks.git?ref=service-improvements";
inputs.nixpkgs.follows = "nixpkgs";
};
stylix = {
url = "github:nix-community/stylix/release-25.11";
inputs = {
nixpkgs.follows = "nixpkgs";
nur.follows = "nur";
flake-parts.follows = "flake-parts";
};
};
treefmt-nix = {
url = "github:numtide/treefmt-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
zerforschen-plus = {
url = "git+https://git.berlin.ccc.de/vinzenz/zerforschen.plus";
inputs.nixpkgs.follows = "nixpkgs";
};
#keep-sorted end
};
outputs =
inputs@{
self,
nixpkgs,
# keep-sorted start
niri,
nix-vscode-extensions,
nixpkgs-unstable,
naersk,
treefmt-nix,
# keep-sorted end
...
}:
let
inherit (nixpkgs) lib;
nixosConfigurations = import ./nixosConfigurations.nix { inherit inputs lib; };
supported-systems = lib.unique (lib.mapAttrsToList (_: v: v.pkgs.system) nixosConfigurations);
systems = [
"aarch64-linux"
"x86_64-linux"
];
treefmt-config = {
projectRootFile = "flake.nix";
programs = {
nixfmt.enable = true;
jsonfmt.enable = true;
prettier.enable = true;
keep-sorted.enable = true;
nixfmt.enable = true;
rustfmt.enable = true;
taplo.enable = true;
};
};
forAllSystems =
f:
lib.genAttrs supported-systems (
lib.genAttrs systems (
system:
f rec {
inherit system;
pkgs = nixpkgs.legacyPackages.${system};
treefmt-eval = treefmt-nix.lib.evalModule pkgs treefmt-config;
naersk-lib = pkgs.callPackage naersk { };
}
);
importModuleDir =
directory:
nixpkgs.lib.packagesFromDirectoryRecursive {
inherit directory;
callPackage = path: _args: path;
};
in
{
overlays = {
unstable = final: prev: {
unstable = import nixpkgs-unstable {
localSystem = prev.stdenv.hostPlatform;
inherit (prev) config;
packages = forAllSystems (
{ pkgs, naersk-lib, ... }:
{
default = naersk-lib.buildPackage {
src = ./.;
# librsvg ships `rsvg-convert`, which hive-c0re/build.rs
# invokes to render branding/agent-configs.svg into the
# PNG it embeds via `include_bytes!` (#424). Keeps the
# raster out of git — SVG stays source-of-truth, PNG is
# a build artifact in $OUT_DIR.
nativeBuildInputs = [ pkgs.librsvg ];
meta.description = "hyperhive workspace (hive-c0re, hive-ag3nt, hive-m1nd)";
};
# Bundled browser assets — see ./nix/frontend.nix. Output is
# $out/{dashboard,agent}/ which the Rust binaries serve via
# tower_http::ServeDir (wired up in Phase 4 of #273).
frontend = pkgs.callPackage ./nix/frontend.nix {
branding-svg = ./branding/hyperhive.svg;
};
# Pre-built per-container system closures. Exposed as packages
# so operators can `nix build .#agent-base-toplevel` (or wire
# them into their host system closure via the
# `preBuildAgentTemplates` option on the hive-c0re module —
# see nix/modules/hive-c0re.nix). Speeds up the first agent
# spawn dramatically because the heavy lifting (nixpkgs +
# claude-code + hive-ag3nt binary) is already in the store
# when the meta evaluator goes to build the container.
# Closes #97.
#
# nixosConfigurations are pinned to x86_64-linux (nixos-
# containers only run native arch), so these toplevels are
# only useful on an x86_64-linux host — flake check across
# systems still tolerates evaluating them on aarch64 because
# they're plain derivations, but `nix build` from a non-x86
# host would only succeed via a remote x86 builder.
agent-base-toplevel = self.nixosConfigurations.agent-base.config.system.build.toplevel;
manager-toplevel = self.nixosConfigurations.manager.config.system.build.toplevel;
}
);
overlays = {
default = final: prev: {
hyperhive = self.packages.${prev.stdenv.hostPlatform.system}.default;
# Bundled frontend dist (see ./nix/frontend.nix). Output is
# $out/{dashboard,agent}/; consumers pick the surface they
# need. Exposed via the overlay so containers' nix evaluations
# can reach it as `pkgs.hyperhive-frontend` once the overlay
# is applied (manager + agent containers both apply it via
# `mkContainer` further down).
hyperhive-frontend = self.packages.${prev.stdenv.hostPlatform.system}.frontend;
};
vscodeExtensions = nix-vscode-extensions.overlays.default;
niri = niri.overlays.niri;
claude-unstable =
final: prev:
let
# The overlay imports its own nixpkgs-unstable instance to
# pin claude-code there. That instance has its own config
# (independent from the user's prev.config), so we have to
# set allowUnfreePredicate inline to whitelist claude-code
# specifically — otherwise the unstable import itself
# refuses to evaluate. This is scoped: only claude-code
# bypasses unfree, nothing else.
unstable = import nixpkgs-unstable {
inherit (prev.stdenv.hostPlatform) system;
config.allowUnfreePredicate = pkg: builtins.elem (prev.lib.getName pkg) [ "claude-code" ];
};
in
{
inherit (unstable) claude-code;
};
};
nixosModules = (importModuleDir ./nixosModules) // {
default = {
imports = builtins.attrValues (builtins.removeAttrs self.nixosModules [ "default" ]);
nixosModules = {
agent-base = ./nix/templates/agent-base.nix;
manager = ./nix/templates/manager.nix;
# The hive-c0re module wants `pkgs.hyperhive` for its default
# `services.hive-c0re.package`. To avoid making operators apply an
# overlay (which would also pollute their host pkgs with our
# build), we thread the package straight from this flake's
# `packages.<system>.default` via a `hyperhivePackage` argument.
# The `claude-unstable` overlay only matters inside our container
# builds (already applied internally in `nixosConfigurations`).
hive-c0re = import ./nix/modules/hive-c0re.nix {
hyperhivePackage = system: self.packages.${system}.default;
hyperhiveFrontend = system: self.packages.${system}.frontend;
hyperhiveFlake = "${self}";
# Per-container toplevels — wired into `system.extraDependencies`
# when `services.hive-c0re.preBuildAgentTemplates` is on so the
# host system closure pre-fetches the heavy build inputs (#97).
# Defined only for x86_64-linux because nixosConfigurations are
# hardcoded to that system; the option's default keeps the
# extra deps gated so aarch64 hosts don't accidentally pull
# them in via cross-build.
agentBaseToplevel = self.packages.x86_64-linux.agent-base-toplevel;
managerToplevel = self.packages.x86_64-linux.manager-toplevel;
};
hive-forge = ./nix/modules/hive-forge.nix;
# Convenience alias: one import covers the full hyperhive host
# stack (hive-c0re + hive-forge, since hive-c0re already pulls
# in hive-forge). Intended usage:
#
# imports = [ hyperhive.nixosModules.default ];
# services.hive-c0re.enable = true;
#
default = self.nixosModules.hive-c0re;
};
homeModules = importModuleDir ./homeModules;
homeConfigurations = {
muede = ./homeConfigurations/muede;
ronja = ./homeConfigurations/ronja;
};
nixosConfigurations =
let
mkContainer =
module:
nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
module
{
nixpkgs.overlays = [
self.overlays.default
self.overlays.claude-unstable
];
}
];
};
in
{
agent-base = mkContainer self.nixosModules.agent-base;
manager = mkContainer self.nixosModules.manager;
};
inherit nixosConfigurations;
devShells = forAllSystems (
{ pkgs, ... }:
{
default = pkgs.mkShell {
packages = with pkgs; [
cargo
clippy
librsvg # rsvg-convert — hive-c0re/build.rs invokes it (#424)
pkg-config
rust-analyzer
rustc
rustfmt
sqlite
];
};
}
);
formatter = forAllSystems ({ treefmt-eval, ... }: treefmt-eval.config.build.wrapper);
checks = forAllSystems (
{ treefmt-eval, ... }:
{
treefmt-eval,
pkgs,
naersk-lib,
...
}:
{
formatting = treefmt-eval.config.build.check self;
# Clippy as a check: reuse naersk's vendored-deps environment but
# replace the build phase with `cargo clippy --workspace --all-targets
# -- -D warnings`. Naersk's own `mode = "clippy"` mangles the `--`
# separator, so we go through overrideAttrs instead.
clippy =
(naersk-lib.buildPackage {
src = ./.;
# Skip the actual build; we only care about the clippy lint.
doCheck = false;
copyTarget = false;
# hive-c0re/build.rs needs rsvg-convert on PATH (#424);
# mirror the runtime derivation's nativeBuildInputs so
# clippy's vendored-deps build phase doesn't break on
# the missing tool.
nativeBuildInputs = [ pkgs.librsvg ];
}).overrideAttrs
(old: {
name = "${old.name}-clippy";
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.clippy ];
buildPhase = ''
runHook preBuild
cargo clippy --workspace --all-targets -- -D warnings
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
touch $out/.clippy-passed
runHook postInstall
'';
});
}
);
};

2
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
packages/*/dist/

33
frontend/README.md Normal file
View file

@ -0,0 +1,33 @@
# hyperhive frontend
npm workspaces project for the hyperhive browser-facing assets:
- `packages/shared/` — shared modules used by both surfaces (terminal
pane, Catppuccin palette + body typography).
- `packages/dashboard/` — the hive-c0re dashboard SPA.
- `packages/agent/` — the per-container web UI (default agent page,
stats, screen).
## Build
```
npm install # one-off; uses the checked-in package-lock.json
npm run build # builds every workspace into packages/*/dist/
```
The Rust binaries serve `packages/dashboard/dist/` and
`packages/agent/dist/` via `tower_http::ServeDir` at runtime; the
build derivation is wired up in `nix/modules/frontend.nix`. Per-agent
additions are layered on top of the default agent dist via the
`hyperhive.frontend.extraFiles` option in `agent.nix`.
## Why npm + esbuild
- **Hermetic**: dependencies vendored via the checked-in lockfile;
`buildNpmPackage` in nix uses it as the source-of-truth so the
output is reproducible without network access at build time.
- **esbuild**: vanilla-JS bundler, no framework runtime overhead.
Each workspace's `build.mjs` is ~30 lines.
- **Single-PR migration**: see issue #273 for the design proposal and
the four-commit shape (npm scaffold → nix derivations → container
plumbing → Rust cutover).

549
frontend/package-lock.json generated Normal file
View file

@ -0,0 +1,549 @@
{
"name": "hyperhive-frontend",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hyperhive-frontend",
"version": "0.0.0",
"workspaces": [
"packages/shared",
"packages/dashboard",
"packages/agent"
],
"devDependencies": {
"esbuild": "0.25.5"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz",
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz",
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz",
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz",
"integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz",
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz",
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz",
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz",
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz",
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz",
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz",
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz",
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz",
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz",
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz",
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz",
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz",
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz",
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz",
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz",
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz",
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz",
"integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@hive/agent": {
"resolved": "packages/agent",
"link": true
},
"node_modules/@hive/dashboard": {
"resolved": "packages/dashboard",
"link": true
},
"node_modules/@hive/shared": {
"resolved": "packages/shared",
"link": true
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/chart.js": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz",
"integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/esbuild": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.5",
"@esbuild/android-arm": "0.25.5",
"@esbuild/android-arm64": "0.25.5",
"@esbuild/android-x64": "0.25.5",
"@esbuild/darwin-arm64": "0.25.5",
"@esbuild/darwin-x64": "0.25.5",
"@esbuild/freebsd-arm64": "0.25.5",
"@esbuild/freebsd-x64": "0.25.5",
"@esbuild/linux-arm": "0.25.5",
"@esbuild/linux-arm64": "0.25.5",
"@esbuild/linux-ia32": "0.25.5",
"@esbuild/linux-loong64": "0.25.5",
"@esbuild/linux-mips64el": "0.25.5",
"@esbuild/linux-ppc64": "0.25.5",
"@esbuild/linux-riscv64": "0.25.5",
"@esbuild/linux-s390x": "0.25.5",
"@esbuild/linux-x64": "0.25.5",
"@esbuild/netbsd-arm64": "0.25.5",
"@esbuild/netbsd-x64": "0.25.5",
"@esbuild/openbsd-arm64": "0.25.5",
"@esbuild/openbsd-x64": "0.25.5",
"@esbuild/sunos-x64": "0.25.5",
"@esbuild/win32-arm64": "0.25.5",
"@esbuild/win32-ia32": "0.25.5",
"@esbuild/win32-x64": "0.25.5"
}
},
"node_modules/marked": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
"integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 12"
}
},
"packages/agent": {
"name": "@hive/agent",
"version": "0.0.0",
"dependencies": {
"@hive/shared": "*",
"chart.js": "4.4.4",
"marked": "4.3.0"
}
},
"packages/dashboard": {
"name": "@hive/dashboard",
"version": "0.0.0",
"dependencies": {
"@hive/shared": "*",
"marked": "4.3.0"
}
},
"packages/shared": {
"name": "@hive/shared",
"version": "0.0.0"
}
}
}

18
frontend/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "hyperhive-frontend",
"version": "0.0.0",
"private": true,
"description": "Frontend assets for the hyperhive dashboard and per-agent UIs. Built with esbuild into static dist directories that the Rust binaries serve via tower_http::ServeDir.",
"workspaces": [
"packages/shared",
"packages/dashboard",
"packages/agent"
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"clean": "rm -rf packages/*/dist"
},
"devDependencies": {
"esbuild": "0.25.5"
}
}

View file

@ -0,0 +1,60 @@
// esbuild build for @hive/agent. Output layout (`dist/`):
//
// dist/index.html served at GET /
// dist/stats.html served at GET /stats
// dist/screen.html served at GET /screen
// dist/static/app.js served at /static/app.js (ESM bundle,
// pulls in @hive/shared + marked)
// dist/static/app.js.map source map sibling
// dist/static/stats.js served at /static/stats.js (pulls in
// chart.js/auto)
// dist/static/stats.js.map source map sibling
// dist/static/agent.css served at /static/agent.css (@import
// resolved from @hive/shared)
//
// The in-container Rust binary mounts `dist/` (with per-agent
// `hyperhive.frontend.extraFiles` layered on top) as a
// `tower_http::ServeDir` fallback; the layout above keeps every URL
// the HTML references reachable without rewriting paths.
import { build } from 'esbuild';
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const src = (p) => resolve(here, 'src', p);
const dist = (p) => resolve(here, 'dist', p);
const staticDir = (p) => resolve(here, 'dist', 'static', p);
rmSync(dist(''), { recursive: true, force: true });
mkdirSync(staticDir(''), { recursive: true });
// Two JS entries: the main app + the stats page. Both bundle their
// own deps so each page can be loaded independently.
await build({
entryPoints: [src('app.js'), src('stats.js')],
outdir: staticDir(''),
bundle: true,
format: 'esm',
platform: 'browser',
target: ['es2022'],
sourcemap: true,
logLevel: 'info',
});
// Bundle the CSS — the @import lines pull in shared/base.css and
// shared/terminal.css from the @hive/shared workspace dep.
await build({
entryPoints: [src('agent.css')],
outfile: staticDir('agent.css'),
bundle: true,
loader: { '.css': 'css' },
logLevel: 'info',
});
for (const html of ['index.html', 'stats.html', 'screen.html']) {
copyFileSync(src(html), dist(html));
}
console.log('agent build ok →', dist(''));

View file

@ -0,0 +1,15 @@
{
"name": "@hive/agent",
"version": "0.0.0",
"private": true,
"description": "hive-ag3nt per-container web UI. Bundled by esbuild into a static dist; served by the in-container Rust binary at runtime via tower_http::ServeDir. Per-agent additions are layered on top via the hyperhive.frontend.extraFiles nix option.",
"type": "module",
"scripts": {
"build": "node ./build.mjs"
},
"dependencies": {
"@hive/shared": "*",
"marked": "4.3.0",
"chart.js": "4.4.4"
}
}

View file

@ -0,0 +1,810 @@
/* Shared Catppuccin palette + body typography + terminal pane styles.
Bundled in front of the agent-only rules below via esbuild. */
@import "@hive/shared/base.css";
@import "@hive/shared/terminal.css";
/* full-screen vibec0re overhaul (issue #360)
Layout shape: fixed-position frosted-glass header at top, fixed-
position composer at bottom, full-viewport terminal in between.
The terminal scrolls its text passes BENEATH the floating
header/composer with backdrop-filter blur for the frosted look.
Inbox + loose-ends move into the side-panel flyout; header pills
surface their counts as the only chrome they get. */
:root {
/* Bumped to 6em (#394) so the agent icon can be a full-height
square identity anchor without crowding the two-row main column
(title + nav-links on top, state strip below). */
--agent-header-h: 6em;
--agent-composer-h: 3.6em;
--agent-frost-bg: rgba(30, 30, 46, 0.72);
--agent-frost-blur: blur(12px) saturate(140%);
}
html, body { height: 100%; margin: 0; }
/* Legacy in-page layout retained for the sibling stats page
(`stats.html`) which doesn't apply `body.agent-shell` and stays
on a normal-document scroll. */
body:not(.agent-shell) {
max-width: 110em;
margin: 1.5em auto;
padding: 0 1.5em;
height: auto;
}
.banner {
text-align: center;
margin: 0 0 1em 0;
font-size: 0.95em;
overflow-x: auto;
background: linear-gradient(
90deg,
var(--purple-dim) 0%,
var(--purple) 50%,
var(--purple-dim) 100%
);
background-size: 200% 100%;
background-position: 50% 0;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(0 0 6px rgba(203, 166, 247, 0.45));
}
body.agent-shell {
background: var(--bg);
color: var(--fg);
/* Body itself doesn't scroll; the terminal does inside .agent-main. */
overflow: hidden;
/* Subtle radial accent to give the otherwise-flat full-screen
surface some depth and reinforce the vibec0re mood. */
background:
radial-gradient(ellipse 80% 60% at 50% 0%,
rgba(203, 166, 247, 0.06) 0%,
transparent 60%),
var(--bg);
}
.agent-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 30;
min-height: var(--agent-header-h);
display: flex;
/* align-items: stretch lets the icon take the full header height
(it sizes itself via aspect-ratio off the stretched height). The
main column + pills column self-centre via inner layout. */
align-items: stretch;
gap: 0.9em;
padding: 0.5em 1em;
background: var(--agent-frost-bg);
-webkit-backdrop-filter: var(--agent-frost-blur);
backdrop-filter: var(--agent-frost-blur);
border-bottom: 1px solid var(--purple-dim);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
}
/* Main column: title row on top, state strip below (#394). Centred
vertically against the full-height icon on the left. */
.agent-header-main {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.45em;
min-width: 0;
flex: 1 1 auto;
}
.agent-header-row {
display: flex;
align-items: center;
gap: 0.8em;
flex-wrap: wrap;
min-width: 0;
}
.agent-header-title-row h2 {
margin: 0;
line-height: 1;
}
.agent-nav {
display: flex;
flex-wrap: wrap;
gap: 0.4em 0.8em;
margin: 0;
}
.agent-state-row {
margin: 0;
gap: 0.5em;
}
/* Right cluster flyout pills stacked / inline with the overflow
trigger. Vertically centred against the full-height icon, no
wrap; pills can drop to a row of their own under crowding via
the flex-wrap of `.agent-header-pills` itself. */
.agent-header-pills {
display: flex;
align-items: center;
gap: 0.5em;
flex-shrink: 0;
flex-wrap: wrap;
justify-content: flex-end;
align-self: center;
}
h2, h3 {
color: var(--purple);
text-transform: uppercase;
letter-spacing: 0.15em;
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
}
.agent-icon {
/* Square identity anchor (#394 mara's spec). Explicit em sizing
so the <img>'s intrinsic (large) dimensions don't push the
parent flex container open via `align-items: stretch`-driven
height feedback. Width = header content area (header min-h 6em
- 2 × 0.5em padding 5em). Sticks to the top so a state-row
wrap doesn't drag the icon down with it. (#411) */
width: 5em;
height: 5em;
flex-shrink: 0;
align-self: flex-start;
border-radius: 8px;
box-shadow: 0 0 18px -2px rgba(203, 166, 247, 0.4);
object-fit: cover;
}
/* Meta-nav links (stats / screen / forge / dashboard / extras)
no underline (#394 mara's spec); hover lights with cyan glow +
subtle background tint. Reads as a row of soft tabs rather than
default-styled inline anchors. */
.agent-nav-link {
color: var(--cyan);
text-decoration: none;
font-size: 0.85em;
letter-spacing: 0.04em;
padding: 0.1em 0.35em;
border-radius: 3px;
text-shadow: 0 0 4px rgba(137, 220, 235, 0.4);
transition: color 0.15s ease, text-shadow 0.15s ease, background 0.15s ease;
}
.agent-nav-link:hover {
color: var(--fg);
background: rgba(137, 220, 235, 0.08);
text-shadow: 0 0 10px rgba(137, 220, 235, 0.85);
}
/* Overflow menu trigger `` round button on the right of the
pills row. Quiet by default, lights on hover / open (#394). */
.overflow-btn {
background: transparent;
border: 1px solid var(--purple-dim);
color: var(--muted);
border-radius: 999px;
width: 2em;
height: 1.8em;
font-size: 1em;
line-height: 1;
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.overflow-btn:hover,
.overflow-btn[aria-expanded="true"] {
color: var(--purple);
border-color: var(--purple);
box-shadow: 0 0 10px -2px var(--purple);
}
/* Overflow popover rebuild + new-session (and the dashboard
back-link, prepended in app.js setHeader). Positioned in JS so
the menu's top-right corner anchors under the trigger button.
`:not([hidden])` scoping (#411): the `[hidden]` HTML attribute
sets `display: none` via the UA stylesheet, but author CSS's
`display: flex` would override that. Scope display rules so
they apply only when the menu is unhidden. */
.overflow-menu:not([hidden]) {
display: flex;
flex-direction: column;
gap: 0.15em;
}
.overflow-menu {
position: fixed;
background: var(--agent-frost-bg);
-webkit-backdrop-filter: var(--agent-frost-blur);
backdrop-filter: var(--agent-frost-blur);
border: 1px solid var(--purple-dim);
border-radius: 6px;
padding: 0.35em;
z-index: 40;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.45);
min-width: 14em;
}
.overflow-item {
background: transparent;
border: 1px solid transparent;
color: var(--fg);
font-family: inherit;
font-size: 0.9em;
text-align: left;
padding: 0.4em 0.7em;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.6em;
letter-spacing: 0.06em;
text-decoration: none;
text-shadow: 0 0 4px currentColor;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.overflow-item:hover {
background: rgba(203, 166, 247, 0.08);
border-color: var(--purple-dim);
}
.overflow-item-icon {
font-size: 1.05em;
width: 1.4em;
text-align: center;
flex-shrink: 0;
}
.overflow-item-rebuild { color: var(--amber); }
.overflow-item-new-session { color: var(--amber); }
.overflow-item-rebuild:hover,
.overflow-item-new-session:hover {
background: rgba(250, 179, 135, 0.1);
border-color: var(--amber);
}
.overflow-item-dashboard { color: var(--cyan); }
.overflow-item-dashboard:hover {
background: rgba(137, 220, 235, 0.1);
border-color: var(--cyan);
}
.overflow-item:disabled {
opacity: 0.4;
cursor: progress;
}
/* Header pill — inbox / loose-ends triggers. Compact, count-prominent. */
.header-pill {
background: transparent;
border: 1px solid var(--purple-dim);
color: var(--fg);
font-family: inherit;
font-size: 0.85em;
letter-spacing: 0.04em;
border-radius: 999px;
padding: 0.25em 0.7em;
display: inline-flex;
align-items: center;
gap: 0.4em;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
}
.header-pill:hover {
border-color: var(--purple);
color: var(--purple);
box-shadow: 0 0 10px -2px var(--purple);
}
.header-pill-icon { font-size: 1.05em; line-height: 1; }
.header-pill-label { color: var(--muted); }
.header-pill-count {
background: var(--purple-dim);
color: var(--purple);
border-radius: 999px;
padding: 0 0.5em;
min-width: 1.6em;
text-align: center;
font-weight: bold;
font-variant-numeric: tabular-nums;
}
.header-pill-inbox .header-pill-count {
background: rgba(250, 179, 135, 0.18);
color: var(--amber);
}
.header-pill-loose .header-pill-count {
background: rgba(243, 139, 168, 0.18);
color: var(--red);
}
.agent-main {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
/* Login flow overlay: only rendered when status != online. Sits
centred over the (likely-empty) terminal area; doesn't take chrome
space in the normal online flow. */
.agent-status-overlay {
position: absolute;
top: calc(var(--agent-header-h) + 1.5em);
left: 50%;
transform: translateX(-50%);
max-width: 44em;
width: calc(100% - 3em);
z-index: 10;
}
.agent-status-overlay:empty { display: none; }
.agent-status-overlay > * {
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
border-radius: 6px;
padding: 1em 1.2em;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
}
.agent-composer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 30;
min-height: var(--agent-composer-h);
background: var(--agent-frost-bg);
-webkit-backdrop-filter: var(--agent-frost-blur);
backdrop-filter: var(--agent-frost-blur);
border-top: 1px solid var(--purple-dim);
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.35);
}
.agent-composer .term-input {
/* The composer is its own chrome now drop the in-terminal-wrap
padding the legacy layout assumed. */
padding: 0.45em 1em;
}
.agent-composer .term-input .sendform-term {
/* No dashed top-border in the floating composer the box-shadow
and frosted border already separate it from the terminal. */
border-top: 0;
padding-top: 0;
}
.meta { color: var(--muted); font-size: 0.85em; }
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); }
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); }
code { background: rgba(203, 166, 247, 0.12); padding: 0.05em 0.3em; border-radius: 2px; }
a {
color: var(--cyan);
text-shadow: 0 0 4px rgba(137, 220, 235, 0.5);
}
a:hover { color: var(--fg); text-shadow: 0 0 12px rgba(137, 220, 235, 0.9); }
.btn {
font-family: inherit;
font-size: 1em;
background: var(--bg);
border: 1px solid var(--purple);
color: var(--purple);
padding: 0.25em 0.8em;
cursor: pointer;
letter-spacing: 0.1em;
}
.btn {
text-shadow: 0 0 4px currentColor;
transition: box-shadow 0.15s ease, text-shadow 0.15s ease;
}
.btn:hover {
background: rgba(205, 214, 244, 0.06);
text-shadow: 0 0 10px currentColor;
box-shadow: 0 0 10px -2px currentColor;
}
.btn-login { color: var(--amber); border-color: var(--amber); }
.btn-cancel { color: var(--red); border-color: var(--red); font-size: 0.85em; padding: 0.15em 0.6em; }
/* `.btn-rebuild` was the per-agent header chip moved into the
overflow menu in #394 (`.overflow-item-rebuild` covers it now).
The dashboard has its own `.btn-rebuild` rule for the per-row
R3BU1LD form on the SW4RM tab; this one was specific to the
per-agent header.
`.btn-send` was a green send-button variant orphaned since
the dashboard's compose form was retired; no live consumer left
in either the agent or dashboard tree. */
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
.sendform input {
font-family: inherit; font-size: 1em;
background: rgba(255, 255, 255, 0.04);
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
flex: 1;
}
.sendform input:focus { outline: 1px solid var(--purple); }
.loginform { display: flex; gap: 0.6em; margin-top: 0.5em; }
.loginform input {
font-family: inherit; font-size: 1em;
background: rgba(255, 255, 255, 0.04);
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
flex: 1;
}
.loginform input:focus { outline: 1px solid var(--purple); }
pre.diff {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--purple-dim);
padding: 0.6em 0.8em;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 30em;
}
/* Per-agent inbox section collapsible, dim, lives between the
state row and the terminal so the operator can peek at what
landed without scrolling through the live tail. */
.agent-inbox {
margin: 0.4em 0;
font-size: 0.85em;
color: var(--muted);
}
.agent-inbox > summary {
cursor: pointer;
letter-spacing: 0.05em;
list-style: none;
}
.agent-inbox > summary::marker { content: ''; }
.agent-inbox[open] > summary > span::before { content: ''; }
.agent-inbox ul {
list-style: none;
padding: 0.4em 0.8em;
margin: 0.3em 0 0;
background: rgba(255, 255, 255, 0.02);
border-left: 2px solid var(--purple-dim);
max-height: 16em;
overflow-y: auto;
}
/* Inbox / loose-ends rows: header (from / sep / ts) on one line,
body on its own line below gives the body the full panel width
instead of squeezing it into a fourth grid column that wrapped
long messages over many narrow lines (issue #376). */
.agent-inbox li {
padding: 0.4em 0;
display: block;
}
.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; margin-left: 0.5em; }
.agent-inbox .inbox-from { color: var(--amber); }
.agent-inbox .inbox-sep { color: var(--muted); margin-left: 0.4em; }
.agent-inbox .inbox-body {
display: block;
color: var(--fg);
white-space: pre-wrap;
word-break: break-word;
margin-top: 0.3em;
padding-left: 0.8em;
border-left: 2px solid var(--purple-dim);
}
.agent-inbox li.inbox-reply {
padding-left: 1em;
border-left: 2px solid var(--border);
margin-left: 0.4em;
}
.agent-inbox .inbox-reply-tag { color: var(--muted); font-size: 0.85em; }
.agent-inbox .answer-form {
/* Block-level under the new layout `grid-column: 1 / -1` was
for the legacy grid; under block layout the form naturally
starts on its own row. */
display: flex;
gap: 0.4em;
align-items: flex-start;
margin-top: 0.5em;
padding-left: 0.8em;
}
.agent-inbox .answer-form textarea {
flex: 1;
font-family: inherit;
font-size: inherit;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.3em;
resize: vertical;
}
.agent-inbox .answer-form button {
font-family: inherit;
font-size: inherit;
background: var(--bg-elev);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.3em 0.7em;
cursor: pointer;
white-space: nowrap;
}
.agent-inbox .answer-form button:hover:not(:disabled) {
border-color: var(--purple);
color: var(--purple);
}
.agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; }
.agent-inbox .answer-status { color: var(--muted); align-self: center; }
.last-turn {
color: var(--muted);
font-size: 0.8em;
letter-spacing: 0.05em;
}
.model-chip {
display: inline-block;
padding: 0.1em 0.6em;
border: 1px solid var(--purple-dim);
border-radius: 999px;
color: var(--cyan);
font-size: 0.78em;
letter-spacing: 0.04em;
}
/* Context-window badge. Mirrors Claude Code's bottom-right "N tokens"
chip single primary number (total prompt tokens in use), full
breakdown on hover. Sized/coloured like a peer of model-chip so
the state row reads as one row of chrome. */
.ctx-badge {
display: inline-block;
padding: 0.1em 0.6em;
border: 1px solid var(--purple-dim);
border-radius: 999px;
color: var(--green);
font-size: 0.78em;
letter-spacing: 0.04em;
cursor: default;
white-space: pre-line;
}
/* Harness reachability badge. Same chip shape + sizing as
`.state-badge` / `.model-chip` so the state row stays visually
uniform; colour communicates the actual reachability state. */
.status-badge {
display: inline-block;
padding: 0.25em 0.8em;
border: 1px solid;
border-radius: 999px;
font-size: 0.85em;
letter-spacing: 0.05em;
}
.status-badge.status-loading { color: var(--muted); border-color: var(--purple-dim); }
.status-badge.status-online { color: var(--green); border-color: var(--green);
text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); }
.status-badge.status-rate-limited { color: var(--red); border-color: var(--red);
text-shadow: 0 0 6px rgba(243, 139, 168, 0.55); }
.status-badge.status-needs-login { color: var(--amber); border-color: var(--amber); }
.status-badge.status-offline { color: var(--muted); border-color: var(--muted); }
/* Orphaned in #394 `.btn-dashlink` chip beside the title moved
into the overflow menu (`.overflow-item-dashboard` covers it). */
.btn-cancel-turn {
font-family: inherit;
font-size: 0.8em;
letter-spacing: 0.08em;
background: transparent;
color: var(--red);
border: 1px solid var(--red);
border-radius: 999px;
padding: 0.2em 0.8em;
cursor: pointer;
text-shadow: 0 0 4px currentColor;
transition: box-shadow 0.15s ease, background 0.15s ease;
}
.btn-cancel-turn:hover {
background: rgba(243, 139, 168, 0.1);
box-shadow: 0 0 10px -2px currentColor;
}
/* Orphaned in #394 `.btn-new-session` round-pill moved into the
overflow menu (`.overflow-item-new-session` covers it; the
`:disabled` opacity treatment lives on the shared
`.overflow-item:disabled` rule). */
.state-badge {
display: inline-block;
padding: 0.25em 0.8em;
border: 1px solid;
border-radius: 999px;
font-size: 0.85em;
letter-spacing: 0.05em;
transition: color 280ms ease, border-color 280ms ease,
box-shadow 280ms ease, background 280ms ease;
}
.state-badge.state-loading {
color: var(--muted); border-color: var(--purple-dim);
}
.state-badge.state-offline {
color: var(--muted); border-color: var(--muted);
}
.state-badge.state-idle {
color: var(--cyan); border-color: var(--cyan);
text-shadow: 0 0 6px rgba(137, 220, 235, 0.55);
}
.state-badge.state-thinking {
color: var(--amber); border-color: var(--amber);
text-shadow: 0 0 6px rgba(250, 179, 135, 0.65);
animation: badge-pulse 1.8s ease-in-out infinite;
}
.state-badge.state-compacting {
color: var(--purple); border-color: var(--purple);
text-shadow: 0 0 6px rgba(203, 166, 247, 0.65);
animation: badge-pulse 1.8s ease-in-out infinite;
}
.state-badge.state-just-changed {
animation: state-flash 600ms ease-out;
}
@keyframes state-flash {
0% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
60% { box-shadow: 0 0 18px -4px currentColor, 0 0 4px 0 currentColor; }
100% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
}
/* Full-screen overrides for the shared terminal rules. The base
`.terminal-wrap` (in shared/src/terminal.css) ships a crust-on-
black frame for the in-page case; the agent page now owns the
whole viewport so the frame chrome would be redundant noise.
`.live.terminal` similarly drops the in-page max-height cap so
it can fill the main area top-to-bottom; the floating header +
composer overlay it via fixed positioning. */
.agent-main .terminal-wrap {
position: absolute;
inset: 0;
border: 0;
background: transparent;
box-shadow: none;
border-radius: 0;
margin: 0;
padding: 0;
}
.agent-main .live.terminal {
position: absolute;
inset: 0;
height: auto;
max-height: none;
/* Scroll behind the floating header/composer, but keep the first
and last rows reachable with extra padding inside the scroll
area. scroll-padding-* keeps anchor-jumps (the ` N new` pill,
focus restore) clear of the floats too. */
padding-top: calc(var(--agent-header-h) + 0.8em);
padding-bottom: calc(var(--agent-composer-h) + 0.8em);
scroll-padding-top: calc(var(--agent-header-h) + 0.8em);
scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em);
overflow: auto;
}
/* Tail pill ( N new): nudged up so it floats clear of the composer
rather than colliding with the frosted bar. z-index bumped above
the composer (z-30) so the pill sits on the top layer instead of
being clipped by the floating chrome (issue #375). */
.agent-main .tail-pill {
bottom: calc(var(--agent-composer-h) + 0.6em);
z-index: 35;
}
/* Composer chrome used to live inside `.terminal-wrap`; now lives
inside the fixed `.agent-composer` defined further up. The base
rules below stay scoped to whichever ancestor owns it. */
.term-input .sendform-term {
display: flex;
align-items: flex-start;
gap: 0.5em;
/* The dashed in-frame separator is dropped see the
.agent-composer .term-input override above for the floating-bar
variant. */
border-top: 1px dashed var(--purple-dim);
padding-top: 0.5em;
}
.term-input .prompt, .term-input .submit-hint {
padding-top: 0.25em;
}
.term-input .prompt {
color: var(--green);
text-shadow: 0 0 6px rgba(166, 227, 161, 0.6);
user-select: none;
flex: 0 0 auto;
}
.term-input textarea {
flex: 1;
background: transparent;
border: 0;
outline: 0;
color: var(--fg);
font-family: inherit;
font-size: 1em;
padding: 0.2em 0;
caret-color: var(--green);
resize: none;
overflow-y: auto;
line-height: 1.4;
min-height: 1.4em;
}
.term-input textarea::placeholder { color: var(--muted); }
.term-input .submit-hint { color: var(--muted); font-size: 0.8em; flex: 0 0 auto; }
.term-input.disabled .prompt { color: var(--muted); text-shadow: none; }
.term-input.disabled textarea { color: var(--muted); }
/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */
/* side panel (singleton drawer)
Inbox + loose-ends details open here instead of expanding inline
(issue #360). Copy of the dashboard's side-panel pattern
candidate for extraction into @hive/shared once both surfaces
stabilize. */
.side-panel {
position: fixed;
inset: 0;
z-index: 50;
/* Closed: ignore pointer events so the agent page underneath stays
interactive; `.open` flips it back on. */
pointer-events: none;
}
.side-panel-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
opacity: 0;
transition: opacity 0.2s ease;
}
.side-panel-drawer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(640px, 92vw);
display: flex;
flex-direction: column;
background: var(--bg-elev);
border-left: 2px solid var(--purple);
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.45);
transform: translateX(100%);
transition: transform 0.25s ease;
}
.side-panel.open { pointer-events: auto; }
.side-panel.open .side-panel-backdrop { opacity: 1; }
.side-panel.open .side-panel-drawer { transform: translateX(0); }
.side-panel-head {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
padding: 0.7em 1em;
border-bottom: 1px solid var(--border);
}
.side-panel-title {
color: var(--purple);
font-weight: bold;
letter-spacing: 0.05em;
word-break: break-all;
}
.side-panel-close {
flex: 0 0 auto;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
font-family: inherit;
font-size: 1em;
line-height: 1;
padding: 0.25em 0.55em;
cursor: pointer;
}
.side-panel-close:hover { border-color: var(--red); color: var(--red); }
.side-panel-body {
flex: 1 1 auto;
overflow: auto;
padding: 0.8em 1em;
}
/* Inbox / loose-ends lists rendered into the side-panel body. The
legacy <details>-collapsible variant of .agent-inbox is gone, so
here we strip the inbox-only chrome (background, border-left) and
let the panel body's own padding own the framing. */
.side-panel-body .agent-inbox {
margin: 0;
font-size: inherit;
color: var(--fg);
}
.side-panel-body .agent-inbox ul {
background: transparent;
border-left: 0;
padding: 0;
max-height: none;
overflow: visible;
}
/* Empty-state placeholders for the side panel (when count drops to 0
between the click and the render rare, but possible). */
.side-panel-empty {
color: var(--muted);
font-style: italic;
padding: 1em 0;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,109 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>hyperhive agent</title>
<link rel="icon" type="image/svg+xml" href="/icon">
<link rel="stylesheet" href="/static/agent.css">
</head>
<body class="agent-shell">
<!-- Fixed-overlay header (#394 redesign): two-row layout in the
main column — row 1 carries the title + meta-nav, row 2 carries
the live state strip. The agent icon eats the full header
height on the left as the identity anchor; flyout pills + an
overflow menu trigger sit on the right. Frosted glass over the
terminal — backdrop-filter blur shows the scrolled terminal
text behind. -->
<header class="agent-header" id="agent-header">
<img class="agent-icon" src="/icon" alt="">
<div class="agent-header-main">
<div class="agent-header-row agent-header-title-row">
<h2 id="title">◆ … ◆</h2>
<!-- Meta-nav: backend-supplied links (stats / screen / forge /
…) plus a client-injected `↑ dashboard` link prepended in
setHeader so the host dashboard stays one click away
without a separate button styled differently. -->
<nav class="meta agent-nav" id="meta-links"></nav>
</div>
<div id="state-row" class="agent-state-row agent-header-row">
<span id="alive-badge" class="status-badge status-loading" title="harness reachability"></span>
<span id="state-badge" class="state-badge state-loading">… booting</span>
<span id="model-chip" class="model-chip" hidden></span>
<span id="ctx-badge" class="ctx-badge" hidden title="tokens used in the current context window"></span>
<span id="cost-badge" class="ctx-badge" hidden title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"></span>
<span id="last-turn" class="last-turn" hidden></span>
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
</div>
</div>
<!-- Right cluster: flyout triggers + overflow menu. Pills stay
hidden until their list is non-empty; the overflow `⋯` is
always visible (rebuild + new-session live inside it per
#394 — both rare, both destructive, both deserve one extra
click). -->
<div class="agent-header-pills">
<button type="button" id="inbox-pill" class="header-pill header-pill-inbox" hidden
title="open inbox flyout">
<span class="header-pill-icon" aria-hidden="true">📬</span>
<span class="header-pill-label">inbox</span>
<span class="header-pill-count" id="inbox-count">0</span>
</button>
<button type="button" id="loose-ends-pill" class="header-pill header-pill-loose" hidden
title="open loose-ends flyout">
<span class="header-pill-icon" aria-hidden="true">🪢</span>
<span class="header-pill-label">loose ends</span>
<span class="header-pill-count" id="loose-ends-count">0</span>
</button>
<button type="button" id="overflow-btn" class="overflow-btn"
aria-haspopup="menu" aria-expanded="false"
title="more actions">⋯</button>
</div>
</header>
<!-- Overflow popover. Sits outside the header so the header's
`overflow: hidden`-adjacent ancestors don't clip it; positioned
in JS relative to the overflow button (top-right anchor). -->
<div id="overflow-menu" class="overflow-menu" role="menu" hidden></div>
<!-- Main content area. The terminal fills it edge-to-edge and
scrolls behind the floating header + composer. The `#status`
overlay renders only when login is required (transient first-
time-setup state); otherwise the terminal owns the screen. -->
<main class="agent-main" id="agent-main">
<div id="status" class="agent-status-overlay"></div>
<div class="terminal-wrap">
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
</div>
</main>
<!-- Fixed-overlay composer. Same frosted-glass treatment as the
header for symmetric framing. Empty until the harness sets up
the textarea via `renderTermInput`. -->
<footer class="agent-composer" id="agent-composer">
<div id="term-input" class="term-input"></div>
</footer>
<!-- Slide-in side panel. Singleton — JS swaps the title + body
and toggles `.open`. Shared shape with the dashboard's panel
(candidate for extraction into @hive/shared in a follow-up). -->
<div id="side-panel" class="side-panel" aria-hidden="true">
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
aria-labelledby="side-panel-title">
<header class="side-panel-head">
<span class="side-panel-title" id="side-panel-title"></span>
<button type="button" class="side-panel-close" id="side-panel-close"
title="close (esc)">✕</button>
</header>
<div class="side-panel-body" id="side-panel-body"></div>
</aside>
</div>
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
the marked npm package into app.js. -->
<script type="module" src="/static/app.js" defer></script>
</body>
</html>

View file

@ -0,0 +1,770 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>screen</title>
<link rel="icon" type="image/svg+xml" href="/icon">
<style>
/* Catppuccin Mocha palette (mirrors base.css) */
:root {
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
--text: #cdd6f4;
--subtext0:#a6adc8;
--surface0:#313244;
--surface1:#45475a;
--blue: #89b4fa;
--red: #f38ba8;
--green: #a6e3a1;
--yellow: #f9e2af;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--base); color: var(--text);
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 14px; }
#toolbar {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.4rem 0.75rem; background: var(--mantle);
border-bottom: 1px solid var(--surface0);
}
#toolbar a { color: var(--blue); text-decoration: none; font-size: 0.85rem; }
#toolbar a:hover { text-decoration: underline; }
.tbtn {
padding: 0.15rem 0.5rem; font-size: 0.72rem; font-family: inherit;
background: var(--surface0); color: var(--subtext0);
border: 1px solid var(--surface1); border-radius: 4px; cursor: pointer;
}
.tbtn.active { color: var(--green); border-color: var(--green); }
.tbtn:disabled { opacity: 0.4; cursor: default; }
#status { margin-left: auto; font-size: 0.75rem; color: var(--subtext0); }
#status.connected { color: var(--green); }
#status.error { color: var(--red); }
#debug-log {
position: fixed; bottom: 0; left: 0; right: 0; max-height: 40vh;
overflow-y: auto; background: rgba(17,17,27,0.95);
border-top: 1px solid var(--surface1);
font-size: 0.72rem; font-family: ui-monospace, monospace;
padding: 0.4rem 0.6rem; z-index: 100;
display: none; /* hidden by default; toggled by toolbar button */
}
#debug-log .dbg-line { color: var(--subtext0); margin: 1px 0; white-space: pre; }
#debug-log .dbg-line.err { color: var(--red); }
#debug-log .dbg-line.ok { color: var(--green); }
#debug-log .dbg-line.send { color: var(--blue); }
#canvas-wrap {
display: flex; justify-content: center; align-items: flex-start;
width: 100%; height: calc(100% - 36px); overflow: auto;
background: var(--crust);
}
/* Fit mode: centre the canvas (relayoutCanvas() scales it in JS to
fit the wrap) and clip any sub-pixel rounding overflow. */
#canvas-wrap.fit { align-items: center; overflow: hidden; }
canvas { display: block; cursor: default; }
/* In fit mode relayoutCanvas() sets the canvas display size explicitly.
The canvas is a flex item, and flex items default to
min-width/min-height: auto — which resolves to the canvas's intrinsic
framebuffer resolution and clamps the JS-set size straight back up,
defeating the downscale (the bug behind #133 round 1). Pin the canvas
to exactly the size relayoutCanvas() sets: min-* 0 lifts the clamp,
flex: none stops flex grow/shrink from fighting it. */
#canvas-wrap.fit canvas { flex: none; min-width: 0; min-height: 0; }
#msg {
position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
background: var(--surface0); color: var(--yellow); border-radius: 6px;
padding: 0.4rem 0.9rem; font-size: 0.8rem;
opacity: 0; transition: opacity 0.3s;
pointer-events: none;
}
</style>
</head>
<body>
<div id="toolbar">
<strong>🖥 screen</strong>
<a href="/" title="back to agent page">← agent</a>
<button id="fit-toggle" class="tbtn" title="Toggle fit-to-window scaling">⤢ fit</button>
<button id="match-toggle" class="tbtn" title="Resize the remote desktop to fit this window" disabled>⤡ match size</button>
<button id="debug-toggle" class="tbtn" title="Toggle RFB debug log">debug</button>
<span id="status">connecting…</span>
</div>
<div id="canvas-wrap"><canvas id="c"></canvas></div>
<div id="msg"></div>
<div id="debug-log"></div>
<script>
// Minimal RFB-over-WebSocket renderer.
// Connects to /screen/ws on the same host; the harness relays raw
// RFB bytes to the VNC server running inside the container.
//
// This is a deliberately thin implementation — enough to display the
// desktop and forward pointer + keyboard events. For a production-grade
// viewer, replace with noVNC (issue #52 vendors the full bundle).
(function () {
'use strict';
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const status = document.getElementById('status');
const msg = document.getElementById('msg');
const debugLog = document.getElementById('debug-log');
const debugBtn = document.getElementById('debug-toggle');
const fitBtn = document.getElementById('fit-toggle');
const matchBtn = document.getElementById('match-toggle');
const canvasWrap = document.getElementById('canvas-wrap');
// --- Debug log ---
let debugVisible = false;
debugBtn.addEventListener('click', () => {
debugVisible = !debugVisible;
debugLog.style.display = debugVisible ? 'block' : 'none';
debugBtn.classList.toggle('active', debugVisible);
});
// --- Fit-to-window toggle ---
// Scales the canvas down so the whole desktop is visible without
// scrolling. The canvas's intrinsic resolution (width/height attrs)
// is untouched — only its CSS display size changes, set explicitly
// by relayoutCanvas(). Pointer coordinates are rescaled in
// sendPointer to stay accurate. Persisted in localStorage; default
// is fit-on.
let fitMode = localStorage.getItem('screen-fit') !== 'off';
// Size the canvas. In fit mode, scale down (never up) to the wrap,
// preserving aspect ratio. Explicit px sizing rather than CSS
// max-width/max-height: on a flex item those are overridden by the
// automatic minimum size, so fit mode was a silent no-op — the
// oversized canvas just got centred and clipped (issue #133).
function relayoutCanvas() {
if (fitMode && canvas.width && canvas.height
&& canvasWrap.clientWidth && canvasWrap.clientHeight) {
const scale = Math.min(
canvasWrap.clientWidth / canvas.width,
canvasWrap.clientHeight / canvas.height,
1,
);
canvas.style.width = (canvas.width * scale) + 'px';
canvas.style.height = (canvas.height * scale) + 'px';
} else if (!fitMode) {
canvas.style.width = '';
canvas.style.height = '';
}
}
function applyFitMode() {
canvasWrap.classList.toggle('fit', fitMode);
fitBtn.classList.toggle('active', fitMode);
relayoutCanvas();
}
fitBtn.addEventListener('click', () => {
fitMode = !fitMode;
localStorage.setItem('screen-fit', fitMode ? 'on' : 'off');
applyFitMode();
});
window.addEventListener('resize', relayoutCanvas);
applyFitMode();
// --- Match-size: resize the remote desktop to this window ---
// Sends an RFB SetDesktopSize request so the VNC server (weston)
// changes its actual output resolution to match the browser
// viewport — sharper than fit-mode's CSS downscale. The button is
// enabled only once the server has advertised the ExtendedDesktopSize
// pseudo-encoding (a -308 rect). (issue #133)
let extDesktopSupported = false;
let screenId = 1; // captured from the server's ExtendedDesktopSize advert
matchBtn.addEventListener('click', () => {
if (!extDesktopSupported) return;
// Even dimensions — some servers reject odd ones.
const w = Math.max(2, canvasWrap.clientWidth & ~1);
const h = Math.max(2, canvasWrap.clientHeight & ~1);
dbg('→ request desktop resize to ' + w + 'x' + h, 'send');
sendSetDesktopSize(w, h);
});
function hex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2,'0')).join(' ');
}
function dbg(text, cls) {
console.log('[rfb]', text);
const line = document.createElement('div');
line.className = 'dbg-line' + (cls ? ' ' + cls : '');
line.textContent = text;
debugLog.appendChild(line);
debugLog.scrollTop = debugLog.scrollHeight;
}
function setStatus(text, cls) {
status.textContent = text;
status.className = cls || '';
if (cls === 'error') dbg('ERROR: ' + text, 'err');
}
function flash(text) {
msg.textContent = text;
msg.style.opacity = '1';
setTimeout(() => { msg.style.opacity = '0'; }, 2500);
}
// --- WebSocket connection ---
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/screen/ws`);
ws.binaryType = 'arraybuffer';
ws.onopen = () => { dbg('WebSocket open — starting RFB handshake', 'ok'); setStatus('handshaking…'); };
ws.onerror = () => setStatus('connection error', 'error');
ws.onclose = (e) => {
setStatus(`disconnected (${e.code})`, 'error');
flash('VNC disconnected — reload to reconnect');
};
// Accumulate received bytes in a simple ring queue
const chunks = [];
let totalBytes = 0;
ws.onmessage = (ev) => {
chunks.push(new Uint8Array(ev.data));
totalBytes += ev.data.byteLength;
processRfb();
};
// --- Minimal RFB state machine ---
// We implement just enough to handshake and receive FramebufferUpdate
// rectangles encoded as Raw (encoding 0). Other encodings are skipped.
// Keyboard and pointer events are forwarded.
let state = 'version';
let fbW = 0, fbH = 0;
let pixelFormat = null; // set after ServerInit
let updateRects = 0;
// ExtendedDesktopSize pseudo-encoding (-308), as the unsigned 32-bit
// value the rect-header encoding field is read as.
const EXT_DESKTOP_SIZE_U32 = (-308) >>> 0;
// Drain bytes from the queue into a flat buffer view
function drainTo(n) {
if (totalBytes < n) return null;
const out = new Uint8Array(n);
let off = 0;
while (off < n) {
const c = chunks[0];
const take = Math.min(c.length, n - off);
out.set(c.subarray(0, take), off);
off += take;
if (take === c.length) {
chunks.shift();
} else {
chunks[0] = c.subarray(take);
}
}
totalBytes -= n;
return out;
}
function send(data) {
if (ws.readyState === WebSocket.OPEN) {
const arr = data instanceof Uint8Array ? data : new Uint8Array(data);
dbg('→ send [' + arr.length + 'b]: ' + hex(arr.slice(0, 32)) + (arr.length > 32 ? '…' : ''), 'send');
ws.send(data);
}
}
function u32be(b, o) { return ((b[o]<<24)|(b[o+1]<<16)|(b[o+2]<<8)|b[o+3])>>>0; }
function u16be(b, o) { return ((b[o]<<8)|b[o+1])>>>0; }
// ── Apple-DH (security type 30) helpers ─────────────────────────────────
// Protocol (from neatvnc apple-dh.c):
// Server → client: generator(2) + key_size(2) + prime[key_size] + server_pub[key_size]
// Client → server: client_pub[key_size] + aes128ecb(MD5(shared_secret), creds[128])
// After SecurityResult=0: normal plaintext VNC (no session encryption)
//
// BigInt mod-pow — handles 2048-bit DH arithmetic.
function modpow(base, exp, mod) {
let r = 1n;
base = base % mod;
while (exp > 0n) {
if (exp & 1n) r = r * base % mod;
exp >>= 1n;
base = base * base % mod;
}
return r;
}
function bytesToBigInt(b) {
let n = 0n;
for (const byte of b) n = (n << 8n) | BigInt(byte);
return n;
}
function bigIntToBytes(n, len) {
const out = new Uint8Array(len);
for (let i = len - 1; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; }
return out;
}
// Compact MD5 — needed because Web Crypto doesn't expose MD5.
// Based on the RFC 1321 reference implementation, minified.
function md5(data) {
const b = data instanceof Uint8Array ? data : new Uint8Array(data);
const len = b.length;
// pad message
const padLen = ((len + 8) >>> 6 << 4) + 16;
const m = new Uint32Array(padLen);
for (let i = 0; i < len; i++) m[i>>2] |= b[i] << ((i&3)*8);
m[len>>2] |= 0x80 << ((len&3)*8);
m[padLen-2] = len*8;
const T = new Int32Array(64);
for (let i = 0; i < 64; i++) T[i] = (Math.abs(Math.sin(i+1)) * 0x100000000)|0;
let [a, b2, c, d] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476];
const S = [7,12,17,22, 5,9,14,20, 4,11,16,23, 6,10,15,21];
function add(x,y){return (x+y)|0;}
function r(v,s){return (v<<s)|(v>>>(32-s));}
for (let i = 0; i < padLen; i += 16) {
let [aa,bb,cc,dd] = [a,b2,c,d];
for (let j = 0; j < 64; j++) {
let [f, g] = j<16 ? [(bb&cc)|((~bb)&dd), j]
: j<32 ? [(dd&bb)|((~dd)&cc), (5*j+1)%16]
: j<48 ? [bb^cc^dd, (3*j+5)%16]
: [cc^(bb|(~dd)), (7*j)%16];
f = add(add(aa, f), add(m[i+g], T[j]));
// Rotation amount: round = j>>4 (changes every 16 steps),
// position-in-round = j%4. S is laid out as 4 rounds × 4.
[aa,dd,cc,bb] = [dd, cc, bb, add(bb, r(f, S[(j%4)+((j>>4)*4)]))];
}
[a,b2,c,d] = [add(a,aa), add(b2,bb), add(c,cc), add(d,dd)];
}
const out = new Uint8Array(16);
[a,b2,c,d].forEach((x,i) => {
out[i*4]=(x)&0xff; out[i*4+1]=(x>>8)&0xff;
out[i*4+2]=(x>>16)&0xff; out[i*4+3]=(x>>24)&0xff;
});
return out;
}
// AES-128-ECB encrypt 128 bytes using Web Crypto AES-CBC (null IV per block = ECB).
async function aes128ecb(key16, data128) {
const keyObj = await crypto.subtle.importKey('raw', key16, {name:'AES-CBC'}, false, ['encrypt']);
const out = new Uint8Array(128);
const iv = new Uint8Array(16); // zeroed IV → ECB mode for single blocks
for (let i = 0; i < 8; i++) {
const enc = await crypto.subtle.encrypt({name:'AES-CBC',iv}, keyObj, data128.slice(i*16,(i+1)*16));
out.set(new Uint8Array(enc,0,16), i*16);
}
return out;
}
// Apple-DH state — stored between async continuations.
let appleDhState = null;
// ────────────────────────────────────────────────────────────────────────
function processRfb() {
while (true) {
if (!tryStep()) break;
}
}
function tryStep() {
switch (state) {
case 'version': {
const b = drainTo(12);
if (!b) return false;
dbg('← server version: ' + new TextDecoder().decode(b).replace('\n','\\n'));
// Send back same version (RFB 003.008)
send(new TextEncoder().encode('RFB 003.008\n'));
state = 'security-types';
return true;
}
case 'security-types': {
const b = drainTo(1);
if (!b) return false;
const n = b[0];
dbg('← security-types: count=' + n + (n === 0 ? ' (server error!)' : ''));
if (n === 0) { setStatus('server sent 0 security types', 'error'); return false; }
const types = drainTo(n);
if (!types) { chunks.unshift(b); totalBytes += 1; return false; }
dbg('← security-types offered: [' + Array.from(types).join(', ') + ']');
// Prefer type 1 (None), then type 19 (VeNCrypt — used by neatvnc/weston
// even with --disable-transport-layer-security), else first offered.
let prefer;
if (types.indexOf(1) !== -1) prefer = 1; // plain None
else if (types.indexOf(19) !== -1) prefer = 19; // VeNCrypt
else prefer = types[0];
// Prefer: 1 (None) → 19 (VeNCrypt) → 30 (Apple-DH)
if (types.indexOf(1) !== -1) prefer = 1;
else if (types.indexOf(19) !== -1) prefer = 19;
else if (types.indexOf(30) !== -1) prefer = 30;
else {
dbg('no supported type in [' + Array.from(types).join(', ') + '] — need 1, 19, or 30', 'err');
setStatus('unsupported security types: [' + Array.from(types).join(', ') + ']', 'error');
ws.close();
return false;
}
dbg('→ choosing security type ' + prefer +
(prefer === 1 ? ' (None)' : prefer === 19 ? ' (VeNCrypt)' : ' (Apple-DH)'));
send(new Uint8Array([prefer]));
if (prefer === 1) state = 'security-result';
else if (prefer === 19) state = 'vencrypt-version';
else state = 'apple-dh-params';
return true;
}
case 'security-vnc-challenge': {
// VNC auth (type 2): we don't have the password, so send zeros.
// This will fail for password-protected servers; fine for our
// weston VNC which uses None via VeNCrypt.
const b = drainTo(16);
if (!b) return false;
dbg('← vnc-challenge (16 bytes): ' + hex(b));
send(new Uint8Array(16));
state = 'security-result';
return true;
}
// ── VeNCrypt (type 19) sub-handshake ───────────────────────────────
// neatvnc (weston VNC backend) uses VeNCrypt as the outer type even
// with --disable-transport-layer-security, offering sub-type 1 (None).
case 'vencrypt-version': {
// Server sends: major (u8), minor (u8) — e.g. 0, 2
const b = drainTo(2);
if (!b) return false;
dbg('← VeNCrypt version: ' + b[0] + '.' + b[1]);
// Echo same version back
send(new Uint8Array([b[0], b[1]]));
state = 'vencrypt-subtypes';
return true;
}
case 'vencrypt-subtypes': {
// Server sends: nSubtypes (u8), then nSubtypes × u32 sub-type ids
const nb = drainTo(1);
if (!nb) return false;
const nSub = nb[0];
dbg('← VeNCrypt nSubtypes=' + nSub);
const raw = drainTo(nSub * 4);
if (!raw) { chunks.unshift(nb); totalBytes += 1; return false; }
// Build sub-type array from big-endian u32s
const subs = [];
for (let i = 0; i < nSub; i++) subs.push(u32be(raw, i * 4));
dbg('← VeNCrypt sub-types: [' + subs.join(', ') + ']');
// Prefer sub-type 1 (VeNCrypt None) — no TLS, no password.
// Fall back to first offered.
const sub = subs.includes(1) ? 1 : subs[0];
dbg('→ choosing VeNCrypt sub-type ' + sub);
// Send chosen sub-type as big-endian u32
send(new Uint8Array([sub>>>24, (sub>>>16)&0xff, (sub>>>8)&0xff, sub&0xff]));
state = 'vencrypt-accept';
return true;
}
case 'vencrypt-accept': {
// Server sends 1 byte: 1=accepted, 0=refused
const b = drainTo(1);
if (!b) return false;
dbg('← VeNCrypt accept byte: ' + b[0] + (b[0] === 1 ? ' (ok)' : ' (REFUSED)'));
if (b[0] !== 1) { setStatus('VeNCrypt sub-type refused', 'error'); return false; }
// Sub-type 1 (None): proceed to SecurityResult
state = 'security-result';
return true;
}
// ── Apple-DH (type 30) ────────────────────────────────────────────
// Server sends: generator(2 BE) + key_size(2 BE) + prime[key_size] +
// server_pub[key_size]
// Client sends: client_pub[key_size] + aes128ecb(MD5(shared), creds[128])
// No session encryption after auth — plain RFB follows.
case 'apple-dh-params': {
const hdr = drainTo(4);
if (!hdr) return false;
const generator = u16be(hdr, 0);
const keySize = u16be(hdr, 2);
dbg('← Apple-DH: generator=' + generator + ' key_size=' + keySize);
const rest = drainTo(keySize * 2);
if (!rest) { chunks.unshift(hdr); totalBytes += 4; return false; }
const prime = rest.slice(0, keySize);
const serverPub = rest.slice(keySize);
dbg('← Apple-DH prime[0:4]=' + hex(prime.slice(0,4)) +
' server_pub[0:4]=' + hex(serverPub.slice(0,4)));
// Async DH computation — pause state machine, resume when done.
appleDhState = { generator, keySize, prime, serverPub };
state = 'apple-dh-wait';
(async () => {
try {
const p = bytesToBigInt(appleDhState.prime);
const g = BigInt(appleDhState.generator);
const ks = appleDhState.keySize;
// Generate client private key: random ks bytes, then mod p
const privBytes = crypto.getRandomValues(new Uint8Array(ks));
const priv = bytesToBigInt(privBytes) % p;
// Client public key = g ^ priv mod p
const clientPub = modpow(g, priv, p);
const clientPubBytes = bigIntToBytes(clientPub, ks);
// Shared secret = server_pub ^ priv mod p
const serverPubInt = bytesToBigInt(appleDhState.serverPub);
const shared = modpow(serverPubInt, priv, p);
const sharedBytes = bigIntToBytes(shared, ks);
// AES key = MD5(shared_secret)
const aesKey = md5(sharedBytes);
dbg('Apple-DH: shared MD5 key=' + hex(aesKey));
// Credentials: 64 bytes username + 64 bytes password.
// weston's vnc_handle_auth (libweston/backend-vnc/vnc.c) does
// getpwnam(username) and requires pw_uid == weston's own uid
// BEFORE PAM is ever consulted — an empty/garbage username is
// rejected outright. weston runs as root, so the username must
// be "root". The password stays empty; pam_permit.so on the
// weston-remote-access PAM service accepts it.
const creds = new Uint8Array(128);
creds.set(new TextEncoder().encode('root'), 0);
const encCreds = await aes128ecb(aesKey, creds);
// Send: encrypted_creds + client_pub
// neatvnc struct rfb_apple_dh_client_msg has encrypted_credentials
// at offset 0 and public_key at offset 128 (flexible array after).
const response = new Uint8Array(ks + 128);
response.set(encCreds, 0);
response.set(clientPubBytes, 128);
send(response);
dbg('→ Apple-DH response sent (' + response.length + ' bytes)', 'ok');
state = 'security-result';
processRfb(); // resume state machine
} catch(e) {
setStatus('Apple-DH error: ' + e.message, 'error');
}
})();
return false; // pause — async resumes
}
case 'apple-dh-wait':
// Async handshake in progress — don't consume bytes
return false;
// ──────────────────────────────────────────────────────────────────
case 'security-result': {
const b = drainTo(4);
if (!b) return false;
const code = u32be(b, 0);
dbg('← security-result: ' + code + ' (bytes: ' + hex(b) + ')' + (code === 0 ? ' ✓' : ' FAIL'), code === 0 ? 'ok' : 'err');
if (code !== 0) { setStatus('auth failed (code ' + code + ')', 'error'); return false; }
// ClientInit: shared flag = 1
send(new Uint8Array([1]));
state = 'server-init';
return true;
}
case 'server-init': {
const b = drainTo(24);
if (!b) return false;
// RFB ServerInit: width @ bytes 0-1, height @ bytes 2-3.
fbW = u16be(b, 0); fbH = u16be(b, 2);
// pixel format: bpp=b[4], depth=b[5], big-endian=b[6], true-colour=b[7]
// red/green/blue max/shift at b[8..17]
pixelFormat = {
bpp: b[4], depth: b[5], bigEndian: b[6], trueColour: b[7],
redMax: u16be(b, 8), greenMax: u16be(b, 10), blueMax: u16be(b, 12),
redShift: b[14], greenShift: b[15], blueShift: b[16],
bytesPerPixel: b[4] / 8,
};
const nameLen = u32be(b, 20);
const nameBytes = drainTo(nameLen);
if (!nameBytes) { chunks.unshift(b); totalBytes += 24; return false; }
dbg('← server-init: ' + fbW + 'x' + fbH + ' bpp=' + pixelFormat.bpp, 'ok');
canvas.width = fbW;
canvas.height = fbH;
relayoutCanvas();
setStatus('connected', 'connected');
// Advertise Raw + the ExtendedDesktopSize pseudo-encoding so the
// server reports (and accepts) desktop-size changes. (issue #133)
sendSetEncodings([0, -308]);
// Request full framebuffer update
requestUpdate(0, 0, 0, fbW, fbH);
state = 'normal';
return true;
}
case 'normal': {
const b = drainTo(1);
if (!b) return false;
const msgType = b[0];
if (msgType === 0) {
// FramebufferUpdate: type(1) + padding(1) + nRects(2). The type
// byte is already consumed above; hdr covers padding + nRects.
const hdr = drainTo(3);
if (!hdr) { chunks.unshift(b); totalBytes += 1; return false; }
updateRects = u16be(hdr, 1);
state = 'rect-header';
} else if (msgType === 2) {
// Bell: ignore
} else if (msgType === 3) {
// ServerCutText
const hdr = drainTo(7);
if (!hdr) { chunks.unshift(b); totalBytes += 1; return false; }
const len = u32be(hdr, 3);
const text = drainTo(len);
if (!text) { chunks.unshift(b); totalBytes += 1 + 7; return false; }
}
return true;
}
case 'rect-header': {
if (updateRects === 0) { state = 'normal'; requestUpdate(1, 0, 0, fbW, fbH); return true; }
const b = drainTo(12);
if (!b) return false;
const x = u16be(b, 0), y = u16be(b, 2), w = u16be(b, 4), h = u16be(b, 6);
const enc = (b[8]<<24|b[9]<<16|b[10]<<8|b[11])>>>0;
if (enc === 0 && pixelFormat) {
const bytes = w * h * pixelFormat.bytesPerPixel;
const pixels = drainTo(bytes);
if (!pixels) { chunks.unshift(b); totalBytes += 12; return false; }
drawRaw(x, y, w, h, pixels);
} else if (enc === EXT_DESKTOP_SIZE_U32) {
// ExtendedDesktopSize: w,h carry the new desktop dimensions;
// the rect body is nScreens(1) + pad(3) + nScreens×16. The
// header's x = change reason, y = request status. (issue #133)
const nScreens = peekByte();
if (nScreens < 0) { chunks.unshift(b); totalBytes += 12; return false; }
const body = drainTo(4 + nScreens * 16);
if (!body) { chunks.unshift(b); totalBytes += 12; return false; }
if (nScreens > 0) screenId = u32be(body, 4); // reuse the server's screen id
if (!extDesktopSupported) {
extDesktopSupported = true;
matchBtn.disabled = false;
}
if (w && h && (w !== fbW || h !== fbH)) {
dbg('← desktop resized to ' + w + 'x' + h
+ ' (reason ' + x + ', status ' + y + ')', 'ok');
fbW = w; fbH = h;
canvas.width = w; canvas.height = h;
relayoutCanvas();
requestUpdate(0, 0, 0, fbW, fbH);
}
}
updateRects--;
return true;
}
default: return false;
}
}
function drawRaw(x, y, w, h, data) {
if (!pixelFormat || w === 0 || h === 0) return;
const bpp = pixelFormat.bytesPerPixel;
const img = ctx.createImageData(w, h);
const d = img.data;
const rs = pixelFormat.redShift, gs = pixelFormat.greenShift, bs = pixelFormat.blueShift;
for (let i = 0, o = 0; i < w * h; i++, o += bpp) {
let px = 0;
if (bpp === 4) px = pixelFormat.bigEndian
? (data[o]<<24|data[o+1]<<16|data[o+2]<<8|data[o+3])>>>0
: (data[o+3]<<24|data[o+2]<<16|data[o+1]<<8|data[o])>>>0;
else if (bpp === 2) px = pixelFormat.bigEndian
? (data[o]<<8|data[o+1])>>>0 : (data[o+1]<<8|data[o])>>>0;
else px = data[o];
d[i*4] = (px >> rs) & pixelFormat.redMax;
d[i*4+1] = (px >> gs) & pixelFormat.greenMax;
d[i*4+2] = (px >> bs) & pixelFormat.blueMax;
d[i*4+3] = 255;
}
ctx.putImageData(img, x, y);
}
function requestUpdate(incremental, x, y, w, h) {
const b = new Uint8Array(10);
b[0] = 3; b[1] = incremental;
b[2] = x>>8; b[3] = x&0xff;
b[4] = y>>8; b[5] = y&0xff;
b[6] = w>>8; b[7] = w&0xff;
b[8] = h>>8; b[9] = h&0xff;
send(b);
}
// SetEncodings (msg type 2): advertise the encodings we understand.
// Negative values are pseudo-encodings (e.g. -308 ExtendedDesktopSize).
function sendSetEncodings(encs) {
const b = new Uint8Array(4 + encs.length * 4);
b[0] = 2; // message-type
b[1] = 0; // padding
b[2] = encs.length >> 8; b[3] = encs.length & 0xff;
let o = 4;
for (const e of encs) {
const v = e >>> 0; // two's-complement for negatives
b[o++] = (v>>24)&0xff; b[o++] = (v>>16)&0xff;
b[o++] = (v>>8)&0xff; b[o++] = v&0xff;
}
send(b);
}
// SetDesktopSize (msg type 251): ask the server to change the desktop
// resolution. One screen at the origin, sized to the request. (#133)
function sendSetDesktopSize(w, h) {
const b = new Uint8Array(24);
b[0] = 251; b[1] = 0; // message-type + padding
b[2] = w>>8; b[3] = w&0xff;
b[4] = h>>8; b[5] = h&0xff;
b[6] = 1; b[7] = 0; // number-of-screens + padding
// screen: id(4) x(2) y(2) width(2) height(2) flags(4)
b[8] = (screenId>>>24)&0xff; b[9] = (screenId>>>16)&0xff;
b[10] = (screenId>>>8)&0xff; b[11] = screenId&0xff;
b[12] = 0; b[13] = 0; // x-position
b[14] = 0; b[15] = 0; // y-position
b[16] = w>>8; b[17] = w&0xff;
b[18] = h>>8; b[19] = h&0xff;
b[20] = 0; b[21] = 0; b[22] = 0; b[23] = 0; // flags
send(b);
}
// Peek the first unconsumed byte without draining it. -1 when empty.
function peekByte() {
for (const c of chunks) { if (c.length) return c[0]; }
return -1;
}
// --- Input forwarding ---
canvas.addEventListener('mousemove', sendPointer);
canvas.addEventListener('mousedown', sendPointer);
canvas.addEventListener('mouseup', sendPointer);
function sendPointer(ev) {
const r = canvas.getBoundingClientRect();
// In fit mode the canvas is CSS-scaled, so the rendered rect differs
// from the intrinsic resolution — map client coords back to fb pixels.
const sx = r.width ? canvas.width / r.width : 1;
const sy = r.height ? canvas.height / r.height : 1;
const x = Math.max(0, Math.min(fbW-1, Math.round((ev.clientX - r.left) * sx)));
const y = Math.max(0, Math.min(fbH-1, Math.round((ev.clientY - r.top) * sy)));
let mask = 0;
if (ev.buttons & 1) mask |= 1;
if (ev.buttons & 4) mask |= 2;
if (ev.buttons & 2) mask |= 4;
const b = new Uint8Array(6);
b[0] = 5; b[1] = mask;
b[2] = x>>8; b[3] = x&0xff;
b[4] = y>>8; b[5] = y&0xff;
send(b);
}
document.addEventListener('keydown', (ev) => sendKey(ev, 1));
document.addEventListener('keyup', (ev) => sendKey(ev, 0));
function sendKey(ev, down) {
ev.preventDefault();
const key = rfbKeysym(ev);
const b = new Uint8Array(8);
b[0] = 4; b[1] = down; b[2] = 0; b[3] = 0;
b[4] = key>>24; b[5] = (key>>16)&0xff; b[6] = (key>>8)&0xff; b[7] = key&0xff;
send(b);
}
function rfbKeysym(ev) {
// Map common keys to X11 keysym values
const map = {
'BackSpace': 0xff08, 'Tab': 0xff09, 'Enter': 0xff0d, 'Escape': 0xff1b,
'Delete': 0xffff, 'Home': 0xff50, 'End': 0xff57, 'PageUp': 0xff55,
'PageDown': 0xff56, 'ArrowLeft': 0xff51, 'ArrowUp': 0xff52,
'ArrowRight': 0xff53, 'ArrowDown': 0xff54,
'Shift': 0xffe1, 'Control': 0xffe3, 'Alt': 0xffe9, 'Meta': 0xffe7,
'F1': 0xffbe, 'F2': 0xffbf, 'F3': 0xffc0, 'F4': 0xffc1,
'F5': 0xffc2, 'F6': 0xffc3, 'F7': 0xffc4, 'F8': 0xffc5,
'F9': 0xffc6, 'F10': 0xffc7, 'F11': 0xffc8, 'F12': 0xffc9,
};
if (map[ev.key]) return map[ev.key];
if (ev.key.length === 1) return ev.key.codePointAt(0);
return 0;
}
})();
</script>
</body>
</html>

View file

@ -0,0 +1,98 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>hyperhive agent — stats</title>
<link rel="icon" type="image/svg+xml" href="/icon">
<link rel="stylesheet" href="/static/agent.css">
<style>
.stats-nav { display: flex; gap: 0.75rem; align-items: baseline; margin-bottom: 0.5rem; }
.stats-nav a { color: var(--cyan); text-decoration: none; }
.stats-nav a:hover { text-decoration: underline; }
.window-tabs { display: flex; gap: 0.4rem; margin: 0.5rem 0 1rem; }
.window-tabs button {
background: var(--bg-elev); color: var(--fg);
border: 1px solid var(--border); padding: 0.3rem 0.8rem;
font-family: inherit; cursor: pointer;
}
.window-tabs button.active { background: var(--purple-dim); border-color: var(--purple); color: var(--purple); }
.summary { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 1rem; }
.summary .chip {
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
background: var(--bg-elev);
border: 1px solid var(--border);
padding: 0.5rem 0.9rem;
border-radius: 4px;
box-sizing: border-box;
min-width: 9rem;
height: 3.4rem;
line-height: 1.2;
}
.summary .chip .label {
color: var(--muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.summary .chip .value {
color: var(--cyan);
font-weight: bold;
font-size: 1.05rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
gap: 1rem;
}
.card {
background: var(--bg-elev);
border: 1px solid var(--border);
padding: 0.75rem 1rem 1rem;
border-radius: 4px;
}
.card h3 { margin: 0 0 0.5rem; color: var(--purple); font-size: 0.95rem; font-weight: normal; }
.card .chart-wrap { position: relative; height: 220px; }
.card.wide { grid-column: 1 / -1; }
.card.wide .chart-wrap { height: 260px; }
.empty-note { color: var(--muted); font-style: italic; }
</style>
</head>
<body>
<pre class="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt · stats ░▒▓█▓▒░</pre>
<div class="stats-nav">
<a id="back-link" href="/">← live</a>
<a id="dashboard-link" href="#">dashboard</a>
<h2 id="title" style="margin: 0;">◆ … ◆</h2>
</div>
<div class="window-tabs" id="window-tabs">
<button data-w="1h">last 1h</button>
<button data-w="4h">last 4h</button>
<button data-w="24h" class="active">last 24h</button>
<button data-w="3d">last 3d</button>
<button data-w="7d">last 7d</button>
<button data-w="30d">last 30d</button>
</div>
<div class="summary" id="summary"></div>
<div class="grid">
<div class="card wide"><h3>turns per bucket</h3><div class="chart-wrap"><canvas id="chart-turns"></canvas></div></div>
<div class="card wide"><h3>turn duration (ms) — p50 / p95 / avg</h3><div class="chart-wrap"><canvas id="chart-duration"></canvas></div></div>
<div class="card wide"><h3>context tokens (last inference per turn) — avg / max</h3><div class="chart-wrap"><canvas id="chart-ctx"></canvas></div></div>
<div class="card wide"><h3>token cost per bucket (sum across inferences)</h3><div class="chart-wrap"><canvas id="chart-cost"></canvas></div></div>
<div class="card wide"><h3>turns by model per bucket — model drives token cost</h3><div class="chart-wrap"><canvas id="chart-model"></canvas></div></div>
<div class="card"><h3>top tools</h3><div class="chart-wrap"><canvas id="chart-tools"></canvas></div></div>
<div class="card"><h3>wake source mix</h3><div class="chart-wrap"><canvas id="chart-wake"></canvas></div></div>
<div class="card"><h3>result mix</h3><div class="chart-wrap"><canvas id="chart-result"></canvas></div></div>
</div>
<!-- Chart.js is now bundled into stats.js by esbuild (npm dep
chart.js@4.4.4), so the page works offline / on operator
machines without internet egress. No SRI hash to maintain. -->
<script type="module" src="/static/stats.js" defer></script>
</body>
</html>

View file

@ -0,0 +1,346 @@
// Per-agent stats page. Fetches /api/state for the title + dashboard link
// once on load, then /api/stats?window=... for the chart data — re-fetches
// when the operator clicks a window tab.
import Chart from 'chart.js/auto';
// Expose for the IIFE below — pre-split this was a window global from
// the jsDelivr CDN script tag. esbuild now bundles chart.js into
// stats.js; once the IIFE opens up we can use the imported `Chart`
// directly.
window.Chart = Chart;
(function () {
'use strict';
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const palette = {
bg: cssVar('--bg'),
bgElev: cssVar('--bg-elev'),
fg: cssVar('--fg'),
muted: cssVar('--muted'),
purple: cssVar('--purple'),
cyan: cssVar('--cyan'),
pink: cssVar('--pink'),
amber: cssVar('--amber'),
green: cssVar('--green'),
red: cssVar('--red'),
border: cssVar('--border'),
};
// Distinct hues for categorical charts (top tools / wake mix / result mix).
const wheel = [palette.purple, palette.cyan, palette.pink, palette.amber,
palette.green, palette.red, '#94e2d5', '#f9e2af',
'#74c7ec', '#b4befe'];
// Apply Catppuccin defaults globally so each Chart inherits without per-call
// overrides. Chart.js v4 reads these on chart construction.
Chart.defaults.color = palette.fg;
Chart.defaults.borderColor = palette.border;
Chart.defaults.font.family = '"JetBrains Mono", "Fira Code", monospace';
Chart.defaults.font.size = 11;
Chart.defaults.plugins.legend.labels.color = palette.fg;
const charts = {};
let currentWindow = '24h';
function fmtMs(ms) {
if (!Number.isFinite(ms) || ms <= 0) return '0';
if (ms < 1000) return ms.toFixed(0) + 'ms';
return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + 's';
}
function fmtInt(n) {
if (!Number.isFinite(n)) return '0';
return new Intl.NumberFormat().format(Math.round(n));
}
function bucketLabel(ts, bucketSecs) {
const d = new Date(ts * 1000);
if (bucketSecs >= 86400) {
return d.toISOString().slice(5, 10); // MM-DD
}
return d.toISOString().slice(11, 16); // HH:MM
}
function destroy(name) {
if (charts[name]) {
charts[name].destroy();
delete charts[name];
}
}
function paintEmpty(canvasId, msg) {
destroy(canvasId);
const cv = document.getElementById(canvasId);
if (!cv) return;
const ctx = cv.getContext('2d');
ctx.clearRect(0, 0, cv.width, cv.height);
ctx.fillStyle = palette.muted;
ctx.font = '12px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(msg, cv.width / 2, cv.height / 2);
}
function renderSummary(s) {
const root = document.getElementById('summary');
root.replaceChildren();
const chips = [
['turns', fmtInt(s.turn_count)],
['avg duration', fmtMs(s.duration_summary.avg_ms)],
['p50 duration', fmtMs(s.duration_summary.p50_ms)],
['p95 duration', fmtMs(s.duration_summary.p95_ms)],
['window', s.window],
];
for (const [label, value] of chips) {
const chip = document.createElement('span');
chip.className = 'chip';
const l = document.createElement('span');
l.className = 'label';
l.textContent = label;
const v = document.createElement('span');
v.className = 'value';
v.textContent = value;
chip.append(l, v);
root.append(chip);
}
}
function renderTurnsChart(s) {
const id = 'chart-turns';
destroy(id);
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
const data = s.buckets.map((b) => b.turn_count);
charts[id] = new Chart(document.getElementById(id), {
type: 'bar',
data: {
labels,
datasets: [{
label: 'turns',
data,
backgroundColor: palette.purple,
borderColor: palette.purple,
borderWidth: 1,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: palette.border } },
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } },
},
},
});
}
function renderDurationChart(s) {
const id = 'chart-duration';
destroy(id);
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
const ds = (label, color, key) => ({
label, data: s.buckets.map((b) => b[key]),
borderColor: color, backgroundColor: color + '33',
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
});
charts[id] = new Chart(document.getElementById(id), {
type: 'line',
data: {
labels,
datasets: [
ds('p50', palette.cyan, 'p50_duration_ms'),
ds('p95', palette.pink, 'p95_duration_ms'),
ds('avg', palette.amber, 'avg_duration_ms'),
],
},
options: {
responsive: true, maintainAspectRatio: false,
scales: {
x: { grid: { color: palette.border } },
y: {
beginAtZero: true,
grid: { color: palette.border },
ticks: { callback: (v) => fmtMs(v) },
},
},
},
});
}
function renderCtxChart(s) {
const id = 'chart-ctx';
destroy(id);
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
charts[id] = new Chart(document.getElementById(id), {
type: 'line',
data: {
labels,
datasets: [
{
label: 'avg ctx',
data: s.buckets.map((b) => b.avg_ctx_tokens),
borderColor: palette.cyan,
backgroundColor: palette.cyan + '33',
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
},
{
label: 'max ctx',
data: s.buckets.map((b) => b.max_ctx_tokens),
borderColor: palette.amber,
backgroundColor: palette.amber + '33',
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
},
],
},
options: {
responsive: true, maintainAspectRatio: false,
scales: {
x: { grid: { color: palette.border } },
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
},
},
});
}
function renderCostChart(s) {
const id = 'chart-cost';
destroy(id);
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
// Stacked bars: cache_read (cheap) / cache_creation / input / output.
// Highlights "what's actually getting billed at full rate" vs cache hits.
charts[id] = new Chart(document.getElementById(id), {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'cache_read', data: s.buckets.map((b) => b.cache_read_input_tokens),
backgroundColor: palette.muted },
{ label: 'cache_creation', data: s.buckets.map((b) => b.cache_creation_input_tokens),
backgroundColor: palette.cyan },
{ label: 'input', data: s.buckets.map((b) => b.input_tokens),
backgroundColor: palette.amber },
{ label: 'output', data: s.buckets.map((b) => b.output_tokens),
backgroundColor: palette.pink },
],
},
options: {
responsive: true, maintainAspectRatio: false,
scales: {
x: { stacked: true, grid: { color: palette.border } },
y: { stacked: true, beginAtZero: true,
grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
},
},
});
}
function renderModelChart(s) {
const id = 'chart-model';
destroy(id);
const models = s.models || [];
if (!models.length) { paintEmpty(id, 'no turns in window'); return; }
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
// One stacked series per model. Model choice drives token cost,
// so this lines up against the cost chart above it.
const datasets = models.map((m, i) => ({
label: m,
data: s.buckets.map((b) => (b.model_counts && b.model_counts[m]) || 0),
backgroundColor: wheel[i % wheel.length],
}));
charts[id] = new Chart(document.getElementById(id), {
type: 'bar',
data: { labels, datasets },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } },
scales: {
x: { stacked: true, grid: { color: palette.border } },
y: { stacked: true, beginAtZero: true,
grid: { color: palette.border }, ticks: { precision: 0 } },
},
},
});
}
function renderKeyCount(canvasId, items, emptyMsg) {
destroy(canvasId);
if (!items || items.length === 0) {
paintEmpty(canvasId, emptyMsg);
return;
}
const labels = items.map((kc) => kc.key);
const data = items.map((kc) => kc.count);
const colors = items.map((_, i) => wheel[i % wheel.length]);
charts[canvasId] = new Chart(document.getElementById(canvasId), {
type: 'doughnut',
data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: palette.bg, borderWidth: 2 }] },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } },
},
});
}
function render(s) {
renderSummary(s);
if (s.turn_count === 0) {
paintEmpty('chart-turns', 'no turns in window');
paintEmpty('chart-duration', 'no turns in window');
paintEmpty('chart-ctx', 'no turns in window');
paintEmpty('chart-cost', 'no turns in window');
paintEmpty('chart-model', 'no turns in window');
paintEmpty('chart-tools', 'no tool calls');
paintEmpty('chart-wake', 'no wakes');
paintEmpty('chart-result', 'no results');
return;
}
renderTurnsChart(s);
renderDurationChart(s);
renderCtxChart(s);
renderCostChart(s);
renderModelChart(s);
renderKeyCount('chart-tools', s.tool_breakdown, 'no tool calls');
renderKeyCount('chart-wake', s.wake_mix, 'no wakes');
renderKeyCount('chart-result', s.result_mix, 'no results');
}
async function loadStats() {
try {
const resp = await fetch('/api/stats?window=' + encodeURIComponent(currentWindow));
if (!resp.ok) throw new Error('http ' + resp.status);
const snap = await resp.json();
render(snap);
} catch (e) {
document.getElementById('summary').textContent = 'stats fetch failed: ' + e;
}
}
async function loadIdentity() {
try {
const resp = await fetch('/api/state');
if (!resp.ok) return;
const s = await resp.json();
document.title = 'stats · ' + s.label;
document.getElementById('title').textContent = '◆ ' + s.label + ' ◆';
const dl = document.getElementById('dashboard-link');
dl.href = 'http://' + window.location.hostname + ':' + s.dashboard_port + '/';
} catch (_) { /* non-fatal */ }
}
function bindTabs() {
const tabs = document.getElementById('window-tabs');
tabs.addEventListener('click', (ev) => {
const btn = ev.target.closest('button[data-w]');
if (!btn) return;
currentWindow = btn.dataset.w;
for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn);
loadStats();
});
}
document.addEventListener('DOMContentLoaded', () => {
bindTabs();
loadIdentity();
loadStats();
});
})();

View file

@ -0,0 +1,85 @@
// esbuild build for @hive/dashboard. Output layout (`dist/`):
//
// dist/index.html served by the Rust router at GET /
// dist/flow.html served at GET /flow.html
// dist/static/tabs.js /index.html entry — tab renderers +
// tab routing + refreshState
// dist/static/flow.js /flow.html entry — broker terminal +
// operator inbox + @-mention composer
// dist/static/{tabs,flow}.js.map source map siblings
// dist/static/dashboard.css served at /static/dashboard.css
// (@import resolved from @hive/shared)
//
// Both JS entries inline `./common.js` (DOM helpers, Panel singleton,
// NOTIF, path linkification) — esbuild dedupes the shared module
// between bundles. The Rust binary mounts `dist/` as a
// `tower_http::ServeDir` fallback; the layout above keeps every URL
// the HTML files reference reachable without rewriting paths in the
// HTML.
import { build } from 'esbuild';
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const src = (p) => resolve(here, 'src', p);
const dist = (p) => resolve(here, 'dist', p);
const staticDir = (p) => resolve(here, 'dist', 'static', p);
rmSync(dist(''), { recursive: true, force: true });
mkdirSync(staticDir(''), { recursive: true });
// Bundle both JS entries. ES-module output, browser target, no minify
// (line-aligned source aids debugging; minification belongs in a later
// follow-up once asset sizes warrant it). esbuild writes each entry
// to `static/<name>.js` based on the entryPoint basename.
await build({
entryPoints: [src('tabs.js'), src('flow.js')],
outdir: staticDir(''),
bundle: true,
format: 'esm',
platform: 'browser',
target: ['es2022'],
sourcemap: true,
logLevel: 'info',
});
// Stream-worker entry (#448). Lives in a separate bundle: SharedWorker
// scripts run in a different global (`self` is the worker scope, no
// `window`) so they can't be inlined into tabs.js / flow.js. Output is
// at `static/stream-worker.js`; common.js's `openStream` references
// `/static/stream-worker.js` as the SharedWorker URL. `format: 'iife'`
// matches the classic-script load (`new SharedWorker(url, name)` with
// no `{ type: 'module' }`); Firefox is the #448 target and module
// SharedWorker support there is patchy, so keeping the worker as a
// classic script + IIFE bundle is the compatible default. argus nit
// on #453: if a future contributor adds an `import` to this bundle,
// the IIFE format will surface it as a build error rather than
// silently shipping broken code.
await build({
entryPoints: [src('stream-worker.js')],
outdir: staticDir(''),
bundle: true,
format: 'iife',
platform: 'browser',
target: ['es2022'],
sourcemap: true,
logLevel: 'info',
});
// Bundle the CSS — esbuild resolves @import including the package
// re-exports from @hive/shared.
await build({
entryPoints: [src('dashboard.css')],
outfile: staticDir('dashboard.css'),
bundle: true,
loader: { '.css': 'css' },
logLevel: 'info',
});
for (const html of ['index.html', 'flow.html']) {
copyFileSync(src(html), dist(html));
}
console.log('dashboard build ok →', dist(''));

View file

@ -0,0 +1,14 @@
{
"name": "@hive/dashboard",
"version": "0.0.0",
"private": true,
"description": "hive-c0re dashboard SPA. Bundled by esbuild into a static dist; served by the hive-c0re Rust binary at runtime via tower_http::ServeDir.",
"type": "module",
"scripts": {
"build": "node ./build.mjs"
},
"dependencies": {
"@hive/shared": "*",
"marked": "4.3.0"
}
}

View file

@ -0,0 +1,591 @@
// Shared dashboard helpers — extracted from the original monolithic
// dashboard JS as step 1 of the #406 split. These bits are used by
// both the tab dashboard (index.html) and the flow page (flow.html):
// pure DOM helpers, the side-panel singleton, the OS-notification
// module, and the path-link / file-preview infrastructure for the
// side panel.
//
// Each page now has its own entry point — `./tabs.js` for index.html,
// `./flow.js` for flow.html — and both import from here directly
// (#406 steps 2 + 3 complete; #406 closed).
import { linkify as termLinkify } from '@hive/shared/terminal.js';
// ─── helpers ────────────────────────────────────────────────────────────
export const $ = (id) => document.getElementById(id);
export const fmtAgeSecs = (s) => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m`
: s < 86400 ? `${Math.floor(s/3600)}h` : `${Math.floor(s/86400)}d`;
export const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[c])
);
export const el = (tag, attrs = {}, ...children) => {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') e.className = v;
else if (k === 'html') e.innerHTML = v;
else if (k.startsWith('data-')) e.setAttribute(k, v);
else e.setAttribute(k, v);
}
for (const c of children) {
if (c == null) continue;
e.append(c.nodeType ? c : document.createTextNode(c));
}
return e;
};
export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => {
const f = el('form', {
method: 'POST', action, class: 'inline', 'data-async': '',
...(confirmMsg ? { 'data-confirm': confirmMsg } : {}),
// Endpoints whose mutation fires a DashboardEvent (and whose
// derived store applies it live) opt out of the post-submit
// /api/state refetch. See the async-form handler.
...(opts.noRefresh ? { 'data-no-refresh': '' } : {}),
});
for (const [name, value] of Object.entries(extra)) {
f.append(el('input', { type: 'hidden', name, value }));
}
f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel));
return f;
};
// `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` stay in tabs.js
// for now — each has display-specific phrasing ("X running", "X ago")
// tied to its caller, so they don't generalise cleanly. We can lift
// them when a second consumer needs the same shape.
// ─── shared-worker SSE pipe (#448) ──────────────────────────────────────
// Returns an EventSource-shaped object backed by a SharedWorker that
// holds ONE upstream `new EventSource(url)` and fans events out to
// every connected tab. Replaces direct `new EventSource(url)` at the
// dashboard's two consumer sites (tabs.js inline + flow.js via
// terminal.js's `streamFactory` option) so N hyperhive tabs share
// ONE backend connection — way under the browser's per-host
// connection cap, immune to per-tab throttling that drops the SSE
// when Firefox suspends background tabs.
//
// Graceful fallback to direct EventSource on environments without
// SharedWorker (some embedded browsers, some Safari versions). The
// per-tab connection cost is the same as today — no regression.
//
// The page consumer uses the returned object like a regular
// EventSource: assign `onmessage` / `onopen` / `onerror`. `.close()`
// tells the worker to drop the subscription; the worker closes the
// upstream EventSource when the last subscriber leaves.
const SHARED_WORKER_PATH = '/static/stream-worker.js';
const SHARED_WORKER_NAME = 'hyperhive-stream';
// One SharedWorker port per page, reused by all openStream calls on
// that page. Invalidated on `pagehide` so a bfcache restore picks up
// a fresh port (the cached one may have been collected if all other
// tabs closed while this page was frozen — argus nit on #453).
let _sharedPort = null;
function makeSharedPort() {
if (typeof SharedWorker === 'undefined') return null;
try {
const sw = new SharedWorker(SHARED_WORKER_PATH, SHARED_WORKER_NAME);
sw.port.start();
return sw.port;
} catch (err) {
console.warn('SharedWorker unavailable, falling back to direct EventSource:', err);
return null;
}
}
function getSharedPort() {
if (!_sharedPort) _sharedPort = makeSharedPort();
return _sharedPort;
}
// Registry of live subscriptions on this page. Keyed by url so a
// second openStream call for the same URL (would only happen on a
// hypothetical multi-consumer page) attaches to the existing route
// rather than overlapping. Each entry caches the route function so
// bfcache-restore re-bind can re-attach it to the fresh port.
//
// Today's pages only call openStream once with one URL; the registry
// shape just keeps the bfcache-restore path correct if that changes
// (e.g. /index.html later subscribing to two streams).
const _activeSubs = new Map();
// One-shot wiring of the page-wide lifecycle hooks: on bfcache
// freeze (`pagehide { persisted: true }`) we unsubscribe so the
// worker can close the upstream when the last live subscriber
// leaves; on bfcache restore (`pageshow { persisted: true }`) we
// invalidate the cached port (it may be dead if all other tabs
// closed during the freeze) and re-attach every active subscription
// to a fresh port. argus nit on #453: without this, the consumer's
// onmessage stays bound but no events flow after a bfcache restore.
let _lifecycleBound = false;
function bindLifecycleOnce() {
if (_lifecycleBound) return;
_lifecycleBound = true;
window.addEventListener('pagehide', () => {
if (!_sharedPort) return;
for (const url of _activeSubs.keys()) {
try { _sharedPort.postMessage({ kind: 'unsubscribe', url }); }
catch { /* port dead — worker side already cleaned up */ }
}
// Drop port routes too; the bfcache-restore path will re-add
// them on a fresh port. Leaving stale routes on a dead port
// would just keep a closure alive without cost, but cleaning
// up keeps the registry shape honest.
for (const sub of _activeSubs.values()) {
try { _sharedPort.removeEventListener('message', sub.route); }
catch { /* same */ }
}
_sharedPort = null;
});
window.addEventListener('pageshow', (ev) => {
if (!ev.persisted) return; // cold load — openStream just bound listeners
if (!_activeSubs.size) return;
const port = getSharedPort();
if (!port) return; // SharedWorker really gone; fallback already in place
for (const [url, sub] of _activeSubs) {
sub.target.readyState = 0; // CONNECTING — the worker will fire 'open'
port.addEventListener('message', sub.route);
try { port.postMessage({ kind: 'subscribe', url }); }
catch { /* port dead immediately — skip */ }
}
});
}
export function openStream(url) {
const port = getSharedPort();
if (!port) return new EventSource(url);
bindLifecycleOnce();
// Build an EventSource-shaped facade so consumer code is unchanged.
// `target.onmessage` / `onopen` / `onerror` are assigned by the
// consumer; the routing function below forwards events received
// from the worker (filtered by url, since one port can multiplex
// multiple subscriptions).
const target = {
onmessage: null,
onopen: null,
onerror: null,
readyState: 0, // CONNECTING
close() {
const p = _sharedPort;
if (p) {
try { p.postMessage({ kind: 'unsubscribe', url }); }
catch { /* port dead */ }
try { p.removeEventListener('message', route); }
catch { /* same */ }
}
_activeSubs.delete(url);
},
};
const route = (e) => {
const m = e.data;
if (!m || m.url !== url) return;
if (m.kind === 'open') {
target.readyState = 1; // OPEN
if (target.onopen) {
try { target.onopen({ target }); }
catch (err) { console.error('openStream onopen threw', err); }
}
} else if (m.kind === 'message') {
if (target.onmessage) {
try { target.onmessage({ data: m.data, target }); }
catch (err) { console.error('openStream onmessage threw', err); }
}
} else if (m.kind === 'error') {
if (target.onerror) {
try { target.onerror({ target }); }
catch (err) { console.error('openStream onerror threw', err); }
}
}
};
_activeSubs.set(url, { target, route });
port.addEventListener('message', route);
port.postMessage({ kind: 'subscribe', url });
return target;
}
// ─── side panel ─────────────────────────────────────────────────────────
// Singleton drawer that swipes in from the right. Long content
// (file previews, approval diffs, journald logs, applied config)
// opens here via `Panel.open(title, node)` instead of expanding
// inline. Body is swapped on each open; closing just slides out so
// the content stays visible through the transition.
export const Panel = (() => {
let root = null;
let titleEl = null;
let bodyEl = null;
let drawer = null;
/** Owner key set by `openNamed` (e.g. 'inbox'). `refresh(name, )`
* is a no-op when the current owner doesn't match, so live
* updates can re-render an open view without grabbing focus
* from a closed one (or from an unrelated open view like a
* diff drill-in). Untyped calls via `open(title, content)`
* clear the owner the legacy file-preview/diff/log paths
* don't participate in named-refresh semantics. */
let owner = null;
function ensure() {
if (!root) {
root = $('side-panel');
titleEl = $('side-panel-title');
bodyEl = $('side-panel-body');
drawer = root && root.querySelector('.side-panel-drawer');
}
return root != null;
}
function open(title, content) {
if (!ensure()) return;
owner = null;
titleEl.textContent = title;
bodyEl.replaceChildren(...(content ? [content] : []));
root.classList.add('open');
root.setAttribute('aria-hidden', 'false');
}
function openNamed(name, title, content) {
open(title, content);
owner = name;
}
function refresh(name, title, content) {
if (!ensure()) return;
if (owner !== name) return;
titleEl.textContent = title;
bodyEl.replaceChildren(...(content ? [content] : []));
}
function close() {
if (!ensure()) return;
owner = null;
root.classList.remove('open');
root.setAttribute('aria-hidden', 'true');
}
// #451: drag-to-resize the drawer's width. Listens on a thin
// hit-strip glued to the drawer's left edge; mousedown captures
// pointermove + pointerup on the document so the drag continues
// even if the cursor strays outside the 6px handle band. Width
// persists to localStorage so it survives page reload. The CSS
// clamps the value (min-width: 320px, max-width: 96vw) — drop
// unparseable / out-of-range stored values silently.
const WIDTH_KEY = 'hyperhive:side-panel-width';
const WIDTH_MIN = 320;
function clampWidth(w) {
const max = Math.floor(window.innerWidth * 0.96);
return Math.max(WIDTH_MIN, Math.min(max, w));
}
function applyStoredWidth() {
if (!drawer) return;
const raw = (() => {
try { return localStorage.getItem(WIDTH_KEY); }
catch { return null; }
})();
if (!raw) return;
const parsed = parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return;
drawer.style.setProperty('--side-panel-w', clampWidth(parsed) + 'px');
}
function bindResize() {
if (!drawer) return;
const handle = document.createElement('div');
handle.className = 'side-panel-resize';
handle.setAttribute('role', 'separator');
handle.setAttribute('aria-orientation', 'vertical');
handle.setAttribute('aria-label', 'drag to resize side panel');
handle.title = 'drag to resize';
drawer.prepend(handle);
let dragging = false;
handle.addEventListener('pointerdown', (e) => {
e.preventDefault();
dragging = true;
document.body.classList.add('side-panel-resizing');
// Capture so we keep getting pointermove even when the cursor
// outpaces the handle band (drag-fast-then-pause loses the
// handle's :hover state otherwise).
try { handle.setPointerCapture(e.pointerId); } catch { /* legacy */ }
});
document.addEventListener('pointermove', (e) => {
if (!dragging) return;
// Drawer is anchored to the right edge — width = viewport - pointer X.
const w = clampWidth(window.innerWidth - e.clientX);
drawer.style.setProperty('--side-panel-w', w + 'px');
});
function stopDrag() {
if (!dragging) return;
dragging = false;
document.body.classList.remove('side-panel-resizing');
// Persist the final width. Read the actual rendered width
// rather than re-deriving so the stored value matches what
// the operator saw at mouseup.
const w = drawer.getBoundingClientRect().width;
try { localStorage.setItem(WIDTH_KEY, String(Math.round(w))); }
catch { /* localStorage unavailable — width is session-only */ }
}
document.addEventListener('pointerup', stopDrag);
document.addEventListener('pointercancel', stopDrag);
// Re-clamp on viewport resize so a persisted width that exceeds
// 96vw doesn't push the drawer off-screen after a window shrink.
window.addEventListener('resize', () => {
if (dragging) return;
const cur = drawer.getBoundingClientRect().width;
const clamped = clampWidth(cur);
if (clamped !== Math.round(cur)) {
drawer.style.setProperty('--side-panel-w', clamped + 'px');
}
});
}
function bind() {
if (!ensure()) return;
$('side-panel-close').addEventListener('click', close);
$('side-panel-backdrop').addEventListener('click', close);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && root.classList.contains('open')) close();
});
applyStoredWidth();
bindResize();
}
return { open, openNamed, refresh, close, bind };
})();
// ─── path linkification ─────────────────────────────────────────────────
// Agents constantly drop pointer strings into messages + question
// bodies (it's the 1 KiB-cap escape hatch). Anything matching the
// PATH_RE patterns becomes a clickable anchor; clicking expands an
// inline <details> with the file's contents, fetched lazily from
// /api/state-file. The legacy in-container `/state/...` prefix is
// deliberately not matched — it's ambiguous from the host's
// perspective (we'd need to know which agent the message is about
// to translate it). Prefer `/agents/<name>/state/...` in agent
// outputs and the link will resolve.
async function fetchStateFile(path) {
const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path));
const text = await resp.text();
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
return text;
}
// A 2-tab file preview: a "rendered" tab (default) + a raw-text tab.
// `renderRendered()` produces the rendered-tab node fresh on each
// switch; `plainText` backs the raw tab; `plainLabel` names it.
function buildTabbedPreview(renderRendered, plainText, plainLabel) {
const tabs = el('div', { class: 'diff-base-tabs' });
const host = el('div', { class: 'preview-host' });
function show(mode) {
for (const b of tabs.children) {
b.classList.toggle('active', b.dataset.mode === mode);
}
host.replaceChildren(mode === 'plain'
? el('pre', { class: 'path-preview-body' }, plainText)
: renderRendered());
}
for (const [mode, label] of [['rendered', 'rendered'], ['plain', plainLabel]]) {
const b = el('button',
{ type: 'button', class: 'diff-base-tab', 'data-mode': mode }, label);
b.addEventListener('click', () => show(mode));
tabs.append(b);
}
show('rendered');
return el('div', {}, tabs, host);
}
// Rendered <img> for an SVG, loaded via an <img> data: URI —
// <img>-loaded SVG runs in the browser's secure static mode (no
// scripts, no external fetches), so an untrusted SVG from an
// agent's state dir can't execute code in the dashboard.
function svgImage(text) {
const img = el('img', { class: 'img-preview', alt: 'SVG preview' });
img.addEventListener('error', () => {
img.replaceWith(el('div', { class: 'meta' },
'(could not render — see the source tab)'));
});
img.src = 'data:image/svg+xml,' + encodeURIComponent(text);
return img;
}
// Marked-rendered markdown node (raw text fallback if `marked`
// failed to load).
function mdNode(text) {
const div = el('div', { class: 'md' });
if (window.marked && typeof window.marked.parse === 'function') {
window.marked.setOptions({ breaks: true, gfm: true });
div.innerHTML = window.marked.parse(text);
// marked autolinks URLs but leaves them same-tab — open externally
// so a click never navigates away from the dashboard. (issue #233)
div.querySelectorAll('a[href]').forEach((a) => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
});
} else {
div.textContent = text;
}
return div;
}
// Raster image extensions the preview renders as an <img> pointed
// straight at /api/state-file (served binary with a real
// content-type). SVG is handled on the text path instead.
const RASTER_RE = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i;
// Lazy-load `path` from /api/state-file into the side panel.
// Markdown + SVG get a rendered/plain tabbed view; raster images
// render as an <img>; every other file stays raw text in a <pre>.
async function openFilePanel(path) {
if (RASTER_RE.test(path)) {
const img = el('img', { class: 'img-preview', alt: path });
img.addEventListener('error', () => {
img.replaceWith(el('pre', { class: 'path-preview-body' },
'(could not load image — it may be missing or over the preview size cap)'));
});
img.src = '/api/state-file?path=' + encodeURIComponent(path);
Panel.open('↳ ' + path, img);
return;
}
const isMd = /\.(md|markdown)$/i.test(path);
const isSvg = /\.svg$/i.test(path);
const view = el('div');
view.textContent = '(fetching…)';
Panel.open('↳ ' + path, view);
try {
const text = await fetchStateFile(path);
if (isSvg) {
view.replaceChildren(buildTabbedPreview(() => svgImage(text), text, 'source'));
} else if (isMd) {
view.replaceChildren(buildTabbedPreview(() => mdNode(text), text, 'plain'));
} else {
view.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
}
} catch (e) {
view.textContent = 'error: ' + (e.message || e);
}
}
export function makePathLink(path) {
const anchor = el('a', {
href: '#', class: 'path-link', title: 'open ' + path + ' in panel',
}, path);
anchor.addEventListener('click', (e) => {
e.preventDefault();
openFilePanel(path);
});
return anchor;
}
// Append a plain-text run, with bare http(s) URLs turned into clickable
// links via the shared terminal linkifier.
export function appendText(parent, s) {
if (!s) return;
parent.appendChild(termLinkify(s));
}
// Append `text` to `parent` as a mix of text nodes + path anchors.
// `refs` is the server-attached `file_refs` array (verified-file
// tokens that appear in `text`); each occurrence of a ref becomes a
// clickable anchor that opens the file in the side panel. Anything
// not in `refs` stays plain text. No client-side regex, no probe
// endpoint — the server saw the body first and made the call. When
// `refs` is empty/missing we just emit plain text.
export function appendLinkified(parent, text, refs) {
if (text == null) return;
const str = String(text);
const tokens = (refs || []).slice();
if (!tokens.length) {
appendText(parent, str);
return;
}
// Walk the string left-to-right, at each step looking for the
// next occurrence of any token. Longest-first tie-break so a
// ref like `/agents/foo/state/x.md` wins over a (hypothetical)
// shorter token that prefixes it. O(text * refs) worst case;
// refs is bounded server-side to whatever fits in a body, so
// this stays cheap.
tokens.sort((a, b) => b.length - a.length);
let i = 0;
while (i < str.length) {
let bestStart = -1;
let bestToken = null;
for (const t of tokens) {
const idx = str.indexOf(t, i);
if (idx === -1) continue;
if (bestStart === -1 || idx < bestStart || (idx === bestStart && t.length > bestToken.length)) {
bestStart = idx;
bestToken = t;
}
}
if (bestStart === -1) {
appendText(parent, str.slice(i));
break;
}
if (bestStart > i) {
appendText(parent, str.slice(i, bestStart));
}
parent.appendChild(makePathLink(bestToken));
i = bestStart + bestToken.length;
}
}
// ─── browser notifications ──────────────────────────────────────────────
// Fires OS notifications on three operator-bound signals:
// - new approval landed in the queue
// - new operator question queued (ask, target IS NULL)
// - broker message sent `to: "operator"`
// Permission grant is per-browser; a localStorage "muted" toggle lets
// the operator silence without revoking. Secure-context only (HTTPS /
// localhost) — on other origins the API is unavailable and we hide
// the controls.
export const NOTIF = (() => {
const supported = typeof Notification !== 'undefined';
const MUTED_KEY = 'hyperhive.notify.muted';
const isMuted = () => localStorage.getItem(MUTED_KEY) === '1';
const setMuted = (v) => v
? localStorage.setItem(MUTED_KEY, '1')
: localStorage.removeItem(MUTED_KEY);
function renderControls() {
const enable = $('notif-enable');
const mute = $('notif-mute');
const unmute = $('notif-unmute');
const status = $('notif-status');
if (!enable || !mute || !unmute || !status) return;
if (!supported) {
enable.hidden = mute.hidden = unmute.hidden = true;
status.hidden = false;
status.textContent = 'notifications unsupported in this browser';
return;
}
const perm = Notification.permission;
enable.hidden = perm === 'granted';
mute.hidden = perm !== 'granted' || isMuted();
unmute.hidden = perm !== 'granted' || !isMuted();
status.hidden = perm !== 'denied';
if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings';
}
function bind() {
const enable = $('notif-enable');
const mute = $('notif-mute');
const unmute = $('notif-unmute');
if (!supported || !enable || !mute || !unmute) return;
enable.addEventListener('click', async () => {
await Notification.requestPermission();
renderControls();
});
mute.addEventListener('click', () => { setMuted(true); renderControls(); });
unmute.addEventListener('click', () => { setMuted(false); renderControls(); });
renderControls();
}
function show(title, body, tag) {
if (!supported) {
console.debug('notify: Notification API not supported');
return;
}
if (Notification.permission !== 'granted') {
console.debug('notify: permission not granted', Notification.permission);
return;
}
if (isMuted()) {
console.debug('notify: muted');
return;
}
try {
// Per-event tag so distinct messages stack instead of
// collapsing into one slot. Caller passes a unique tag per
// notification kind/id; we don't fall back to 'hyperhive'
// because that one tag would replace itself on every fire.
const n = new Notification(title, {
body,
tag: tag || ('hyperhive:' + Date.now()),
});
n.onclick = () => { window.focus(); n.close(); };
console.debug('notify: shown', title, 'tag=', tag);
} catch (err) {
console.warn('notification show failed', err);
}
}
return { bind, show, renderControls };
})();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,120 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>hyperhive // FL0W</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/dashboard.css">
</head>
<body class="flow-shell">
<!-- Fixed-overlay chrome — just the tab strip (#389 follow-up:
slug moved to the dashboard's page footer; the flow page is a
full-viewport terminal with no normal-flow footer position, so
the slug simply doesn't appear here). The operator can still
switch tabs from the flow page without navigating back; FL0W is
the current page, SW4RM / Y3R C4LL / SYST3M cross-link to the
dashboard with the matching hash. -->
<header class="dashboard-chrome flow-chrome" id="flow-header">
<nav class="tabbar" id="tabbar" role="tablist">
<a class="tab" href="/#swarm" role="tab" data-tab="swarm">
<span class="tab-label">◆ SW4RM ◆</span>
<span class="tab-count" id="tab-count-swarm" hidden></span>
</a>
<a class="tab" href="/#call" role="tab" data-tab="call">
<span class="tab-label">◆ Y3R C4LL ◆</span>
<span class="tab-count tab-count-attn" id="tab-count-call" hidden></span>
</a>
<a class="tab" href="/#system" role="tab" data-tab="system">
<span class="tab-label">◆ SYST3M ◆</span>
<span class="tab-count" id="tab-count-system" hidden></span>
</a>
<a class="tab" href="/#schedules" role="tab" data-tab="schedules">
<span class="tab-label">◆ SCH3DUL3S ◆</span>
<span class="tab-count" id="tab-count-schedules" hidden></span>
</a>
<a class="tab tab-link active" id="tab-flow" href="/flow.html"
aria-current="page"
title="all-agents chat — you are here">
<span class="tab-label">◆ FL0W ◆</span>
<span class="tab-count" id="tab-count-flow" hidden></span>
</a>
<!-- Notif controls cohabit with the tabs (always-on chrome).
Same IDs as on the dashboard so the shared NOTIF binding
(from common.js, imported by both tabs.js and flow.js)
picks them up unchanged. -->
<div id="notif-row" class="notif-row">
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
<span id="notif-status" class="meta" hidden></span>
</div>
</nav>
</header>
<!-- Operator inbox flyout trigger — count + click → side panel
(singleton, declared below). Hidden until the inbox is non-
empty. Mirrors the agent page's pill pattern (#362). -->
<button type="button" id="inbox-pill" class="flow-pill" hidden
title="open operator inbox">
<span class="flow-pill-icon" aria-hidden="true">📬</span>
<span class="flow-pill-label">inbox</span>
<span class="flow-pill-count" id="inbox-pill-count">0</span>
</button>
<!-- Main content: the full-viewport terminal. Padded for the
overlay header + composer so the first/last rows stay
reachable. -->
<main class="flow-main">
<div class="terminal-wrap">
<div id="msgflow" class="live terminal"><div class="meta">connecting…</div></div>
</div>
</main>
<!-- Fixed-overlay composer at the bottom. Same frosted treatment
as the header — symmetric framing, terminal goes edge-to-edge
between them. -->
<footer class="flow-composer">
<div id="op-compose" class="op-compose">
<span id="op-compose-prompt" class="op-compose-prompt">@—&gt;</span>
<textarea id="op-compose-input" class="op-compose-input"
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
rows="1" autocomplete="off"></textarea>
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
</div>
</footer>
<!-- Inbox rendered offscreen — kept in the DOM so flow.js's
renderInbox keeps working unchanged. The pill click handler
opens the side panel which displays a clone of the list. The
legacy section heading would otherwise be visible; hidden
here. -->
<div id="inbox-section" class="flow-inbox-headless" hidden>
<p class="meta">loading…</p>
</div>
<!-- Slide-in side panel. Singleton — JS swaps the title + body
and toggles `.open`. On this page only used to surface the
operator inbox flyout; the dashboard's other panel uses
(approval diffs, file previews, logs) don't apply here. -->
<div id="side-panel" class="side-panel" aria-hidden="true">
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
aria-labelledby="side-panel-title">
<header class="side-panel-head">
<span class="side-panel-title" id="side-panel-title"></span>
<button type="button" class="side-panel-close" id="side-panel-close"
title="close (esc)">✕</button>
</header>
<div class="side-panel-body" id="side-panel-body"></div>
</aside>
</div>
<!-- Flow-specific bundle (#406 step 2). Contains the broker
terminal init, the operator-inbox derived store, the inbox
pill flyout, and the @-mention composer. Tab renderers etc.
live in `/static/tabs.js` which /flow.html doesn't load. -->
<script type="module" src="/static/flow.js" defer></script>
</body>
</html>

View file

@ -0,0 +1,440 @@
// /flow.html entry point (#406 step 2 — flow-specific split from the
// previous combined entry; #406 step 3 renamed that combined entry
// from `app.js` to `tabs.js`).
//
// Owns the full-page broker terminal, the operator-inbox derived store
// (populated from the broker stream), the inbox pill flyout, and the
// @-mention compose box. Pulls shared infrastructure (DOM helpers, side
// panel, OS notifications, path linkification) from `./common.js`.
//
// Does NOT contain the dashboard's tab renderers, mutation-event
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
// /index.html. The flow page runs purely on the broker stream + an
// initial /api/state fetch (compose autocomplete needs the live
// container list).
import { create as termCreate } from '@hive/shared/terminal.js';
import {
$, el,
Panel, NOTIF,
appendLinkified,
openStream,
} from './common.js';
(() => {
Panel.bind();
NOTIF.bind();
// ─── operator inbox (derived from the broker message stream) ───────────
// No longer shipped on `/api/state.operator_inbox`. The broker
// terminal feeds this via `onAnyEvent` — backfill from
// `/dashboard/history` populates on load, live SSE keeps it current.
// Newest-first to match the previous behaviour.
const INBOX_LIMIT = 50;
const operatorInbox = [];
function inboxAppendFromEvent(ev) {
if (ev.kind !== 'sent' || ev.to !== 'operator') return false;
operatorInbox.unshift({
from: ev.from,
body: ev.body,
at: ev.at,
file_refs: ev.file_refs || [],
});
if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT;
return true;
}
function buildInboxListNode() {
if (!operatorInbox.length) return el('p', { class: 'empty' }, 'no messages');
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const ul = el('ul', { class: 'inbox' });
for (const m of operatorInbox) {
const li = el('li');
const body = el('span', { class: 'msg-body' });
appendLinkified(body, m.body, m.file_refs);
li.append(
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
el('span', { class: 'msg-from' }, m.from), ' ',
el('span', { class: 'msg-sep' }, '→ '),
body,
);
ul.append(li);
}
return ul;
}
function renderInbox() {
// Flow page surfaces inbox as a pill that opens the side-panel
// flyout. Pill is hidden when empty; click handler below opens
// the panel with the freshest list. If the panel is already
// showing the inbox view, refresh its body in place so live
// messages land without a re-open.
const pill = $('inbox-pill');
const pillCount = $('inbox-pill-count');
if (pillCount) pillCount.textContent = String(operatorInbox.length);
if (pill) pill.hidden = operatorInbox.length === 0;
Panel.refresh('inbox', 'inbox · ' + operatorInbox.length, buildInboxListNode());
}
// Wire the inbox pill to open the side-panel flyout with the
// operator inbox.
const inboxPill = $('inbox-pill');
if (inboxPill) {
inboxPill.addEventListener('click', () => {
Panel.openNamed('inbox', 'inbox · ' + operatorInbox.length,
buildInboxListNode());
});
}
// ─── local containers cache (for compose autocomplete) ──────────────────
// The compose box's @-mention completion suggests known agent names.
// /index.html (tabs.js) maintains the canonical `containersState`
// from /api/state + SSE; here we keep a small local mirror updated
// by the same `container_state_changed` / `container_removed` events
// the dashboard would handle.
const flowContainers = new Map();
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
if (!s || !Array.isArray(s.containers)) return;
for (const c of s.containers) flowContainers.set(c.name, c);
}).catch(() => { /* graceful: compose just shows `*` and nothing else */ });
// ─── message flow: shared terminal pane ────────────────────────────────
// Scroll, pill, backfill + SSE plumbing live in @hive/shared/terminal.
// What stays here is the broker-message renderer + the page-local
// side effects (banner pulse, inbox refresh on operator-bound
// traffic, OS notifications).
(() => {
const flow = $('msgflow');
if (!flow) return;
flow.innerHTML = '';
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
// Pulse the page banner whenever a broker event lands. (Note:
// post-#389 the `.banner` lives in the dashboard's <footer>, not
// in the flow page chrome — `pulseBanner` no-ops on /flow.html
// since there's no element to find. Kept for parity if a future
// chrome change reintroduces a banner.)
const banner = document.querySelector('.banner');
let bannerOffTimer = null;
function pulseBanner() {
if (!banner) return;
banner.classList.add('active');
if (bannerOffTimer) clearTimeout(bannerOffTimer);
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
}
// Map of broker row id → rendered row element. Lets reply rows add
// a visual "↳ in reply to" indicator that links back to the parent.
// Bounded by the history window (~200 msgs from /dashboard/history),
// well within normal memory.
const msgRowMap = new Map();
function renderMsg(ev, api, glyph) {
const isReply = ev.in_reply_to != null;
const cls = 'msgrow ' + ev.kind + (isReply ? ' msg-reply' : '');
const row = api.row(cls, '');
// Build via DOM so path anchors stay live + escape rules are
// automatic (text nodes don't need esc()).
const ts = document.createElement('span');
ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at);
const arrow = document.createElement('span');
arrow.className = 'msg-arrow'; arrow.textContent = glyph;
const from = document.createElement('span');
from.className = 'msg-from'; from.textContent = ev.from;
const sep = document.createElement('span');
sep.className = 'msg-sep'; sep.textContent = '→';
const to = document.createElement('span');
to.className = 'msg-to'; to.textContent = ev.to;
const body = document.createElement('span');
body.className = 'msg-body';
appendLinkified(body, ev.body, ev.file_refs);
// Reply thread indicator: a small "↳ reply to <from>" hint that
// shows which message this is responding to. If we have the parent
// in our row map, clicking scrolls it into view.
if (isReply) {
const replyTag = document.createElement('span');
replyTag.className = 'msg-reply-tag';
const parentRow = msgRowMap.get(ev.in_reply_to);
if (parentRow) {
const link = document.createElement('a');
link.href = '#';
link.textContent = '↳ reply';
link.title = 'scroll to parent message';
link.addEventListener('click', (e) => {
e.preventDefault();
parentRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
parentRow.classList.add('msg-highlight');
setTimeout(() => parentRow.classList.remove('msg-highlight'), 1500);
});
replyTag.append(link);
} else {
replyTag.textContent = '↳ reply';
}
row.prepend(replyTag);
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
} else {
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
}
// Register this row so future replies can reference it.
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
}
// Anchor the `↓ N new` pill in `.flow-main` (NOT the default
// `log.parentElement` = `.terminal-wrap`). `.terminal-wrap`
// applies `backdrop-filter`, which creates a CSS stacking
// context — the pill's z-index would otherwise be trapped
// inside and clipped under the fixed composer (issue #375).
// `.flow-main` has no backdrop-filter / stacking-context
// creators, so the pill's z-index reaches the root and floats
// above the composer.
const flowMain = document.querySelector('.flow-main');
termCreate({
logEl: flow,
pillAnchor: flowMain,
historyUrl: '/dashboard/history',
// #408: server-side filter — only the kinds this page actually
// renders or routes (sent/delivered → broker terminal,
// container_state_changed/_removed → local autocomplete cache).
// Backend (#499) pre-parses the allow-list at subscribe time so
// the per-frame hot path is one HashSet::contains and the
// JSON-serialise is skipped entirely on irrelevant kinds. The
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
// since it routes every mutation kind into its derived stores.
streamUrl: '/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
// #448: route through the SharedWorker so this page's SSE shares
// a single backend connection with /index.html (and any other
// open hyperhive tab). Worker keys on the full URL (incl.
// query string), so the filtered subscribe is its own upstream
// — won't accidentally share with tabs.js's wider subscribe.
streamFactory: openStream,
renderers: {
sent: (ev, api) => renderMsg(ev, api, '→'),
delivered: (ev, api) => renderMsg(ev, api, '✓'),
// Maintain the local containers cache from the same stream
// (compose autocomplete reads from `flowContainers`). The
// dashboard's tab renderers aren't on this page, so we don't
// need to dispatch to applyContainerStateChanged etc. — just
// keep the autocomplete list current.
container_state_changed: (ev) => {
if (ev.container && ev.container.name) {
flowContainers.set(ev.container.name, ev.container);
}
},
container_removed: (ev) => { flowContainers.delete(ev.name); },
// Drop every other mutation kind silently — without this
// they'd fall through to the terminal module's default
// renderer and clutter the log with JSON dumps. The dashboard
// tabs handle these on /index.html via tabs.js.
_default: () => {},
},
// Both history backfill and live frames flow through here, so the
// inbox section ends up populated correctly on first paint and
// updated thereafter — no /api/state refetch needed for inbox
// freshness.
onAnyEvent: (ev /* , { fromHistory } */) => {
if (inboxAppendFromEvent(ev)) renderInbox();
},
// Re-sync the local containers cache on every SSE (re)connect.
// Live mutation events that fired during a disconnect window
// are never replayed, so without this the compose autocomplete
// could drift stale (issue #163). We don't try to recover
// missed broker rows here — operator inbox briefly stales on
// reconnect; HiveTerminal's history-replay covers the next
// page load.
onStreamOpen: () => {
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
if (!s || !Array.isArray(s.containers)) return;
flowContainers.clear();
for (const c of s.containers) flowContainers.set(c.name, c);
}).catch(() => {});
},
onLiveEvent: (ev) => {
pulseBanner();
if (ev.kind === 'sent' && ev.to === 'operator') {
NOTIF.show(
'◆ ' + ev.from + ' → operator',
String(ev.body || '').slice(0, 200),
// Unique-per-arrival tag so a burst stacks instead of
// overwriting itself in the OS notification center.
'hyperhive:msg:' + ev.at + ':' + Math.random().toString(36).slice(2, 6),
);
}
},
});
})();
// ─── compose: @-mention with sticky recipient ───────────────────────────
(() => {
const input = $('op-compose-input');
const prompt = $('op-compose-prompt');
const suggest = $('op-compose-suggest');
if (!input || !prompt || !suggest) return;
const STORAGE_KEY = 'hyperhive:op-compose:to';
let stickyTo = localStorage.getItem(STORAGE_KEY) || '';
let suggestActive = -1;
function renderPrompt() {
prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>';
}
function knownAgents() {
// Read live from the flow-local containers cache so newly-spawned
// agents become addressable without a manual reload.
// Broker uses the literal recipient `manager` for the manager's
// inbox, not the container name `hm1nd`.
const names = Array.from(flowContainers.values())
.map((c) => (c.is_manager ? 'manager' : c.name));
// `*` fans out to every registered agent (server-side
// broadcast_send).
names.unshift('*');
return names;
}
function autosize() {
input.style.height = 'auto';
input.style.height = `${input.scrollHeight}px`;
}
/// Parse "@name body…" — return {to, body} when the input opens
/// with a known @-mention, otherwise null.
function parseAddressed(raw) {
const m = raw.match(/^@([\w*-]+)\s+([\s\S]+)$/);
if (!m) return null;
return { to: m[1], body: m[2] };
}
function hideSuggest() {
suggest.hidden = true;
suggest.innerHTML = '';
suggestActive = -1;
}
function renderSuggest(matches) {
suggest.innerHTML = '';
if (!matches.length) { hideSuggest(); return; }
for (let i = 0; i < matches.length; i += 1) {
const item = document.createElement('div');
item.className = 'item' + (i === suggestActive ? ' active' : '');
item.textContent = '@' + matches[i];
item.addEventListener('mousedown', (e) => {
e.preventDefault();
applySuggestion(matches[i]);
});
suggest.append(item);
}
suggest.hidden = false;
}
function applySuggestion(name) {
// Replace the partial @-token at the start with the full name.
const v = input.value;
const m = v.match(/^@(\S*)/);
if (m) {
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, '');
} else {
input.value = `@${name} ` + v;
}
hideSuggest();
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
autosize();
}
function updateSuggest() {
const v = input.value;
// Only suggest when an @-token sits at the very start of the
// input — switching recipient is always "redirect this whole
// line." Mid-message @-mentions stay literal.
const m = v.match(/^@(\S*)/);
if (!m) { hideSuggest(); return; }
const partial = m[1].toLowerCase();
const matches = knownAgents().filter((n) => n.toLowerCase().startsWith(partial));
if (!matches.length) { hideSuggest(); return; }
if (suggestActive < 0 || suggestActive >= matches.length) suggestActive = 0;
renderSuggest(matches);
}
async function submit() {
const raw = input.value.trim();
if (!raw) return;
let to;
let body;
const addressed = parseAddressed(raw);
if (addressed) {
to = addressed.to;
body = addressed.body.trim();
} else if (stickyTo) {
to = stickyTo;
body = raw;
} else {
flashError('no recipient — start with @name to address a message');
return;
}
if (!body) return;
const fd = new FormData();
fd.append('to', to);
fd.append('body', body);
input.disabled = true;
try {
// /op-send returns 200. The SSE channel carries the resulting
// MessageEvent → the terminal renders the sent row + the
// inbox updates on its own; no /api/state refetch needed.
const resp = await fetch('/op-send', {
method: 'POST',
body: new URLSearchParams(fd),
});
if (!resp.ok) {
flashError(`send failed: http ${resp.status}`);
return;
}
} catch (err) {
flashError(`send failed: ${err}`);
return;
} finally {
input.disabled = false;
}
stickyTo = to;
localStorage.setItem(STORAGE_KEY, to);
input.value = '';
autosize();
renderPrompt();
input.focus();
}
function flashError(msg) {
const flow = $('msgflow');
if (!flow) return;
const row = document.createElement('div');
row.className = 'msgrow meta';
row.textContent = msg;
flow.insertBefore(row, flow.firstChild);
}
input.addEventListener('input', () => { autosize(); updateSuggest(); });
input.addEventListener('keydown', (e) => {
if (!suggest.hidden) {
if (e.key === 'ArrowDown') {
const items = suggest.querySelectorAll('.item');
suggestActive = (suggestActive + 1) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === 'ArrowUp') {
const items = suggest.querySelectorAll('.item');
suggestActive = (suggestActive - 1 + items.length) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
const active = suggest.querySelector('.item.active');
if (active) {
applySuggestion(active.textContent.slice(1));
e.preventDefault();
return;
}
}
if (e.key === 'Escape') {
hideSuggest();
e.preventDefault();
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
});
input.addEventListener('blur', () => {
// Defer so a click on a suggestion item (mousedown) lands first.
setTimeout(hideSuggest, 100);
});
renderPrompt();
autosize();
})();
})();

View file

@ -0,0 +1,226 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>hyperhive // h1ve-c0re</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/dashboard.css">
</head>
<body class="dashboard-shell">
<!-- Sticky chrome — just the tab strip now (#389 follow-up: the
"WE ARE THE WIRED" slug moved out of chrome entirely and lives
at the page footer below `<main>`; chrome is navigation only).
Tabs route via the URL hash so F5 / back-button / shared links
keep you on the same view. JS owns the actual show/hide; this
is just the menu. -->
<header class="dashboard-chrome">
<nav class="tabbar" id="tabbar" role="tablist">
<a class="tab" id="tab-swarm" href="#swarm" role="tab"
aria-controls="tab-pane-swarm"
data-tab="swarm">
<span class="tab-label">◆ SW4RM ◆</span>
<span class="tab-count" id="tab-count-swarm" hidden></span>
</a>
<a class="tab" id="tab-call" href="#call" role="tab"
aria-controls="tab-pane-call"
data-tab="call">
<span class="tab-label">◆ Y3R C4LL ◆</span>
<span class="tab-count tab-count-attn" id="tab-count-call" hidden></span>
</a>
<a class="tab" id="tab-system" href="#system" role="tab"
aria-controls="tab-pane-system"
data-tab="system">
<span class="tab-label">◆ SYST3M ◆</span>
<span class="tab-count" id="tab-count-system" hidden></span>
</a>
<!-- SCH3DUL3S (#459): scheduled-prompts surface. List of
queued schedules + an operator-direct creation form.
Count pill mirrors the active (non-cancelled) schedule
count; hidden when zero. -->
<a class="tab" id="tab-schedules" href="#schedules" role="tab"
aria-controls="tab-pane-schedules"
data-tab="schedules">
<span class="tab-label">◆ SCH3DUL3S ◆</span>
<span class="tab-count" id="tab-count-schedules" hidden></span>
</a>
<!-- FL0W is its own page (`/flow.html`), not a tab — per
operator @ #369#issuecomment-3437 ("yes terminal can be a
separate page"). The link lives in the tab strip so it
reads as a peer surface; clicking navigates rather than
swapping panes in place. Count pill mirrors the dashboard's
operator-inbox length and is hidden when zero. -->
<a class="tab tab-link" id="tab-flow" href="/flow.html"
title="open the all-agents chat in a dedicated full-page terminal">
<span class="tab-label">◆ FL0W ◆ →</span>
<span class="tab-count" id="tab-count-flow" hidden></span>
</a>
<!-- Notification controls live in the chrome (always-on
ergonomics; not tab-specific). -->
<div id="notif-row" class="notif-row">
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
<span id="notif-status" class="meta" hidden></span>
</div>
</nav>
</header>
<!-- Tab panes. Exactly one is `.tab-pane-active` at a time;
JS toggles based on the URL hash + `hashchange` events. -->
<main class="dashboard-main">
<!-- SW4RM: the swarm itself. Container cards (the central thing
the operator looks at) and rebuild queue / cascade visualisation
that drives them. The tab label itself reads SW4RM, so the
inline C0NTAINERS h2 heading + divider would be redundant —
dropped per #385. -->
<section class="tab-pane" id="tab-pane-swarm"
role="tabpanel" aria-labelledby="tab-swarm">
<div id="containers-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- Y3R C4LL: things blocked on operator decision. Approvals +
questions read as the same concept ("something is waiting on
you"); both surface their full bodies inline so the operator
can decide without leaving the pane. -->
<section class="tab-pane" id="tab-pane-call"
role="tabpanel" aria-labelledby="tab-call">
<h2>◆ P3NDING APPR0VALS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="approvals-section">
<p class="meta">loading…</p>
</div>
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="questions-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- SYST3M: passive / rare-interaction state. Meta inputs (lock
bumps), rebuild queue (watch only), kept state from previous
tombstoned agents. Queued reminders moved to the SCH3DUL3S
tab in #460 — they're conceptually "fire X at time Y" too,
just self-scheduled by agents instead of operator-set.
Headings stay; the per-section content auto-compresses to a
one-line summary when empty (separate JS toggle). -->
<section class="tab-pane" id="tab-pane-system"
role="tabpanel" aria-labelledby="tab-system">
<h2>◆ M3T4 1NPUTS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual <code>rebuilt</code> system event.</p>
<div id="meta-inputs-section">
<p class="meta">loading…</p>
</div>
<h2>◆ R3BU1LD QU3U3 ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.</p>
<div id="rebuild-queue-section">
<p class="meta">loading…</p>
</div>
<h2>◆ K3PT ST4T3 ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="tombstones-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- SCH3DUL3S (#459): scheduled prompts. operator-direct creation
form at the top (POST /api/schedules, no approval gate), live
schedules list below (GET /api/schedules) with per-target
last-fired timestamps + result, plus per-target / whole-row
cancel buttons (POST /api/schedules/{id}/cancel). #444 backend
doesn't emit a SchedulesChanged dashboard event yet, so the
list re-fetches on tab activation + after each form/cancel
submit. Live SSE wiring is the future PR C. -->
<section class="tab-pane" id="tab-pane-schedules"
role="tabpanel" aria-labelledby="tab-schedules">
<h2>◆ N3W SCH3DUL3 ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">queue a prompt to fire at a future time. operator-direct (no approval gate); recurring when an interval is set. targets are any known agent name or <code>operator</code> / <code>manager</code>.</p>
<div id="schedule-new-section">
<p class="meta">loading…</p>
</div>
<h2>◆ QU3U3D SCH3DUL3S ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">all schedules currently in the table. expand each card to see per-target firing history. cancel a single target with the row button or the whole schedule with <code>✕ cancel all</code>.</p>
<div id="schedules-section">
<p class="meta">loading…</p>
</div>
<!-- QU3U3D R3M1ND3RS (#460): self-scheduled agent reminders.
Moved here from the SYST3M tab so the operator has one
place for everything that fires at a future time —
operator-set schedules above, agent-self reminders here.
Backed by GET /api/reminders; refresh handled by
refreshReminders() (called from refreshState). -->
<h2>◆ QU3U3D R3M1ND3RS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.</p>
<div id="reminders-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- FL0W: lives on its own page now (`/flow.html`). The
message-flow + inbox + compose DOM only exists there — when
tabs.js boots on this page the corresponding renderers
no-op silently (each guard is `if (!el) return`). -->
</main>
<footer>
<pre class="banner banner-thin">░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p>▲△▲ <a href="https://git.berlin.ccc.de/vinzenz/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
</footer>
<!-- Slide-in detail panel. Long content (clicked file previews,
approval diffs, journald logs, applied config) opens here
instead of expanding inline. Singleton — JS swaps the title +
body and toggles `.open`. Lives outside the tab panes so it
overlays any active tab. -->
<div id="side-panel" class="side-panel" aria-hidden="true">
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
aria-labelledby="side-panel-title">
<header class="side-panel-head">
<span class="side-panel-title" id="side-panel-title"></span>
<button type="button" class="side-panel-close" id="side-panel-close"
title="close (esc)">✕</button>
</header>
<div class="side-panel-body" id="side-panel-body"></div>
</aside>
</div>
<!-- Selection action bar (#443). Sticky-bottom strip that slides
into view when one or more agent cards is selected (click the
icon to toggle). Shows the selection count + actions that
apply to ALL selected; disabled-with-tooltip for actions that
don't (mara picked option B). Hidden when selection is empty. -->
<div id="selection-bar" class="selection-bar" hidden role="toolbar"
aria-label="bulk agent actions">
<span class="selection-count" id="selection-count"></span>
<span class="selection-names" id="selection-names"></span>
<span class="selection-actions" id="selection-actions"></span>
<button type="button" class="btn selection-clear" id="selection-clear"
title="clear selection (esc)">✕ clear</button>
</div>
<!-- Single bundled entry (#406 step 3 — renamed from app.js to
tabs.js since this bundle is the dashboard *tabs* surface only;
flow.html has its own flow.js bundle). esbuild folds
@hive/shared/terminal.js and the marked npm package into
tabs.js; load order is preserved by the module bundler. -->
<script type="module" src="/static/tabs.js" defer></script>
</body>
</html>

View file

@ -0,0 +1,110 @@
// SharedWorker that holds ONE EventSource per stream URL and fans
// every server-sent event out to every connected tab via MessagePort.
//
// Problem this solves (#448 — mara: "firefox disconnects bc of too
// many tabs"): every dashboard / agent tab opens its own
// `EventSource('/dashboard/stream')`. Browsers cap concurrent
// connections per host (~6), and Firefox throttles / disconnects
// background tabs when many are open. The result: tabs silently
// drop the SSE, fall behind, and only catch up on focus.
//
// Centralising the connection in a SharedWorker means N tabs share
// ONE backend EventSource regardless of focus state — way under the
// per-host cap, immune to per-tab throttling, and the worker survives
// any individual tab being suspended.
//
// Wire protocol (port.postMessage payloads):
//
// tab → worker
// { kind: 'subscribe', url: '/dashboard/stream' }
// { kind: 'unsubscribe', url: '/dashboard/stream' }
//
// worker → tab
// { kind: 'open', url: '...' } relayed from EventSource.onopen,
// plus a synthetic open fired to
// a brand-new subscriber when the
// upstream is already OPEN — so
// the page's onStreamOpen still
// runs and triggers a snapshot
// re-sync after a reconnect gap.
// { kind: 'message', url: '...', data: '<raw SSE data string>' }
// { kind: 'error', url: '...' } relayed from EventSource.onerror.
//
// Subscriptions are tracked per (port, url): a single port can
// subscribe to multiple URLs (today only one is in use but the shape
// stays open for the per-agent /events/stream multiplexing follow-up).
// Unsubscribing the last port for a URL closes the EventSource so we
// don't keep idle streams open.
const streams = new Map();
function getOrCreateStream(url) {
let entry = streams.get(url);
if (entry) return entry;
const es = new EventSource(url);
entry = { es, url, ports: new Set() };
es.onopen = () => {
for (const port of entry.ports) {
try { port.postMessage({ kind: 'open', url }); }
catch { /* port dead — cleanup happens on unsubscribe / next subscribe */ }
}
};
es.onmessage = (e) => {
for (const port of entry.ports) {
try { port.postMessage({ kind: 'message', url, data: e.data }); }
catch { /* same */ }
}
};
es.onerror = () => {
for (const port of entry.ports) {
try { port.postMessage({ kind: 'error', url }); }
catch { /* same */ }
}
};
streams.set(url, entry);
return entry;
}
function unsubscribe(port, url) {
const entry = streams.get(url);
if (!entry) return;
entry.ports.delete(port);
if (entry.ports.size === 0) {
entry.es.close();
streams.delete(url);
}
}
self.onconnect = (connectEvent) => {
const port = connectEvent.ports[0];
const subscribedUrls = new Set();
port.onmessage = (e) => {
const msg = e.data;
if (!msg || typeof msg.url !== 'string') return;
if (msg.kind === 'subscribe') {
if (subscribedUrls.has(msg.url)) return; // idempotent
const entry = getOrCreateStream(msg.url);
entry.ports.add(port);
subscribedUrls.add(msg.url);
// Synthetic open for late subscribers — the upstream EventSource
// may already be OPEN when this tab joins, in which case the
// native onopen has long since fired and won't fire again until
// the next reconnect. Hand the new tab the open event explicitly
// so its onStreamOpen handler runs.
if (entry.es.readyState === EventSource.OPEN) {
try { port.postMessage({ kind: 'open', url: msg.url }); }
catch { /* port dead immediately — give up */ }
}
} else if (msg.kind === 'unsubscribe') {
if (!subscribedUrls.has(msg.url)) return;
unsubscribe(port, msg.url);
subscribedUrls.delete(msg.url);
}
};
// A port has no explicit "disconnect" event in the SharedWorker API
// — tabs close, the GC eventually reclaims the port, but postMessage
// to a dead port throws which the senders above catch. We don't
// proactively prune ports on a timer because the cost is bounded
// (one dead Set entry per stale tab) and the next subscribe / catch
// catches it.
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
{
"name": "@hive/shared",
"version": "0.0.0",
"private": true,
"description": "Shared frontend modules used by both the dashboard and the per-agent UI: terminal log pane, Catppuccin palette, base typography. Imported by sibling workspaces; not bundled standalone.",
"type": "module",
"main": "./src/index.js",
"exports": {
".": "./src/index.js",
"./terminal.js": "./src/terminal.js",
"./base.css": "./src/base.css",
"./terminal.css": "./src/terminal.css"
},
"files": [
"src/"
]
}

View file

@ -0,0 +1,24 @@
/* Base palette + typography shared by the hive-c0re dashboard and the
hive-ag3nt web UI. Catppuccin Mocha. Per-page stylesheets append on
top of this and must NOT redeclare the colour variables the whole
point of pulling them out is one source of truth. */
:root {
--bg: #1e1e2e; /* base */
--bg-elev: #181825; /* mantle */
--fg: #cdd6f4; /* text */
--muted: #7f849c; /* overlay1 */
--purple: #cba6f7; /* mauve */
--purple-dim: #45475a;/* surface1 */
--cyan: #89dceb; /* sky */
--pink: #f5c2e7; /* pink */
--amber: #fab387; /* peach */
--green: #a6e3a1; /* green */
--red: #f38ba8; /* red */
--border: #313244; /* surface0 */
}
body {
background: var(--bg);
color: var(--fg);
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
line-height: 1.6;
}

View file

@ -0,0 +1,3 @@
// Convenience re-export so consumers can `import { create, linkify }
// from '@hive/shared'` without naming the sub-module path.
export { create, linkify } from './terminal.js';

View file

@ -0,0 +1,228 @@
/* Shared terminal pane: a scroll-sticky log of rows + a "↓ N new" pill.
Pages wrap their stream container in `.terminal-wrap` and give the log
itself the `.live` class; renderer JS appends `.row` (flat line) or
`details.row` (collapsible body) elements. Row-kind classes
(`.turn-start`, `.tool-use`, `.thinking`, etc.) carry the per-event
colour; pages that don't emit a given kind simply never produce that
class the unused rule sits in the bundle harmlessly.
`.terminal-wrap` provides the crust-on-black phosphor chrome that makes
the agent page feel like a terminal. Pages can opt in by wrapping a
block in this class; or skip it and the rows still render with their
class colours, just without the frame.
No `.term-input` here composers are a separate concern (see
hive-fr0nt::COMPOSER_CSS / COMPOSER_JS once introduced). */
.terminal-wrap {
position: relative;
background: rgba(17, 17, 27, 0.78);
-webkit-backdrop-filter: blur(8px) saturate(120%);
backdrop-filter: blur(8px) saturate(120%);
border: 1px solid var(--purple-dim);
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7);
border-radius: 4px;
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
font-size: 0.92em;
color: var(--fg);
margin-top: 0.6em;
}
.live {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
overflow-y: auto;
max-height: 32em;
font-family: inherit;
}
.live.terminal {
background: transparent;
border: 0;
box-shadow: none;
border-radius: 0;
padding: 0.8em 1em 0.4em;
overflow-y: auto;
height: min(72vh, 60em);
max-height: none;
font-family: inherit;
font-size: inherit;
color: inherit;
}
.live .row,
.live details.row {
animation: row-fade-in 220ms ease-out both;
}
.live .row.no-anim,
.live details.row.no-anim {
animation: none;
}
@keyframes row-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Unified prefix column for every row kind. The glyph (` · !`)
is the first character of the row's text content; `padding-left` reserves
the column and `text-indent: -1.4em` pulls the glyph back into it. Wrapped
continuation lines then start under the body, not under the glyph, so
wraps don't blur into the next row. `details.row` summaries reuse the
same metrics below. */
.live .row {
white-space: pre-wrap;
word-break: break-word;
padding: 0.05em 0;
line-height: 1.45;
border-left: 2px solid transparent;
padding-left: 1.9em;
text-indent: -1.4em;
margin: 0.1em 0;
}
.live .row + .row { border-top: 0; }
/* Row-kind colours. Pages register renderers that emit these classes;
any class no page emits is just dead CSS, which is fine. Turn-framing
classes carry their signal entirely on the coloured border-left rule
no bold, no top/bottom margins, no background tint. The chrome was
overweight for what's just a "this is a boundary" marker. */
.live .turn-start { color: var(--amber); border-left-color: var(--amber); }
/* turn-body is a child block under turn-start carrying the wake-prompt
body; reset text-indent so wrapped content stays under its own column
instead of pulling back into the parent's prefix. */
.live .turn-body { color: var(--fg); text-indent: 0; margin-top: 0.15em; }
/* Any child block (markdown body, nested details) resets the parent
row's hanging indent so the content lays out from column 0 of the
body area. */
.live .row .md, .live .row > details { text-indent: 0; }
.live .turn-end-ok { color: var(--green); border-left-color: var(--green); }
.live .turn-end-fail { color: var(--red); border-left-color: var(--red); }
.live .text { color: var(--fg); }
.live .thinking { color: var(--muted); font-style: italic; }
.live .tool-use { color: var(--cyan); }
.live .tool-result { color: var(--muted); }
.live .result { color: var(--green); }
.live .note { color: var(--muted); }
/* Distinguish stderr lines (orange) and operator-initiated notes
(mauve, lightly emphasised) from ambient harness chatter so the
eye picks out anomalies + operator actions in the scrollback. */
.live .note.stderr { color: var(--amber); }
.live .note.op { color: var(--purple); font-style: italic; }
/* The .sys catch-all fires when renderStream landed an event shape it
couldn't classify. Make it visually loud so silently-dropped event
types surface for follow-up. */
.live .sys { color: var(--amber); }
.live .unread-badge {
color: var(--amber);
font-weight: normal;
margin-left: 0.6em;
font-size: 0.85em;
text-shadow: 0 0 6px rgba(250, 179, 135, 0.55);
animation: badge-pulse 1.4s ease-in-out infinite;
}
@keyframes badge-pulse {
0%, 100% { opacity: 1; text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); }
50% { opacity: 0.7; text-shadow: 0 0 14px rgba(250, 179, 135, 0.95); }
}
/* "↓ N new" pill: shown when new rows arrive while the operator is
scrolled up; click to jump to bottom. Positioned by the wrapper's
`position: relative` (terminal-wrap supplies it; pages that skip the
wrapper must add their own positioned ancestor). */
.tail-pill {
position: absolute;
right: 1em;
bottom: 4.2em;
background: var(--amber);
color: #11111b;
font-family: inherit;
font-size: 0.8em;
font-weight: bold;
letter-spacing: 0.08em;
border: 0;
border-radius: 999px;
padding: 0.35em 0.9em;
cursor: pointer;
box-shadow: 0 0 14px -2px rgba(250, 179, 135, 0.85);
opacity: 0;
transform: translateY(6px);
pointer-events: none;
transition: opacity 160ms ease, transform 160ms ease;
}
.tail-pill.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.tail-pill:hover { filter: brightness(1.1); }
/* Expandable rows reuse the flat-row prefix metrics (padding-left +
negative text-indent) so the disclosure glyph (` / `) lands in
exactly the same column as flat-row prefix glyphs (` · `).
Summary text omits the per-row directional glyph (the row colour
already carries cyan = outbound tool, muted = inbound result) so
the prefix column doesn't have to fit two glyphs side-by-side. */
details.row {
white-space: normal;
}
details.row > summary {
cursor: pointer;
list-style: none;
white-space: pre-wrap;
word-break: break-word;
}
details.row > summary::before {
content: '▸ ';
color: inherit;
}
details.row[open] > summary::before { content: '▾ '; }
details.row > pre.diff-body,
details.row > pre.tool-body {
margin: 0.3em 0 0.4em 0;
padding: 0.4em 0.6em;
text-indent: 0;
background: rgba(255, 255, 255, 0.02);
border-left: 2px solid var(--purple-dim);
white-space: pre-wrap;
word-break: break-word;
max-height: 22em;
overflow-y: auto;
}
details.row > pre.tool-body { color: var(--fg); }
details.row > pre.diff-body .diff-add { color: var(--green); }
details.row > pre.diff-body .diff-del { color: var(--red); }
details.row > pre.diff-body .diff-ctx { color: var(--fg); }
/* Markdown body inside a row (assistant text, send/recv/ask/answer
message bodies). Inline elements get muted accents; block elements
reset the parent row's hanging indent so content lays out cleanly. */
.live .row .md p { margin: 0.2em 0; }
.live .row .md p:first-child { margin-top: 0; }
.live .row .md p:last-child { margin-bottom: 0; }
.live .row .md code {
background: rgba(255, 255, 255, 0.06);
padding: 0.05em 0.3em;
border-radius: 3px;
font-size: 0.95em;
}
.live .row .md pre {
margin: 0.3em 0;
padding: 0.4em 0.6em;
background: rgba(255, 255, 255, 0.04);
border-left: 2px solid var(--purple-dim);
text-indent: 0;
white-space: pre-wrap;
word-break: break-word;
}
.live .row .md pre code {
background: transparent;
padding: 0;
border-radius: 0;
}
.live .row .md a { color: var(--cyan); text-decoration: underline; }
/* Auto-linkified bare URLs in plain rows + tool-body blocks (issue #233). */
.live .row a { color: var(--cyan); text-decoration: underline; }
.live .row a:hover { color: var(--fg); }
.live .row .md strong { color: inherit; font-weight: bold; }
.live .row .md em { color: inherit; font-style: italic; }
.live .row .md ul, .live .row .md ol { margin: 0.2em 0 0.2em 1.4em; padding: 0; }
.live .row .md li { margin: 0.05em 0; }
.live .row .md blockquote {
margin: 0.2em 0;
padding-left: 0.6em;
border-left: 2px solid var(--purple-dim);
color: var(--muted);
}

View file

@ -0,0 +1,471 @@
// Shared terminal pane: sticky-bottom log + "↓ N new" pill + history
// backfill + live SSE. Pages provide a kind→renderer map; this module
// owns scroll behaviour, animation suppression on backfill, and the
// EventSource lifecycle.
//
// Usage:
//
// import { create, linkify } from '@hive/shared/terminal.js';
//
// create({
// logEl: document.getElementById('msgflow'),
// historyUrl: '/messages/history?limit=200', // optional
// streamUrl: '/messages/stream',
// renderers: {
// sent: (ev, api) => api.row('msgrow sent', ...),
// delivered: (ev, api) => api.row('msgrow delivered', ...),
// _default: (ev, api) => api.row('note', JSON.stringify(ev)),
// },
// onLiveEvent: (ev) => { /* live-only side effects (notif, state pokes) */ },
// onAnyEvent: (ev, { fromHistory }) => { /* runs for every event in
// both backfill replay and live — use for derived views that need
// the full picture (e.g. a per-recipient inbox built from broker
// events) */ },
// onBackfillDone: (count) => { /* one-shot after history replay */ },
// onStreamOpen: () => { /* fires on every EventSource (re)connect —
// use to re-sync snapshot-derived state after a reconnect gap */ },
// pillAnchor: document.getElementById('msgflow').parentElement,
// });
//
// Renderers receive (ev, api) where api exposes:
//
// api.row(cls, text) → appends a flat <div class="row cls">
// api.details(cls, summary, body) → appends <details class="row cls">
// with a <pre.tool-body>
// api.detailsDiff(cls, summary, body) → ditto but body is line-coloured by
// leading "+ " / "- " prefix
// api.placeholder(text) → replaces log content with a single
// muted "(placeholder)" row, cleared
// on the next real row
// api.fromHistory → true while backfill is replaying
//
// Each kind is dispatched to `renderers[ev.kind]`; unknown kinds fall
// through to `renderers._default` (which itself defaults to a JSON-dump
// note row). The convention is that the SSE/history endpoints emit
// objects with a `kind` field.
//
// Backfill is best-effort: if `historyUrl` is unset or the fetch fails,
// we skip straight to SSE. The optional `onBackfillDone(count)` hook
// fires after replay finishes (or after a failed/skipped fetch with
// count=0); pages use it to set state flags from the replayed history.
const NEAR_BOTTOM_PX = 48;
// Snap-to-bottom animation duration (#400 + mara feedback). Browser
// default `scrollTo({ behavior: 'smooth' })` runs ~500ms, which read
// as "still smooth, but visibly slow." 140ms with ease-out is fast
// enough to feel snap-y, slow enough that the row's destination
// reads as motion (not a jump). Distances under SCROLL_SNAP_PX
// short-circuit to instant — animating a 12px nudge is just jitter.
const SCROLL_ANIM_MS = 140;
const SCROLL_SNAP_PX = 24;
export function create(opts) {
const log = opts.logEl;
if (!log) throw new Error('HiveTerminal.create: logEl is required');
const renderers = opts.renderers || {};
const defaultRender = renderers._default
|| ((ev, api) => api.row('note', JSON.stringify(ev)));
const pillAnchor = opts.pillAnchor || log.parentElement || log;
let placeholderEl = null;
let pill = null;
let unseen = 0;
let currentNoAnim = false;
// Sticky-bottom intent. True means "keep snapping to bottom on
// any mutation"; false means "the operator scrolled up — leave
// them alone". Updated synchronously from the scroll event
// handler so both programmatic scrollTop assignments and
// operator-driven wheel/drag stay in sync.
let stickToBottom = true;
// Guards scroll-event-handler from misreading the position while
// our own animation is mid-flight (#400). The animation drives
// scrollTop with rAF, which fires a stream of scroll events as
// the position eases toward the target — the position passes
// through "not near bottom" before settling. Without this gate,
// the scroll handler flips `stickToBottom` to false mid-animation,
// which then causes the MutationObserver to skip the next snap
// and leaves the operator stranded mid-scroll. Set to the
// animation's nominal end + small headroom; each fresh snap
// re-arms it so back-to-back snaps stay gated.
let smoothScrollingUntil = 0;
// rAF id for the current snap animation. Cancelled when a new
// snap starts so we never have two animations fighting over
// scrollTop.
let scrollAnimRaf = 0;
function isNearBottom() {
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
}
// Snap the log to the bottom with a brief eased animation
// (#400 + mara: snappier than the browser's default 500ms smooth
// scroll). Each call cancels the previous frame loop and starts a
// fresh one, so a burst of mutations coalesces into one ride to
// the latest bottom. Re-evaluates the target each frame so a
// renderer mutation landing mid-animation extends the destination
// without a visible jump. Falls back to instant scroll when
// `currentNoAnim` is true (backfill replay — operator never sees
// intermediate positions, animation is wasted frames) or when the
// remaining distance is under SCROLL_SNAP_PX.
function snapToBottom(immediate) {
stickToBottom = true;
if (scrollAnimRaf) {
cancelAnimationFrame(scrollAnimRaf);
scrollAnimRaf = 0;
}
const target = log.scrollHeight - log.clientHeight;
const start = log.scrollTop;
const distance = target - start;
if (immediate || currentNoAnim || distance <= SCROLL_SNAP_PX) {
smoothScrollingUntil = 0;
log.scrollTop = target;
return;
}
smoothScrollingUntil = Date.now() + SCROLL_ANIM_MS + 80;
const t0 = performance.now();
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
const step = (now) => {
const elapsed = now - t0;
const frac = Math.min(1, elapsed / SCROLL_ANIM_MS);
// Re-read target each frame so mutations landing mid-animation
// (the common case — a renderer appended badge / body bits
// after api.row returned) extend the destination smoothly
// rather than landing short.
const currentTarget = log.scrollHeight - log.clientHeight;
log.scrollTop = start + (currentTarget - start) * easeOut(frac);
if (frac < 1) {
scrollAnimRaf = requestAnimationFrame(step);
} else {
// Final exact settle on the as-of-now bottom.
log.scrollTop = log.scrollHeight - log.clientHeight;
scrollAnimRaf = 0;
}
};
scrollAnimRaf = requestAnimationFrame(step);
}
function ensurePill() {
if (pill) return pill;
pill = document.createElement('button');
pill.type = 'button';
pill.className = 'tail-pill';
pill.addEventListener('click', () => snapToBottom());
pillAnchor.appendChild(pill);
return pill;
}
function updatePill() {
if (unseen <= 0) {
if (pill) pill.classList.remove('visible');
return;
}
ensurePill();
pill.textContent = '↓ ' + unseen + ' new';
pill.classList.add('visible');
}
log.addEventListener('scroll', () => {
// Mid-smooth-scroll: ignore the intermediate scroll events. The
// gate releases when the animation has had time to settle (or
// when the next snap re-arms it). Without this, easing toward
// bottom would flip `stickToBottom` false partway and the next
// MO callback would skip the snap.
if (Date.now() < smoothScrollingUntil) return;
stickToBottom = isNearBottom();
if (stickToBottom) { unseen = 0; updatePill(); }
});
// Post-append mutations (issue #393). Renderers commonly call
// `api.row(cls, text)` to create the row shell, then mutate it
// by appending more children (badges, multi-line bodies, tool
// result panes) AFTER api.row returned. The afterAppend scroll
// below only sees the row's INITIAL height — once the renderer
// adds the body, the row's grown past the visible bottom and
// the operator is left scrolled to the row's TOP, breaking
// stick-to-bottom for every subsequent event.
//
// Fix: MutationObserver on the log subtree. Fires once per
// microtask after each batch of synchronous mutations, so it
// runs once per renderer call regardless of how many children
// the renderer appends. When `stickToBottom` is true, snap to
// bottom again — catches whatever the renderer added after the
// afterAppend hop. Programmatic `scrollTop = scrollHeight`
// assignments don't re-trigger MO (the scroll itself isn't a
// DOM mutation), so no feedback loop.
const mo = new MutationObserver(() => {
if (stickToBottom) snapToBottom();
});
mo.observe(log, { childList: true, subtree: true, characterData: true });
// Auto-scroll decision uses the PRE-append scroll position
// (issue #375). Checking after the append underestimates
// "nearness" because the new row's own height has already pushed
// `scrollHeight - scrollTop - clientHeight` past the threshold,
// even when the user was visually at the bottom an instant ago.
// Each row/details/detailsDiff captures `nearBottomBeforeAppend`
// and hands it to afterAppend so the auto-scroll triggers
// whenever the operator was at the bottom when the row landed.
// (The MutationObserver above catches the AFTER-row mutations
// too, but this initial scroll keeps the visual lag to one
// frame instead of one microtask + frame.)
function afterAppend(wasNearBottom) {
if (currentNoAnim || wasNearBottom) {
snapToBottom();
} else {
unseen += 1;
updatePill();
}
}
function clearPlaceholder() {
if (placeholderEl && placeholderEl.parentElement === log) {
log.removeChild(placeholderEl);
}
placeholderEl = null;
}
function placeholder(text) {
clearPlaceholder();
const e = document.createElement('div');
e.className = 'row note';
e.textContent = text;
log.appendChild(e);
placeholderEl = e;
}
function row(cls, text) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const e = document.createElement('div');
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
e.appendChild(linkify(text));
log.appendChild(e);
afterAppend(wasNearBottom);
return e;
}
function details(cls, summary, body) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const d = document.createElement('details');
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
const s = document.createElement('summary');
s.textContent = summary;
d.appendChild(s);
const pre = document.createElement('pre');
pre.className = 'tool-body';
pre.appendChild(linkify(body));
d.appendChild(pre);
log.appendChild(d);
afterAppend(wasNearBottom);
return d;
}
function detailsDiff(cls, summary, body) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const d = document.createElement('details');
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
const s = document.createElement('summary');
s.textContent = summary;
d.appendChild(s);
const pre = document.createElement('pre');
pre.className = 'tool-body diff-body';
for (const line of String(body).split('\n')) {
const span = document.createElement('span');
if (line.startsWith('+ ')) span.className = 'diff-add';
else if (line.startsWith('- ')) span.className = 'diff-del';
else span.className = 'diff-ctx';
span.textContent = line + '\n';
pre.appendChild(span);
}
d.appendChild(pre);
log.appendChild(d);
afterAppend(wasNearBottom);
return d;
}
function api(extra) {
return Object.assign({
row, details, detailsDiff, placeholder, linkify,
fromHistory: false,
}, extra || {});
}
function dispatch(ev, fromHistory) {
const r = renderers[ev.kind] || defaultRender;
try {
r(ev, api({ fromHistory }));
} catch (err) {
console.error('terminal renderer threw', ev, err);
row('note', '[render err] ' + (err && err.message ? err.message : err));
}
if (opts.onAnyEvent) {
try { opts.onAnyEvent(ev, { fromHistory }); }
catch (err) { console.error('onAnyEvent threw', err); }
}
}
// Subscribe → buffer → fetch history → dedupe → apply.
//
// Race the SSE subscription opens before the history fetch starts.
// Live events that land before history resolves are buffered, not
// rendered. Once the history response (`{ seq, events }`) arrives we:
// 1. Replay `events` (fromHistory=true).
// 2. Drop buffered events with `seq <= history.seq` — they're
// already reflected in the history rows above.
// 3. Apply remaining buffered events (fromHistory=false).
// 4. Switch to live mode: each new SSE event dispatches immediately.
//
// Without this dance an event that fires between history-fetch and
// SSE-subscribe goes missing; without seq dedupe the same event
// shows twice (once via history, once via live buffer). Both bugs
// were latent before.
//
// If `historyUrl` is unset we skip the dance: buffered events apply
// as live the moment the buffer flushes (no dedupe possible without
// a boundary seq).
function start() {
let live = false;
let buffered = [];
// #448: callers can supply a `streamFactory(url)` that returns an
// EventSource-shaped object (must expose onmessage/onopen/onerror
// + .close()). The dashboard pages pass a SharedWorker-backed
// factory so all open hyperhive tabs share ONE upstream SSE
// connection. Default keeps the direct `new EventSource(url)`
// behaviour so non-dashboard consumers (per-agent UI) are unchanged.
const es = opts.streamFactory
? opts.streamFactory(opts.streamUrl)
: new EventSource(opts.streamUrl);
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); }
catch (err) { row('note', '[parse err] ' + e.data); return; }
if (!live) { buffered.push(ev); return; }
dispatch(ev, false);
if (opts.onLiveEvent) {
try { opts.onLiveEvent(ev); }
catch (err) { console.error('onLiveEvent threw', err); }
}
};
es.onerror = () => {
// SharedWorker-backed facades expose `readyState` mirroring the
// upstream EventSource state; the native EventSource exposes the
// same. Either way the CONNECTING vs. closed distinction works.
if (es.readyState === 0 /* CONNECTING */) row('note', '[reconnecting…]');
else row('note', '[disconnected]');
};
es.onopen = () => {
// Fires on the initial connect and on every automatic
// reconnect. EventSource never replays events that fired
// during a disconnect window, so a consumer with
// snapshot-derived state (the dashboard's /api/state stores)
// must re-sync here or it shows stale state until a manual
// reload (issue #163).
if (opts.onStreamOpen) {
try { opts.onStreamOpen(); }
catch (err) { console.error('onStreamOpen threw', err); }
}
};
function flushBuffered(boundarySeq, historyKinds) {
const drained = buffered;
buffered = [];
live = true;
for (const ev of drained) {
// Seq-dedupe only events of a kind that actually appeared in
// the history replay — those are the only ones that could
// double (once via history, once via the live buffer).
// Mutation events (approval/question/container/…) are never
// carried by the history endpoint; deduping them against the
// broker-history seq would wrongly drop ones that fired
// between a consumer's own snapshot read and this history
// fetch (issue #163). ev.seq absent/0 → no dedupe possible.
if (boundarySeq != null
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
&& historyKinds && historyKinds.has(ev.kind)) {
continue;
}
dispatch(ev, false);
if (opts.onLiveEvent) {
try { opts.onLiveEvent(ev); }
catch (err) { console.error('onLiveEvent threw', err); }
}
}
}
async function backfill() {
if (!opts.historyUrl) {
flushBuffered(null);
if (opts.onBackfillDone) opts.onBackfillDone(0);
return;
}
try {
const resp = await fetch(opts.historyUrl);
if (!resp.ok) {
flushBuffered(null);
if (opts.onBackfillDone) opts.onBackfillDone(0);
return;
}
const body = await resp.json();
// Accept the envelope `{ seq, events }`. A bare array means
// the server hasn't been updated to include seq yet — treat
// it as "no dedupe possible."
const events = Array.isArray(body) ? body : (body.events || []);
const boundarySeq = Array.isArray(body) ? null : (body.seq ?? null);
// Kinds present in the history replay — the only kinds that
// can double and therefore the only ones to seq-dedupe.
const historyKinds = new Set(events.map((ev) => ev.kind));
currentNoAnim = true;
for (const ev of events) dispatch(ev, true);
currentNoAnim = false;
if (events.length) row('note', '─── live (older above) ───');
else placeholder('(connected — waiting for events)');
flushBuffered(boundarySeq, historyKinds);
if (opts.onBackfillDone) opts.onBackfillDone(events.length);
} catch (err) {
console.warn('history backfill failed', err);
flushBuffered(null);
if (opts.onBackfillDone) opts.onBackfillDone(0);
}
}
return backfill();
}
const ready = start();
return { row, details, detailsDiff, placeholder, ready };
}
// Build a DocumentFragment from `text`, turning bare http(s) URLs into
// clickable links that open in a new tab. Non-URL text stays as plain
// text nodes — no innerHTML, so this is XSS-safe. Trailing sentence
// punctuation is kept out of the link. (issue #233)
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
export function linkify(text) {
const str = text == null ? '' : String(text);
const frag = document.createDocumentFragment();
if (str.indexOf('://') === -1) { // fast path: no URLs
if (str) frag.appendChild(document.createTextNode(str));
return frag;
}
let last = 0;
let m;
LINKIFY_URL_RE.lastIndex = 0;
while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
let url = m[0];
// Don't swallow trailing punctuation that's really sentence text.
const trail = url.match(/[.,;:!?)\]}'"]+$/);
const tail = trail ? trail[0] : '';
if (tail) url = url.slice(0, -tail.length);
if (m.index > last) {
frag.appendChild(document.createTextNode(str.slice(last, m.index)));
}
if (!url.slice(url.indexOf('://') + 3)) {
// Nothing past the scheme — not a real URL, emit verbatim.
frag.appendChild(document.createTextNode(m[0]));
} else {
const a = document.createElement('a');
a.href = url; // regex only matches https?:// — safe
a.textContent = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
frag.appendChild(a);
if (tail) frag.appendChild(document.createTextNode(tail));
}
last = m.index + m[0].length;
}
if (last < str.length) {
frag.appendChild(document.createTextNode(str.slice(last)));
}
return frag;
}

33
hive-ag3nt/Cargo.toml Normal file
View file

@ -0,0 +1,33 @@
[package]
name = "hive-ag3nt"
edition.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
axum.workspace = true
reqwest.workspace = true
futures-util = "0.3"
clap.workspace = true
hive-sh4re.workspace = true
rmcp.workspace = true
rusqlite.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
[[bin]]
name = "hive-ag3nt"
path = "src/bin/hive-ag3nt.rs"
[[bin]]
name = "hive-m1nd"
path = "src/bin/hive-m1nd.rs"

View file

@ -0,0 +1,33 @@
You are hyperhive agent `{label}` in a multi-agent system. The operator (recipient `operator` in `send`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person (e.g. when relaying to a peer or the manager).
Tools (hyperhive surface):
- `mcp__hyperhive__recv(wait_seconds?, max?)` — drain inbox messages (returns `(empty)` if nothing pending). Without `wait_seconds` (or with `0`) it returns immediately — a cheap "anything pending?" peek you can sprinkle between tool calls. To **wait** for work when you have nothing else useful to do this turn, call with a long wait (e.g. `wait_seconds: 180`, the max) — incoming messages wake you instantly, otherwise the call returns empty at the timeout. That's strictly better than a fixed `sleep` shell command: lower latency on new work, no busy-loop. `max` (default 1, cap 32) drains several queued messages in one call — the wake prompt tells you the pending count.
- `mcp__hyperhive__send(to, body, in_reply_to?)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action). Optional `in_reply_to: <message-id>` threads this message under a prior one — the dashboard and per-agent inbox render it with a `↳ reply` link. Some agents have a per-agent allow-list (`hyperhive.allowedRecipients` in their `agent.nix`) — if so the tool refuses recipients outside the list with a clear error; route through the manager (`send(to: "manager", …)`) which is always reachable.
- (some agents only) **extra MCP tools** surfaced as `mcp__<server>__<tool>` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time.
- `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the human operator (default, or `to: "operator"`) OR a peer agent (`to: "<agent-name>"`). Returns immediately with a question id — do NOT wait inline. When the recipient answers, a system message with event `question_answered { id, question, answer, answerer }` lands in your inbox; handle it on a future turn. Use this for clarifications, permission for risky actions, choice between options, or peer Q&A without burning regular inbox slots. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the answerer pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` (and `answerer: "ttl-watchdog"`) when the decision becomes moot.
- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU. You'll see one in your inbox as a `question_asked { id, asker, question, options, multi }` system event when a peer or the manager calls `ask(to: "<your-name>", ...)`. The answer surfaces in the asker's inbox as a `question_answered` event. Strict authorisation: you can only answer questions where you are the declared target.
- `mcp__hyperhive__get_loose_ends()` — list your loose ends: unanswered questions where you're asker (waiting on someone) or target (owing a reply), plus reminders you've scheduled that haven't fired. No args, cheap server-side sweep. Useful at turn start to remember what's outstanding without scanning inbox archaeology.
- `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by <you>]` answer so the waiter unblocks) or `"reminder"` (hard-deleted before it fires). `id` from the matching `get_loose_ends` row or the original submission reply. (The third kind `"approval"` exists but is manager-only — sub-agents don't submit approvals so the surface refuses.)
- `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your *own* inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Use for self-paced follow-ups instead of blocking a whole turn on a long `recv` wait. A large `message` auto-spills to a file under `/agents/{label}/state/reminders/`; pass `file_path` to point at one yourself. Each agent's pending-reminder count is capped (default 50) — the tool will error if the cap is already reached.
- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"processing matrix messages"`, `"fixing #319 model priority"`, `"idle"`). Pass an empty string to clear. Persists across harness restarts.
- `mcp__hyperhive__get_agent_meta(name?)` — fetch identity + status metadata for an agent: canonical `name`, `role` (`agent` / `manager`), current `hyperhive_rev`, plus self-reported `status` text (set via `set_status`) and how long ago it was set. Pass `name` to query a peer (e.g. check whether iris is idle before pinging them). Omit `name` to get your own trustworthy identity stamp — useful for state files, commit messages, cross-agent attribution that won't drift across renames or session-continue boundaries where the system-prompt label could be stale.
- `mcp__hyperhive__request_next_turn()` — ask the harness to start another turn immediately after this one ends, even if the inbox is empty. Use for multi-turn tasks (long builds, sequential steps) where you want to continue without waiting for an external message. The next turn starts with `from: "self"` and `body: "continue"`. No-op if new inbox messages arrive before this turn ends (the harness already loops immediately on pending messages). No args.
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config.
Your config repo is mounted **read-only** at `/agents/{label}/config/``agent.nix` plus whatever extra files the manager has split the config into. Read it to see exactly what defines you (declared packages, env vars, MCP servers) before asking the manager for a change, so you can point at the precise file and line. You cannot write here; all changes flow through the manager.
Durable knowledge: write to `/agents/{label}/state/notes.md` (free-form) or any other path under `/agents/{label}/state/`. That directory is bind-mounted from the host and persists across container destroy/recreate — claude's `--continue` session only carries short-term context, but `/agents/{label}/state/` is forever. Read it back at the start of relevant turns to remember things across resets.
Claude session (OAuth credentials) lives at `/root/.claude/` and persists across restarts.
**Shared space**: `/shared` is accessible to all agents (read/write). Only put things here you're willing to lose — other agents may delete them. Use for explicit cross-agent communication or shared artifacts when appropriate.
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`); credentials for the `tea` CLI are pre-configured at boot. Use `tea repos create`, `tea pulls create --base main --head <branch>`, `tea pulls list`, `tea issues create`, etc. for any persistent code work — git repos that should outlive a single turn, code you want a peer or the operator to review, anything you'd otherwise jam into `/shared`. Falls back to plain `git`/`curl` if `tea` doesn't fit; the REST API is at `http://localhost:3000/api/v1/` with the same token (`Authorization: token $(cat /agents/{label}/state/forge-token)`).
The `hive-forge` CLI helper wraps common Forgejo API operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. `lint <sub>` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches`, `assignments`). Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (global flag, works before or after the verb) to target a different repo. Every verb takes `--help` for its full signature. To create a PR: `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..." | --body-file <path>] [--draft]` — prints the PR URL. To create an issue: `hive-forge issue-create --title "..." [--body "..." | --body-file <path>] [--assignee <user>]`. `--body-file -` means stdin, so a HEREDOC body works naturally: `hive-forge comment <num> --body-file - <<EOF ... EOF`. To attach a file: `hive-forge attach-issue <number> <file>` / `hive-forge attach-comment <comment-id> <file>` — both print the `browser_download_url`. Key ops: `hive-forge diff <pr>` prints the unified diff; `hive-forge subscription [--watch|--ignore|--unwatch]` manages repo watch state. Note: forge notifications are delivered via the internal message daemon.
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — go through the manager if a payload needs to reach another sub-agent.
When your inbox has a message, handle it and stop. Don't narrate intent — act.

View file

@ -0,0 +1,8 @@
{
"autoCompactEnabled": false,
"autoMemoryEnabled": false,
"effortLevel": "medium",
"permissions": {
"deny": ["WebFetch", "WebSearch", "Task", "TodoWrite"]
}
}

View file

@ -0,0 +1,104 @@
You are the hyperhive manager `{label}` in a multi-agent system. You coordinate sub-agents and relay between them and the operator. The operator (recipient `operator`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person.
Tools (hyperhive surface):
- `mcp__hyperhive__recv(wait_seconds?, max?)` — drain inbox messages. Without `wait_seconds` (or with `0`) it returns immediately — a cheap inbox peek you can drop between actions. To **wait** when you have nothing else to do, call with a long wait (e.g. `wait_seconds: 180`, the max) — you'll wake instantly on new work, otherwise return after the timeout. Use that instead of ending the turn or sleeping in a Bash command. `max` (default 1, cap 32) drains several queued messages in one call.
- `mcp__hyperhive__send(to, body)` — message an agent (by name), another peer, or the operator (`operator` surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action).
- `mcp__hyperhive__request_init_config(name, description?)`**step 1 of spawning a new agent.** Queues an `InitConfig` approval (≤9 char name). On operator approve, hive-c0re seeds the proposed config repo at `/agents/<name>/config/` with a default `agent.nix` template and delivers a `config_ready` system event to your inbox. You then review, edit, and commit `agent.nix` before calling `request_apply_commit`.
- `mcp__hyperhive__request_apply_commit(agent, commit_ref, description?)`**step 2 of spawning a new agent, and the only step for config changes.** Submit a commit sha from the agent's proposed config repo for operator approval. For a new agent this creates the container; for an existing agent it rebuilds with the new config. At submit time hive-c0re pins the commit as `proposal/<id>` — your proposed branch can continue moving freely without affecting what the operator will build.
- `mcp__hyperhive__kill(name)` — graceful stop on a sub-agent. No approval required.
- `mcp__hyperhive__start(name)` — start a stopped sub-agent. No approval required.
- `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required.
- `mcp__hyperhive__update(name)` — rebuild a sub-agent (re-applies the current hyperhive flake + agent.nix, restarts the container). No approval required — idempotent. Use when you receive a `needs_update` system event.
- `mcp__hyperhive__request_update_meta_inputs(inputs?, description?)` — queue an approval for the operator to run `nix flake update [inputs...]` on the meta flake. Pass specific input names (e.g. `["bitburner-agent"]`) or omit / pass `[]` for all inputs. Returns immediately; lock update runs on operator approval. Does NOT trigger rebuilds — call `update(name)` on affected agents after approval resolves.
- `mcp__hyperhive__request_schedule_prompt(targets, body, first_fire_at_unix, interval_seconds?, description?)` — queue an approval for the operator to add a scheduled prompt. On approve hive-c0re inserts a schedule row and the worker fans `body` out to each agent in `targets` at `first_fire_at_unix` (recurring every `interval_seconds` if set, one-shot when absent). Even self-targeted schedules go through approval — the existing `remind` tool stays the quick no-approval self-wake path. Catch-up clamp: long downtime fires ONCE per recurring row on resume (skipped count surfaces in per-target `last_result`), not N stacked pulses.
- `mcp__hyperhive__cancel_schedule(id, targets?)` — cancel a schedule. Omit `targets` / pass empty to cancel the whole schedule; pass a list to cancel just those recipients (the schedule keeps firing for any remaining active targets, auto-cancels when every target is gone). Authorization: you can cancel schedules you own OR any owned by a sub-agent in your subtree per topology.json.
- `mcp__hyperhive__fire_schedule_now(id)` — fire a scheduled prompt out of band. Runs the per-target fan-out once immediately. Recurring schedules keep their cadence intact (the manual fire is additive); one-shot schedules are CONSUMED by the manual fire (cancelled afterwards). Same authorization as `cancel_schedule`.
- `mcp__hyperhive__edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)` — partial-update a schedule's mutable fields (#474). Pass only the fields you want to change. `targets_add` / `targets_remove` mutate the recipient list in the same transaction; re-adding a previously-cancelled target drops the tombstone + history (operator intent: "fresh start"). Refuses cancelled rows. Same authorization as `cancel_schedule`. Note: clearing scalar fields (e.g. flipping recurring→one-shot) is operator-only via the dashboard PATCH — the agent surface only supports positive sets on `description` / `interval_seconds`.
- `mcp__hyperhive__list_schedules()` — snapshot every schedule in the queue (active + cancelled-but-not-reaped). Returns id, owner, body, target set with per-target `last_fired_at` + `last_result`, `next_fire_at_unix`, recurring `interval_seconds`. Use to look up an id before cancelling, or to audit upcoming wake-ups across the swarm.
- `mcp__hyperhive__get_logs(agent, lines?)` — fetch recent journal lines for a sub-agent container. Use to diagnose MCP-server registration failures, startup crashes, or harness issues you can't see from inside. Pass the plain logical agent name; `lines` defaults to 50 (capped at 500).
- `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the operator (default, or `to: "operator"`) OR a sub-agent (`to: "<agent-name>"`). Returns immediately with a question id; the answer arrives later as a system `question_answered { id, question, answer, answerer }` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline (capped at 6h server-side) — on expiry the answer is `[expired]` and `answerer` is `"ttl-watchdog"`. Do not poll inside the same turn — finish the current work and react when the event lands.
- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU (a sub-agent did `ask(to: "manager", ...)`). The triggering event in your inbox is `question_asked { id, asker, question, options, multi }`. The answer surfaces in the asker's inbox as a `question_answered` event.
- `mcp__hyperhive__get_loose_ends(agent?)` — loose ends. Omit `agent` for your own: pending approvals you submitted + unanswered questions where you are asker/target + your own pending reminders. Pass `agent: "*"` for a hive-wide sweep — every pending approval, unanswered question, and reminder across the swarm — to find stalled threads (sub-agent A asked B something three days ago and B never answered) before they rot. Pass `agent: "<name>"` to inspect one agent's threads. Cheap server-side query.
- `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel any question, reminder, or approval in the swarm. `kind` is `"question"` (bypasses the owner check used on sub-agents → hive-wide cleanup when an agent is offline / can't withdraw its own thread), `"reminder"` (same bypass), or `"approval"` (manager-only path → withdraws a pending approval YOU submitted that got superseded before the operator acted on it; the row resolves as `cancelled` and disappears from the operator's pending pane).
- `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your own inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Good for deadline follow-ups — "check whether agent X answered the question I relayed". Large payloads auto-spill to a file under `/state/reminders/`; pass `file_path` to control the destination.
- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're doing (e.g. `"reviewing argus's #341 proposal"`, `"approving lifecycle changes"`, `"idle"`). Pass an empty string to clear. Persists across harness restarts.
- `mcp__hyperhive__get_agent_meta(name?)` — fetch identity + status metadata for an agent: canonical `name`, `role` (`agent` / `manager`), current `hyperhive_rev`, plus the target's self-reported `status` text (set via `set_status`) and how long ago it was set. Pass `name` to check on a sub-agent (idle? still working on the task you assigned?) without scrolling the dashboard. Omit `name` for your own identity stamp — useful for boot announcements, state-file headers, cross-agent attribution that won't drift across config reloads.
Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (two-step: `request_init_config` + `request_apply_commit`) and *changing* any agent's config (`request_apply_commit`) both go through the approval queue. The operator only signs off on changes; you run the day-to-day.
Your own editable config lives at `/agents/hm1nd/config/`; every sub-agent's lives at `/agents/<name>/config/`. `agent.nix` is a plain NixOS module function — `{ config, pkgs, lib, flakeInputs, ... }: { ... }`. Add packages, services, imports, sibling `.nix` files; the whole committed tree gets deployed together.
`flake.nix` is mostly boilerplate (it exports `agent.nix` as `nixosModules.default` and forwards every flake input to the module as `flakeInputs`). **Don't touch the outputs block** — but you *can* edit the `inputs` block to pull in other flakes, which is the supported way to depend on out-of-tree packages (MCP servers, scrapers, anything not in nixpkgs):
```nix
# flake.nix (manager-edited, inputs side only)
inputs.mcp-matrix.url = "github:foo/mcp-matrix";
inputs.mcp-matrix.inputs.nixpkgs.follows = "nixpkgs"; # optional, reduce closure
```
```nix
# agent.nix — reference the input via flakeInputs
{ pkgs, flakeInputs, ... }:
let matrixPkg = flakeInputs.mcp-matrix.packages.${pkgs.system}.default;
in {
environment.systemPackages = [ matrixPkg ];
hyperhive.extraMcpServers.matrix = {
command = "${matrixPkg}/bin/mcp-matrix";
args = [ "--config" "/agents/<name>/state/matrix.toml" ]; # replace <name> with the agent's label
allowedTools = [ "send_message" "join_room" ];
};
}
```
The new input's pinned sha lands in the agent's `flake.lock` (also tracked + part of the proposal). Build failures from a broken `flake.nix` surface as a `failed/<id>` annotated tag, so the worst case is a rejected deploy — not a silently-broken agent.
Each proposed repo has an `applied` git remote pre-configured pointing at the read-only mirror of what's deployed. Useful patterns:
- `git -C /agents/<name>/config fetch applied` — refresh the local copy of every deployed/failed/denied tag.
- `git -C /agents/<name>/config log applied/main --oneline` — every successful deploy of this agent.
- `git -C /agents/<name>/config show applied/refs/tags/deployed/<id>` — the tree that was deployed for approval `<id>`.
- `git -C /agents/<name>/config show applied/refs/tags/failed/<id>` — annotated tag body is the build error from a rejected rebuild.
- `git -C /agents/<name>/config show applied/refs/tags/denied/<id>` — annotated tag body is the operator's reason for denial.
- `git -C /agents/<name>/config rebase applied/main` — base your in-flight work on whatever's actually deployed (useful after a failed/denied pile-up).
System-wide view: `/meta/` is a read-only mirror of the deployed-agents flake. `git -C /meta log --oneline` is the deploy log for every agent across the swarm; `cat /meta/flake.lock` shows which sha each agent is pinned at right now.
Tag scheme on every approval id: `proposal → approved → building → deployed | failed`, plus `denied` as a terminal alternative to `approved`. `applied/main` only advances on `deployed/*`, so a failed build does not corrupt the agent — submit a fix as a new commit and a fresh `request_apply_commit`.
Sub-agents are NOT trusted by default. When one asks for a config change (new packages, env vars, etc.), verify the request before staging:
- Does it match what the agent actually needs to do its declared role?
- Is the package legitimate (no obviously-malicious names, no overly broad permissions)?
- Are there cheaper / safer alternatives that don't need a config edit?
- If the change has any ambiguity or could affect other agents / the host, surface the question to the operator (see below) instead of staging it yourself.
You're the policy gate between sub-agents and the operator's approval queue — the operator clicks ◆ APPR0VE on your commits, so don't submit changes you wouldn't defend.
Two ways to talk to the operator: `send(to: "operator", ...)` for fire-and-forget status / pointers (surfaces in the operator inbox), or `ask(question, options?)` when you need a decision (omit `to`, or pass `to: "operator"`). `ask` is non-blocking — it queues the question and returns an id immediately; the answer arrives on a future turn as a `question_answered` system event. Prefer `ask` over an open-ended `send` for anything you actually need to wait on. Same primitive can target a sub-agent (`to: "<agent>"`) when you need a structured answer from a peer rather than free-form chat.
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `config_ready`, `spawned`, `rebuilt`, `killed`, `destroyed`, `container_crash`, `needs_login`, `logged_in`, `needs_update`, `question_asked`, `question_answered`. Use these to react to lifecycle changes:
- `config_ready` — the proposed config repo for a new agent was just seeded (post-`InitConfig` approval). Review and edit `/agents/<agent>/config/agent.nix`, commit your changes, then call `request_apply_commit` with the commit sha — this will create the container on approval (first spawn) and rebuild on every subsequent deploy.
- `needs_login` — agent has no claude session yet. You can't help directly (login is interactive OAuth on the operator side); flag the operator if it's been long.
- `logged_in` — agent just completed login; first useful turn is imminent. Good time to brief them on what to do.
- `needs_update` — agent's flake rev is stale. Call `update(name)` to rebuild — it's idempotent and doesn't need approval.
- `container_crash` — restart with `start(name)`. If it crashes again, ask the operator.
- otherwise greet freshly-spawned agents, retry failed rebuilds, pick up the operator's answer to questions you asked.
Durable knowledge:
- Your own: `/state/notes.md` (free-form) or anything else under `/state/`. Bind-mounted from the host — survives destroy/recreate. Claude's `--continue` session only carries short-term context; `/state/` is forever. Good place for a roster of active sub-agents, ongoing initiatives, decisions you've made.
- Sub-agents': every sub-agent has its own `/state/` too. From your container that's `/agents/<name>/state/` (your `/agents` mount is RW), so you can read what they've recorded and write notes for them when you need to leave a heads-up or task list.
Keep messages short — a few sentences each. For anything big (digests, agent rosters, plans, transcripts) write the payload to a file and `send` a short pointer:
- To a sub-agent X: write to `/agents/X/state/<descriptive-name>` and tell them "see /agents/X/state/<descriptive-name>".
- To the operator: write to your own `/state/<descriptive-name>` (host path `/var/lib/hyperhive/agents/hm1nd/state/`) and tell them where to look.
- For shared artifacts (coordination, common reference data): write to `/shared/<descriptive-name>`. Only put things here you're willing to lose — other agents may delete them.
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/state/forge-token` exists. You have your own user (`hm1nd`) and so does every sub-agent (one per name). The `tea` CLI is pre-configured at boot. Use it for code work that should survive a turn — a proposed refactor across sub-agents, scratch repos, PRs you want a sub-agent or the operator to review (`tea pulls create --base main --head <branch>`, `tea pulls list`, `tea issues create`). REST API at `http://localhost:3000/api/v1/` with `Authorization: token $(cat /state/forge-token)` for anything `tea` can't express. The `hive-forge` CLI helper wraps common operations: `view`, `issue`, `issue-create`, `issue-edit`, `pr`, `pr-create`, `comment`, `comments`, `comment-show`, `comment-edit`, `assign`, `close`, `labels`, `lint`, `milestone`, `pr-reviews`, `branches`, `tree-sha`, `diff`, `subscription`, `attach-issue`, `attach-comment`. `lint <sub>` runs triage queries (`unassigned`, `no-reviewer --reviewer NAME`, `stale-branches [--days N]`, `assignments [--user NAME]`) — handy for sweeping the backlog without ad-hoc curl + jq. Default repo from `HIVE_FORGE_REPO`; `-r <repo>` (global flag, works before or after the verb) targets a different repo. Each verb takes `--help`. Use `hive-forge pr-create --title "..." --head <branch> [--base main] [--body "..." | --body-file <path>] [--draft]` to open a PR; `hive-forge issue-create --title "..." [--body "..." | --body-file <path>] [--assignee <user>]` to file an issue. `--body-file -` reads stdin, so a HEREDOC body works: `hive-forge comment <n> --body-file - <<EOF ... EOF`. `diff <pr>` prints the unified diff; `subscription [--watch|--ignore|--unwatch]` manages watch state. Forge notifications arrive via the internal message daemon.
A one-line headline + the file path beats a wall-of-text every time — it survives context compaction and the operator can read it in their own time.
When your inbox has a message, handle it and stop. Don't narrate intent — act.

View file

@ -0,0 +1,463 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use hive_ag3nt::web_ui::TurnLock;
use anyhow::Result;
use clap::{Parser, Subcommand};
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
use hive_ag3nt::login::{self, LoginState};
use hive_ag3nt::turn_stats::TurnStats;
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui};
use hive_sh4re::{AgentRequest, AgentResponse};
#[derive(Parser)]
#[command(name = "hive-ag3nt", about = "hyperhive sub-agent harness")]
struct Cli {
/// Path to the per-agent MCP socket (bind-mounted from the host).
#[arg(long, global = true, default_value = DEFAULT_SOCKET)]
socket: PathBuf,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Run the long-lived harness loop. Polls inbox; replies via `claude --print`
/// when available, falling back to a simple echo otherwise.
Serve {
/// Inbox poll interval in milliseconds.
#[arg(long, default_value_t = 1000)]
poll_ms: u64,
},
/// Run the agent's MCP server on stdio. Spawned by `claude` via
/// `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back into
/// the hyperhive broker.
Mcp,
/// Inject a wake-up event into this agent's inbox so the next turn
/// fires with the given body. Intended for extra MCP servers /
/// helpers running inside the container (matrix bridge, scraper,
/// webhook listener) that need to nudge claude on external events.
/// `from` is the sender label that appears in the wake prompt
/// (claude sees "from: matrix" etc.).
Wake {
#[arg(long)]
from: String,
/// Body of the wake message. Pass `-` to read from stdin.
#[arg(long)]
body: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
match cli.cmd {
Cmd::Serve { poll_ms } => {
let port = std::env::var("HIVE_PORT")
.ok()
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(DEFAULT_WEB_PORT);
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive-ag3nt".into());
let claude_dir = login::default_dir();
let initial = LoginState::from_dir(&claude_dir);
tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "harness boot");
let login_state = Arc::new(Mutex::new(initial));
let bus = Bus::new();
let stats = TurnStats::open_default();
if let Some(s) = &stats {
let (ctx, cost) = s.last_usage();
if ctx.is_some() || cost.is_some() {
bus.seed_usage(ctx, cost);
}
}
let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Agent).await?;
let turn_lock: TurnLock = Arc::new(tokio::sync::Mutex::new(()));
plugins::install_configured(&cli.socket, Some("manager")).await;
tokio::spawn(hive_ag3nt::forge_notify::run(cli.socket.clone(), false));
tokio::spawn(web_ui::serve(
label.clone(),
port,
login_state.clone(),
bus.clone(),
cli.socket.clone(),
files.clone(),
turn_lock.clone(),
));
match initial {
LoginState::Online => {
serve(
&cli.socket,
Duration::from_millis(poll_ms),
login_state,
claude_dir,
bus,
stats,
&files,
turn_lock,
&label,
)
.await
}
LoginState::NeedsLogin => {
// Partial-run mode: keep the harness alive (so the web UI
// stays bound) but don't drive the turn loop. Poll the
// claude dir; once a session lands we enter `serve`.
turn::wait_for_login(&claude_dir, login_state.clone(), &bus, poll_ms).await;
serve(
&cli.socket,
Duration::from_millis(poll_ms),
login_state,
claude_dir,
bus,
stats,
&files,
turn_lock,
&label,
)
.await
}
}
}
Cmd::Mcp => mcp::serve_agent_stdio(cli.socket).await,
Cmd::Wake { from, body } => {
// Read body from stdin if caller passed `-`. Same convention
// many CLI tools use; keeps multi-line / shell-quoting
// friction out of the body content.
let body = if body == "-" {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
buf
} else {
body
};
let resp: AgentResponse =
client::request(&cli.socket, &AgentRequest::Wake { from, body }).await?;
match resp {
AgentResponse::Ok => Ok(()),
AgentResponse::Err { message } => anyhow::bail!("wake: {message}"),
other => anyhow::bail!("wake: unexpected response {other:?}"),
}
}
}
}
#[allow(clippy::too_many_arguments)]
async fn serve(
socket: &Path,
interval: Duration,
login_state: Arc<Mutex<LoginState>>,
claude_dir: std::path::PathBuf,
bus: Bus,
stats: Option<TurnStats>,
files: &turn::TurnFiles,
turn_lock: TurnLock,
label: &str,
) -> Result<()> {
tracing::info!(socket = %socket.display(), "hive-ag3nt serve");
requeue_inflight(socket).await;
loop {
let recv: Result<AgentResponse> =
// Explicit long-poll: park until a message arrives (180s cap).
// `max: None` (= 1) — one turn per wake; claude calls
// recv(max: N) in-turn to drain bursts.
client::request(
socket,
&AgentRequest::Recv {
wait_seconds: Some(180),
max: None,
},
)
.await;
match recv {
Ok(AgentResponse::Messages { messages }) if !messages.is_empty() => {
let first = messages.into_iter().next().expect("checked non-empty");
let auth_failed =
handle_agent_turn(socket, &bus, stats.as_ref(), files, &turn_lock, label, first)
.await;
if auth_failed {
// Park: flip LoginState + wait for the operator's
// re-auth to repopulate claude_dir. wait_for_login
// emits `online` on resume, which clears the
// needs_login sentinel.
*login_state.lock().unwrap() = LoginState::NeedsLogin;
turn::wait_for_login(
&claude_dir,
login_state.clone(),
&bus,
u64::try_from(interval.as_millis()).unwrap_or(2000),
)
.await;
}
}
Ok(AgentResponse::Messages { .. }) => {
// Idle: empty list = nothing pending. Brief sleep
// before next poll so a stretch of empty long-poll
// returns doesn't tight-loop.
tokio::time::sleep(interval).await;
}
Ok(
AgentResponse::Ok
| AgentResponse::Status { .. }
| AgentResponse::Recent { .. }
| AgentResponse::QuestionQueued { .. }
| AgentResponse::LooseEnds { .. }
| AgentResponse::PendingRemindersCount { .. }
| AgentResponse::ReminderRollup { .. }
| AgentResponse::AgentMeta { .. },
) => {
tracing::warn!("recv produced unexpected response kind");
}
Ok(AgentResponse::Err { message }) => {
tracing::warn!(%message, "recv error");
}
Err(e) => {
tracing::warn!(error = ?e, "recv failed; retrying");
}
}
}
}
/// Drive one turn for a received agent-inbox message. Returns `true`
/// when the turn ended with `AuthFailed` so the caller knows to park
/// in `wait_for_login`.
async fn handle_agent_turn(
socket: &Path,
bus: &Bus,
stats: Option<&TurnStats>,
files: &turn::TurnFiles,
turn_lock: &TurnLock,
label: &str,
first: hive_sh4re::DeliveredMessage,
) -> bool {
let from = first.from;
let body = first.body;
let redelivered = first.redelivered;
tracing::info!(%from, %body, %redelivered, "inbox");
let unread = inbox_unread(socket).await;
bus.emit(LiveEvent::TurnStart { from: from.clone(), body: body.clone(), unread });
bus.set_state(TurnState::Thinking);
let started_at = serve_common::now_unix();
let started_instant = std::time::Instant::now();
let model_at_start = bus.model();
let prompt = serve_common::format_wake_prompt(&from, &body, unread, redelivered);
let outcome = {
let _guard = turn_lock.lock().await;
turn::drive_turn(&prompt, files, bus).await
};
turn::emit_turn_end(bus, &outcome);
bus.set_state(TurnState::Idle);
// Ack only on a clean turn-end. `Failed` leaves every message popped
// during the turn in the unacked list; next harness boot requeues them.
if matches!(outcome, turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted) {
ack_turn(socket).await;
}
if matches!(outcome, turn::TurnOutcome::RateLimited) {
let secs = turn::rate_limit_sleep_secs();
bus.emit_status("rate_limited");
bus.emit(LiveEvent::Note {
text: format!("API rate-limited — sleeping {secs}s before retry"),
});
tracing::warn!(sleep_secs = secs, "rate-limited; parking");
tokio::time::sleep(Duration::from_secs(secs)).await;
requeue_inflight(socket).await;
bus.emit_status("online");
}
// 401: flip into needs_login + requeue the message that triggered
// the turn so it survives the re-auth. The serve loop's outer
// login-state watcher parks until the operator's `/login` flow
// completes; once it does, the requeued message replays the turn
// (closes #419).
if matches!(outcome, turn::TurnOutcome::AuthFailed) {
bus.emit_status("needs_login_idle");
bus.emit(LiveEvent::Note {
text: "API 401 — waiting for re-login via web UI".into(),
});
tracing::warn!("auth-failed; parking until re-login");
requeue_inflight(socket).await;
}
// Real crash: PromptTooLong is absorbed by compaction inside drive_turn.
if let turn::TurnOutcome::Failed(e) = &outcome {
notify_manager_of_failure(socket, label, e).await;
}
if let Some(stats) = stats {
let ended_at = serve_common::now_unix();
let duration_ms =
i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
let (open_threads, open_reminders) = fetch_agent_post_turn_counts(socket).await;
let row = serve_common::build_row(
started_at,
ended_at,
duration_ms,
model_at_start,
from.clone(),
&outcome,
bus,
open_threads,
open_reminders,
);
stats.record(&row);
}
let pending = inbox_unread(socket).await;
if pending > 0 {
tracing::info!(%pending, "pending messages after turn; fetching next");
}
// `request_next_turn` MCP tool: agent wrote a sentinel requesting
// an immediate self-continuation. Clear and inject synthetic wake.
check_and_inject_continue(socket, label).await;
matches!(outcome, turn::TurnOutcome::AuthFailed)
}
// Per-turn user prompt: the role/tools/etc. is in the system prompt
// (`prompts/agent.md` → `claude --system-prompt-file`); this is just the
// wake signal claude reacts to. `unread` is the count of *other*
// messages in the inbox right after this one was popped.
// `redelivered` flags messages that were popped in a prior harness
// session, never acked, and resurfaced after a restart — a banner
// at the top of the wake prompt warns that any side-effects of
// previous handling may already have happened.
/// Best-effort: tell the broker every message we popped during the
/// turn is now fully handled (turn-end-OK). Swallows transport
/// errors — the worst case is a redundant requeue on next boot.
async fn ack_turn(socket: &Path) {
match client::request::<_, AgentResponse>(socket, &AgentRequest::AckTurn).await {
Ok(AgentResponse::Ok) => {}
Ok(AgentResponse::Err { message }) => {
tracing::warn!(%message, "ack_turn rejected by broker");
}
Ok(other) => {
tracing::warn!(?other, "ack_turn unexpected response");
}
Err(e) => tracing::warn!(error = ?e, "ack_turn transport error"),
}
}
/// Boot-time recovery: ask the broker to resurface anything we
/// popped in a previous harness session but never acked. The broker
/// resets `delivered_at = NULL` on those rows and remembers their
/// ids so the next `Recv` carries `redelivered: true`. Swallows
/// transport errors — they degrade to "no recovery this boot",
/// which is no worse than the pre-feature behaviour (silent drop).
async fn requeue_inflight(socket: &Path) {
match client::request::<_, AgentResponse>(socket, &AgentRequest::RequeueInflight).await {
Ok(AgentResponse::Ok) => {}
Ok(AgentResponse::Err { message }) => {
tracing::warn!(%message, "requeue_inflight rejected by broker");
}
Ok(other) => {
tracing::warn!(?other, "requeue_inflight unexpected response");
}
Err(e) => tracing::warn!(error = ?e, "requeue_inflight transport error"),
}
}
/// Best-effort: tell the manager that this agent's last turn crashed
/// (claude exited non-zero, compaction didn't help, etc.). Routed
/// through the normal send path so the manager's inbox surfaces it
/// as a system-style event; `label` is included explicitly in the
/// body so the manager can identify the failing agent without having
/// to look at the `from` field (which is broker-stamped and may
/// differ from what the operator sees in the dashboard). Swallows
/// transport errors — we just logged the failure, the worst case is
/// the manager learns about the crash from the dashboard instead of
/// inbox.
async fn notify_manager_of_failure(socket: &Path, label: &str, err: &anyhow::Error) {
let body = format!("[system] agent `{label}` claude turn failed:\n{err:#}");
let res = client::request::<_, AgentResponse>(
socket,
&AgentRequest::Send {
to: "manager".into(),
body,
in_reply_to: None,
},
)
.await;
if let Err(e) = res {
tracing::warn!(error = ?e, "failed to notify manager of turn failure");
}
}
/// Best-effort: ask our own per-agent socket how many messages are still
/// pending after the wake-up Recv. Returns 0 if anything goes wrong.
async fn inbox_unread(socket: &Path) -> u64 {
match client::request::<_, AgentResponse>(socket, &AgentRequest::Status).await {
Ok(AgentResponse::Status { unread }) => unread,
_ => 0,
}
}
/// Best-effort: ask hive-c0re for this agent's open thread count + pending
/// reminder count, after the turn finishes. Either roundtrip can fail
/// (transport hiccup, race with hive-c0re restart) — in those cases we
/// just drop a `None` into the stats row rather than blocking the loop.
async fn fetch_agent_post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) {
let threads = match client::request::<_, AgentResponse>(
socket,
&AgentRequest::GetLooseEnds,
)
.await
{
Ok(AgentResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(),
_ => None,
};
let reminders = match client::request::<_, AgentResponse>(
socket,
&AgentRequest::CountPendingReminders,
)
.await
{
Ok(AgentResponse::PendingRemindersCount { count }) => Some(count),
_ => None,
};
(threads, reminders)
}
/// Check for the `request_next_turn` sentinel file. If present, remove it
/// and inject a synthetic `from: "self", body: "continue"` message so the
/// serve loop fires an immediate follow-up turn even when the inbox is empty.
/// Best-effort: any I/O error is logged and ignored (the agent just waits
/// for a real message as normal).
async fn check_and_inject_continue(socket: &Path, label: &str) {
let sentinel = hive_ag3nt::paths::state_dir().join("hyperhive-continue");
if !sentinel.exists() {
return;
}
if let Err(e) = std::fs::remove_file(&sentinel) {
tracing::warn!(error = %e, "check_and_inject_continue: remove sentinel failed");
return;
}
// Sentinel was present: inject a wake so the outer loop fires immediately.
// Route through the `Wake` request which is already wired in agent_server.
let res = client::request::<_, AgentResponse>(
socket,
&AgentRequest::Wake {
from: "self".into(),
body: "continue".into(),
},
)
.await;
match res {
Ok(AgentResponse::Ok) => {
tracing::info!(%label, "request_next_turn: injected self-continue wake");
}
Ok(AgentResponse::Err { message }) => {
tracing::warn!(%message, "check_and_inject_continue: wake rejected");
}
Err(e) => {
tracing::warn!(error = ?e, "check_and_inject_continue: wake transport error");
}
_ => {}
}
}

View file

@ -0,0 +1,349 @@
//! Manager harness. Talks to the manager socket (bind-mounted from the host
//! at `/run/hive/mcp.sock` inside the `hm1nd` container). Two surfaces:
//! `serve` (long-lived turn loop) and `mcp` (stdio MCP server claude spawns).
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use hive_ag3nt::web_ui::TurnLock;
use anyhow::Result;
use clap::{Parser, Subcommand};
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
use hive_ag3nt::login::{self, LoginState};
use hive_ag3nt::turn_stats::TurnStats;
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui};
use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER};
#[derive(Parser)]
#[command(name = "hive-m1nd", about = "hyperhive manager harness")]
struct Cli {
/// Path to the manager MCP socket (bind-mounted from the host).
#[arg(long, global = true, default_value = DEFAULT_SOCKET)]
socket: PathBuf,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Long-lived loop polling the manager inbox.
Serve {
#[arg(long, default_value_t = 1000)]
poll_ms: u64,
},
/// Run the manager MCP server on stdio. Spawned by claude via
/// `--mcp-config`; same shape as `hive-ag3nt mcp` but with the
/// manager tool surface (`request_init_config`, `request_apply_commit`,
/// `kill`, `start`, `restart`, `ask`, `answer`, `remind`, …).
Mcp,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
match cli.cmd {
Cmd::Serve { poll_ms } => {
let port = std::env::var("HIVE_PORT")
.ok()
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(DEFAULT_WEB_PORT);
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hm1nd".into());
let claude_dir = login::default_dir();
let initial = LoginState::from_dir(&claude_dir);
tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "hm1nd boot");
let login_state = Arc::new(Mutex::new(initial));
let bus = Bus::new();
let stats = TurnStats::open_default();
if let Some(s) = &stats {
let (ctx, cost) = s.last_usage();
if ctx.is_some() || cost.is_some() {
bus.seed_usage(ctx, cost);
}
}
let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Manager).await?;
let turn_lock: TurnLock = Arc::new(tokio::sync::Mutex::new(()));
plugins::install_configured(&cli.socket, None).await;
tokio::spawn(web_ui::serve(
label,
port,
login_state.clone(),
bus.clone(),
cli.socket.clone(),
files.clone(),
turn_lock.clone(),
));
tokio::spawn(hive_ag3nt::forge_notify::run(cli.socket.clone(), true));
match initial {
LoginState::Online => {
serve(
&cli.socket,
Duration::from_millis(poll_ms),
login_state,
claude_dir,
bus,
stats,
&files,
turn_lock,
)
.await
}
LoginState::NeedsLogin => {
turn::wait_for_login(&claude_dir, login_state.clone(), &bus, poll_ms).await;
serve(
&cli.socket,
Duration::from_millis(poll_ms),
login_state,
claude_dir,
bus,
stats,
&files,
turn_lock,
)
.await
}
}
}
Cmd::Mcp => mcp::serve_manager_stdio(cli.socket).await,
}
}
#[allow(clippy::too_many_arguments)]
async fn serve(
socket: &Path,
interval: Duration,
login_state: Arc<Mutex<LoginState>>,
claude_dir: std::path::PathBuf,
bus: Bus,
stats: Option<TurnStats>,
files: &turn::TurnFiles,
turn_lock: TurnLock,
) -> Result<()> {
tracing::info!(socket = %socket.display(), "hive-m1nd serve");
// Same boot-time recovery as hive-ag3nt — see that loop for the
// rationale. Manager-flavour socket so we requeue only manager
// inflight rows.
requeue_inflight(socket).await;
loop {
let recv: Result<ManagerResponse> =
// Explicit long-poll: see hive-ag3nt's serve loop for the
// rationale — recv now defaults to peek when wait_seconds
// is None. `max: None` (= 1) keeps the serve loop driving
// one turn per wake; claude calls recv(max: N) in-turn to
// drain a burst when the wake prompt mentions pending.
client::request(
socket,
&ManagerRequest::Recv {
wait_seconds: Some(180),
max: None,
},
)
.await;
match recv {
Ok(ManagerResponse::Messages { messages }) if !messages.is_empty() => {
let first = messages.into_iter().next().expect("checked non-empty");
let auth_failed =
handle_manager_turn(socket, &bus, stats.as_ref(), files, &turn_lock, first)
.await;
if auth_failed {
*login_state.lock().unwrap() = LoginState::NeedsLogin;
turn::wait_for_login(
&claude_dir,
login_state.clone(),
&bus,
u64::try_from(interval.as_millis()).unwrap_or(2000),
)
.await;
}
}
Ok(ManagerResponse::Messages { .. }) => {
// Idle: empty list = nothing pending. Brief sleep
// before the next long-poll attempt.
tokio::time::sleep(interval).await;
}
Ok(
ManagerResponse::Ok
| ManagerResponse::Status { .. }
| ManagerResponse::QuestionQueued { .. }
| ManagerResponse::Recent { .. }
| ManagerResponse::Logs { .. }
| ManagerResponse::LooseEnds { .. }
| ManagerResponse::PendingRemindersCount { .. }
| ManagerResponse::ReminderRollup { .. }
| ManagerResponse::AgentMeta { .. }
| ManagerResponse::Schedules { .. },
) => {
tracing::warn!("recv produced unexpected response kind");
}
Ok(ManagerResponse::Err { message }) => {
tracing::warn!(%message, "recv error");
}
Err(e) => {
tracing::warn!(error = ?e, "recv failed; retrying");
}
}
}
}
/// Drive one turn for a received manager-inbox message. Called from the
/// serve loop for the non-empty-messages arm to keep that loop readable.
/// Returns `true` when the turn ended with `AuthFailed` so the caller
/// can park in `wait_for_login`.
async fn handle_manager_turn(
socket: &Path,
bus: &Bus,
stats: Option<&TurnStats>,
files: &turn::TurnFiles,
turn_lock: &TurnLock,
first: hive_sh4re::DeliveredMessage,
) -> bool {
let from = first.from;
let body = first.body;
let redelivered = first.redelivered;
if from == SYSTEM_SENDER {
// Helper events (ApprovalResolved / Spawned / Rebuilt /
// Killed / Destroyed) — surface in the live view and drive a
// normal turn so the manager can react.
let parsed = serde_json::from_str::<HelperEvent>(&body).ok();
if let Some(event) = parsed {
tracing::info!(?event, "helper event");
} else {
tracing::info!(%from, %body, "system message");
}
bus.emit(LiveEvent::Note { text: format!("[system] {body}") });
}
tracing::info!(%from, %body, %redelivered, "manager inbox");
let unread = inbox_unread(socket).await;
bus.emit(LiveEvent::TurnStart { from: from.clone(), body: body.clone(), unread });
let prompt = serve_common::format_wake_prompt(&from, &body, unread, redelivered);
bus.set_state(TurnState::Thinking);
let started_at = serve_common::now_unix();
let started_instant = std::time::Instant::now();
let model_at_start = bus.model();
let outcome = {
let _guard = turn_lock.lock().await;
turn::drive_turn(&prompt, files, bus).await
};
turn::emit_turn_end(bus, &outcome);
bus.set_state(TurnState::Idle);
// Ack only on a clean turn-end; Failed / RateLimited leave the
// popped ids in-flight for the next boot's requeue.
if matches!(outcome, turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted) {
ack_turn(socket).await;
}
if matches!(outcome, turn::TurnOutcome::RateLimited) {
let secs = turn::rate_limit_sleep_secs();
bus.emit_status("rate_limited");
bus.emit(LiveEvent::Note {
text: format!("API rate-limited — sleeping {secs}s before retry"),
});
tracing::warn!(sleep_secs = secs, "rate-limited; parking");
tokio::time::sleep(Duration::from_secs(secs)).await;
requeue_inflight(socket).await;
bus.emit_status("online");
}
if matches!(outcome, turn::TurnOutcome::AuthFailed) {
bus.emit_status("needs_login_idle");
bus.emit(LiveEvent::Note {
text: "API 401 — waiting for re-login via web UI".into(),
});
tracing::warn!("auth-failed; parking until re-login");
requeue_inflight(socket).await;
}
if let Some(stats) = stats {
let ended_at = serve_common::now_unix();
let duration_ms =
i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
let (open_threads, open_reminders) = fetch_manager_post_turn_counts(socket).await;
let row = serve_common::build_row(
started_at,
ended_at,
duration_ms,
model_at_start,
from.clone(),
&outcome,
bus,
open_threads,
open_reminders,
);
stats.record(&row);
}
let pending = inbox_unread(socket).await;
if pending > 0 {
tracing::info!(%pending, "pending messages after turn; fetching next");
}
matches!(outcome, turn::TurnOutcome::AuthFailed)
}
/// Best-effort: tell the broker every message popped during the turn
/// is now handled. Mirror of `hive-ag3nt::ack_turn` on the manager
/// surface.
async fn ack_turn(socket: &Path) {
match client::request::<_, ManagerResponse>(socket, &ManagerRequest::AckTurn).await {
Ok(ManagerResponse::Ok) => {}
Ok(ManagerResponse::Err { message }) => {
tracing::warn!(%message, "ack_turn rejected by broker");
}
Ok(other) => {
tracing::warn!(?other, "ack_turn unexpected response");
}
Err(e) => tracing::warn!(error = ?e, "ack_turn transport error"),
}
}
/// Boot-time recovery: ask the broker to resurface any inflight (popped
/// but not acked) messages so the next `Recv` re-delivers them with
/// the redelivery banner. Mirror of `hive-ag3nt::requeue_inflight`.
async fn requeue_inflight(socket: &Path) {
match client::request::<_, ManagerResponse>(socket, &ManagerRequest::RequeueInflight).await {
Ok(ManagerResponse::Ok) => {}
Ok(ManagerResponse::Err { message }) => {
tracing::warn!(%message, "requeue_inflight rejected by broker");
}
Ok(other) => {
tracing::warn!(?other, "requeue_inflight unexpected response");
}
Err(e) => tracing::warn!(error = ?e, "requeue_inflight transport error"),
}
}
async fn inbox_unread(socket: &Path) -> u64 {
match client::request::<_, ManagerResponse>(socket, &ManagerRequest::Status).await {
Ok(ManagerResponse::Status { unread }) => unread,
_ => 0,
}
}
/// Manager-flavour equivalent of the agent helper. Mirror shape, just
/// uses ManagerRequest/ManagerResponse instead of the agent variants.
async fn fetch_manager_post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) {
let threads = match client::request::<_, ManagerResponse>(
socket,
&ManagerRequest::GetLooseEnds { agent: None },
)
.await
{
Ok(ManagerResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(),
_ => None,
};
let reminders = match client::request::<_, ManagerResponse>(
socket,
&ManagerRequest::CountPendingReminders { agent: None },
)
.await
{
Ok(ManagerResponse::PendingRemindersCount { count }) => Some(count),
_ => None,
};
(threads, reminders)
}

124
hive-ag3nt/src/client.rs Normal file
View file

@ -0,0 +1,124 @@
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
/// Backoff schedule between attempts. Five entries → up to 5 retries on
/// top of the initial attempt; total wall-clock cap = 2+4+8+16+30 = 60s.
/// Sized to ride out a hive-c0re restart (systemd usually has the unix
/// socket back inside ~5s) without the agent-side claude session having
/// to handle the transient itself — burning tokens on a tool-error retry
/// loop is more expensive than 60s of in-harness sleep.
const RETRY_BACKOFFS_MS: &[u64] = &[2_000, 4_000, 8_000, 16_000, 30_000];
/// Transparent retry wrapper around [`request_retried`] that throws away
/// the retry count. Use this from non-tool callers (the harness serve
/// loop, web UI, CLI subcommands) where we just want the socket-restart
/// resilience without surfacing the bookkeeping.
///
/// # Errors
///
/// Returns an error if the socket is unreachable after all retries, or if
/// serialization / deserialization of the request or response fails.
pub async fn request<Req, Resp>(socket: &Path, req: &Req) -> Result<Resp>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
request_retried(socket, req).await.map(|(resp, _)| resp)
}
/// Same wire shape as [`request`], but reports how many retries it took
/// past the initial attempt (0 = succeeded first try). MCP tool handlers
/// use this so they can append a one-line hint to the tool result when
/// retries happened — that way claude knows the prior socket flake
/// wasn't a content error and shouldn't trigger an LLM-level retry of
/// its own.
///
/// # Errors
///
/// Returns an error if all retries are exhausted, or on a fatal protocol
/// error (serialization / deserialization failure).
///
/// # Panics
///
/// Panics if `RETRY_BACKOFFS_MS.len()` does not fit in a `u32`, which
/// cannot happen with the current compile-time constant.
pub async fn request_retried<Req, Resp>(socket: &Path, req: &Req) -> Result<(Resp, u32)>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
let mut last_err: Option<anyhow::Error> = None;
let max_retries = u32::try_from(RETRY_BACKOFFS_MS.len()).unwrap();
for attempt in 0..=max_retries {
match try_once::<Req, Resp>(socket, req).await {
Ok(resp) => return Ok((resp, attempt)),
Err(RequestError::Fatal(e)) => return Err(e),
Err(RequestError::Transient(e)) => {
if attempt < max_retries {
let sleep_ms = RETRY_BACKOFFS_MS[attempt as usize];
tracing::warn!(
attempt = attempt + 1,
sleep_ms,
error = %e,
"hive socket attempt failed; retrying"
);
last_err = Some(e);
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
} else {
last_err = Some(e);
}
}
}
}
Err(last_err.unwrap_or_else(|| anyhow!("hive socket: retries exhausted")))
}
/// Transient = connect / IO error worth a retry (server restart, broken
/// pipe). Fatal = serialization / deserialization / protocol error
/// where retrying would just repeat the same failure.
enum RequestError {
Transient(anyhow::Error),
Fatal(anyhow::Error),
}
async fn try_once<Req, Resp>(socket: &Path, req: &Req) -> Result<Resp, RequestError>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
let stream = UnixStream::connect(socket)
.await
.with_context(|| format!("connect to {}", socket.display()))
.map_err(RequestError::Transient)?;
let (read, mut write) = stream.into_split();
let mut payload = serde_json::to_string(req).map_err(|e| RequestError::Fatal(e.into()))?;
payload.push('\n');
write
.write_all(payload.as_bytes())
.await
.map_err(|e| RequestError::Transient(e.into()))?;
write
.flush()
.await
.map_err(|e| RequestError::Transient(e.into()))?;
let mut reader = BufReader::new(read);
let mut line = String::new();
let read_bytes = reader
.read_line(&mut line)
.await
.map_err(|e| RequestError::Transient(e.into()))?;
if read_bytes == 0 || line.is_empty() {
return Err(RequestError::Transient(anyhow!(
"server closed connection without responding"
)));
}
serde_json::from_str(line.trim()).map_err(|e| RequestError::Fatal(e.into()))
}

769
hive-ag3nt/src/events.rs Normal file
View file

@ -0,0 +1,769 @@
//! Live event stream for the per-agent web UI. The harness emits one
//! `LiveEvent` per interesting thing that happens during a turn — wake-up
//! (the popped inbox message), every line claude prints on stdout
//! (parsed from `--output-format stream-json`), and the turn-end summary.
//! The web UI subscribes via SSE and renders rows live.
//!
//! Channel type is `tokio::sync::broadcast`. New subscribers see only
//! future events; the dashboard JS deals with the cold-start case by
//! showing "connecting…" until the first event arrives.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
const CHANNEL_CAPACITY: usize = 256;
/// Max `LiveEvent`s the `Bus` returns from `history()` and keeps in
/// sqlite. Older rows are vacuumed on a periodic sweep.
const HISTORY_CAPACITY: usize = 2000;
/// Path to the persisted event db. Overridable via `HYPERHIVE_EVENTS_DB`
/// for dev / tests; otherwise derived from the agent's state dir.
fn events_db_path() -> PathBuf {
std::env::var_os("HYPERHIVE_EVENTS_DB").map_or_else(
|| crate::paths::state_dir().join("hyperhive-events.sqlite"),
PathBuf::from,
)
}
/// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE`
/// for dev / tests; otherwise derived from the agent's state dir.
fn model_file_path() -> PathBuf {
std::env::var_os("HYPERHIVE_MODEL_FILE").map_or_else(
|| crate::paths::state_dir().join("hyperhive-model"),
PathBuf::from,
)
}
fn load_model() -> Option<String> {
let s = std::fs::read_to_string(model_file_path()).ok()?;
let name = s.trim();
if name.is_empty() {
None
} else {
Some(name.to_owned())
}
}
fn persist_model(name: &str) -> std::io::Result<()> {
let path = model_file_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(path, format!("{name}\n"))
}
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0)
}
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_ts ON events (ts);
";
/// Envelope carried over the broadcast channel: the `LiveEvent` itself
/// plus a monotonic per-process seq stamped by `Bus::emit`. SSE consumers
/// serialize this directly (seq becomes a sibling of the `kind` tag);
/// clients use seq to dedupe their buffered live traffic against the
/// snapshot/history responses (drop anything with `seq <= snapshot.seq`).
#[derive(Debug, Clone, Serialize)]
pub struct BusEvent {
pub seq: u64,
#[serde(flatten)]
pub event: LiveEvent,
}
/// One row of the agent's live stream. Serialised to JSON for SSE delivery.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LiveEvent {
/// Harness popped a wake-up message and is about to invoke claude.
/// `unread` is the count of *other* messages still in the inbox at
/// that moment — surfaced as a badge in the live panel header.
TurnStart {
from: String,
body: String,
unread: u64,
},
/// One line of claude's `--output-format stream-json` stdout, parsed as
/// a generic JSON value (so we don't have to track every claude-code
/// event variant). The frontend pretty-prints by `type` field.
Stream(serde_json::Value),
/// Free-form note from the harness (e.g. "claude exited 0",
/// "stream-json parse error: ..."). Useful when stream-json itself
/// fails so the UI doesn't just go silent.
///
/// Must be a struct variant (not `Note(String)`): internally-tagged
/// enums can't flatten a tag onto a primitive newtype, and serde
/// fails serialization at runtime — silently, because the SSE
/// handler's `filter_map(... .ok()? ...)` swallows the error. From
/// 2025-08 through 2026-05 every `Note` emission was a no-op + the
/// sqlite history persisted them as the literal string `"null"`.
/// The web UI's `note` renderer already reads `ev.text`, so the
/// wire shape matches without a JS change.
Note { text: String },
/// Turn finished. `ok=false` means claude exited non-zero or the
/// harness hit a transport error.
TurnEnd { ok: bool, note: Option<String> },
/// Harness reachability flipped: `"online"` /
/// `"needs_login_idle"` / `"needs_login_in_progress"`. The web UI
/// drives the alive badge from this so the operator sees a login
/// land (or get revoked) without polling. Session detail
/// (`url`/`output`/`finished`) is still served by `/api/state`
/// during the short-lived in-progress window — the client
/// re-fetches only while that flow is active.
StatusChanged { status: String },
/// `/api/model` switched the active claude model. The web UI
/// updates the chip + the per-turn stats sink will key off this
/// to mark the boundary in its log.
ModelChanged { model: String },
/// Token usage for the turn just ended. Carries two snapshots:
/// - `ctx` is the LAST inference's usage block (the actual context
/// window in use right now — what the operator needs to decide
/// whether to compact / reset).
/// - `cost` is the cumulative usage across every inference in the
/// turn (sum of per-call billed tokens — the cost signal). For
/// tool-heavy turns the cumulative blows past the model's window
/// because each tool call's prompt is rebilled.
TokenUsageChanged { ctx: TokenUsage, cost: TokenUsage },
/// Harness's `TurnState` transitioned (idle / thinking /
/// compacting). `since_unix` matches `Bus::state_snapshot().1`
/// so the client's elapsed-time ticker keeps progressing across
/// SSE reconnects without drift.
TurnStateChanged {
state: TurnState,
since_unix: i64,
},
}
/// sqlite-backed event log. Wraps a `Connection` behind a `Mutex` so the
/// `Bus` (which clones cheaply) shares one writer.
struct EventStore {
conn: Mutex<Connection>,
}
impl EventStore {
fn open(path: &Path) -> rusqlite::Result<Self> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let conn = Connection::open(path)?;
conn.execute_batch(SCHEMA)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
fn append(&self, event: &LiveEvent) -> rusqlite::Result<()> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
let kind = match event {
LiveEvent::TurnStart { .. } => "turn_start",
LiveEvent::Stream(_) => "stream",
LiveEvent::Note { .. } => "note",
LiveEvent::TurnEnd { .. } => "turn_end",
LiveEvent::StatusChanged { .. } => "status_changed",
LiveEvent::ModelChanged { .. } => "model_changed",
LiveEvent::TokenUsageChanged { .. } => "token_usage_changed",
LiveEvent::TurnStateChanged { .. } => "turn_state_changed",
};
let payload = serde_json::to_string(event).unwrap_or_else(|_| "null".into());
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO events (ts, kind, payload_json) VALUES (?1, ?2, ?3)",
params![ts, kind, payload],
)?;
Ok(())
}
fn recent(&self, limit: usize) -> rusqlite::Result<Vec<LiveEvent>> {
let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT payload_json FROM events
ORDER BY id DESC
LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit_i], |row| {
let s: String = row.get(0)?;
Ok(serde_json::from_str::<LiveEvent>(&s).ok())
})?;
let mut out: Vec<LiveEvent> = rows.flatten().flatten().collect();
out.reverse();
Ok(out)
}
}
/// Token usage emitted by claude in the final `result` stream-json event.
/// All counts are in tokens. `None` fields mean the server didn't report them.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TokenUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_input_tokens: u64,
pub cache_creation_input_tokens: u64,
}
impl TokenUsage {
/// Total context consumed this turn (input + cache reads + cache writes).
/// This is the per-inference context footprint that counts against the
/// model's `contextWindow` limit. Tracked from the last `assistant` event
/// in the stream-json (per-inference usage, not the cumulative `result`
/// event which sums across all inferences in a tool-heavy turn and can
/// far exceed the per-inference window).
#[must_use]
pub fn context_tokens(&self) -> u64 {
self.input_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens
}
/// Parse usage from the terminal `result` stream-json event. This is the
/// **cumulative** sum across every inference in the turn — useful as a
/// cost signal, but NOT the current context size (a tool-heavy turn
/// sums per-call cached prompts and easily exceeds the model window).
#[must_use]
pub fn from_stream_event(v: &serde_json::Value) -> Option<Self> {
if v.get("type").and_then(|t| t.as_str()) != Some("result") {
return None;
}
Some(Self::from_usage_obj(v.get("usage")?))
}
/// Parse usage from a per-inference `assistant` event's
/// `.message.usage` block. Each turn fires one of these for every
/// model call; tracking the LAST one over the turn gives the actual
/// conversation context size — the number to watch for compaction.
#[must_use]
pub fn from_assistant_event(v: &serde_json::Value) -> Option<Self> {
if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
return None;
}
Some(Self::from_usage_obj(v.get("message")?.get("usage")?))
}
fn from_usage_obj(u: &serde_json::Value) -> Self {
let field = |k: &str| u.get(k).and_then(serde_json::Value::as_u64).unwrap_or(0);
Self {
input_tokens: field("input_tokens"),
output_tokens: field("output_tokens"),
cache_read_input_tokens: field("cache_read_input_tokens"),
cache_creation_input_tokens: field("cache_creation_input_tokens"),
}
}
/// Extract the per-inference context-window limit from a `result`
/// stream-json event's `modelUsage` map. The API reports this as
/// `modelUsage.<model-name>.contextWindow`; we take the first non-zero
/// value across all model keys.
///
/// Returns `None` if the event is not a `result` type or has no
/// `contextWindow` field. The returned value is the authoritative
/// per-inference active window (e.g. 200 000 for `claude-sonnet-4-6`).
/// It may be smaller than the full prompt-cache capacity (which can
/// be several million tokens via cache reads).
#[must_use]
pub fn context_window_from_result_event(v: &serde_json::Value) -> Option<u64> {
if v.get("type").and_then(|t| t.as_str()) != Some("result") {
return None;
}
let model_usage = v.get("modelUsage")?;
let map = model_usage.as_object()?;
for (_model, stats) in map {
if let Some(w) = stats.get("contextWindow").and_then(serde_json::Value::as_u64) {
if w > 0 {
return Some(w);
}
}
}
None
}
}
/// Authoritative turn-loop state. The harness owns it; the web UI
/// reads via `/api/state` and renders. Lives alongside the bus
/// because everyone who has a `Bus` already has the right handle to
/// poke the state on transitions.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TurnState {
/// Inbox is empty / waiting on `Recv`.
Idle,
/// `claude --print` is running for a turn.
Thinking,
/// Operator-triggered `/compact` is running on the persistent
/// session.
Compacting,
}
/// Compiled-in fallback model used when neither `HIVE_DEFAULT_MODEL` nor a
/// persisted runtime override is present.
pub const DEFAULT_MODEL: &str = "haiku";
/// Return the model declared in `HIVE_DEFAULT_MODEL` (set from
/// `hyperhive.model` in `agent.nix`), or `None` if the env var is absent /
/// empty. When `Some`, this takes precedence over any persisted runtime
/// override so that nix config changes always take effect on rebuild.
#[must_use]
pub fn configured_model() -> Option<&'static str> {
// Leak once at startup — acceptable for a single config value.
std::env::var("HIVE_DEFAULT_MODEL")
.ok()
.filter(|s| !s.trim().is_empty())
.map(|s| &*Box::leak(s.into_boxed_str()))
}
/// Return the model to use when no config and no persisted override exist.
#[must_use]
pub fn default_model() -> &'static str {
configured_model().unwrap_or(DEFAULT_MODEL)
}
/// Context-window size in tokens for a given model name.
///
/// Canonical per-model sizes are declared in `harness-base.nix` as
/// `hyperhive.contextWindowTokens` and injected as
/// `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars — so this function
/// normally just reads them. The Rust code carries no model knowledge;
/// updating model families only requires a Nix change.
///
/// Resolution order (first match wins):
/// 1. `HIVE_CONTEXT_WINDOW_TOKENS_<KEY>` — key (lowercased) is a
/// substring of the active model name. Populated by the Nix default
/// map for all known families; add/override in `agent.nix`.
/// 2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override (any model).
/// 3. Hard fallback: `200_000` (conservative; only hit outside NixOS).
#[must_use]
pub fn context_window_tokens(model: &str) -> u64 {
let m = model.to_ascii_lowercase();
// Per-model env vars set by `hyperhive.contextWindowTokens` in Nix.
for (key, val) in std::env::vars() {
if let Some(suffix) = key.strip_prefix("HIVE_CONTEXT_WINDOW_TOKENS_")
&& !suffix.is_empty() && m.contains(&suffix.to_ascii_lowercase())
&& let Ok(v) = val.trim().parse::<u64>()
&& v > 0 {
return v;
}
}
// Global override (single value, any model).
if let Ok(s) = std::env::var("HIVE_CONTEXT_WINDOW_TOKENS")
&& let Ok(v) = s.trim().parse::<u64>()
&& v > 0 {
return v;
}
// Hard fallback for dev/test outside NixOS where env vars aren't set.
200_000
}
#[derive(Clone)]
pub struct Bus {
tx: Arc<broadcast::Sender<BusEvent>>,
/// Monotonic per-process counter stamped onto every `BusEvent`.
/// Persisted nowhere — a harness restart resets seq to 0; clients
/// always treat reconnect as "fresh state, fresh stream of seqs."
/// Historical events served from sqlite carry no seq (they predate
/// the live channel the seq is meant to dedupe against).
event_seq: Arc<AtomicU64>,
/// Persistent event log. `None` only if opening the sqlite db failed
/// at construction — we keep going so the harness doesn't die on a
/// missing state dir mount in dev / test scenarios.
store: Option<Arc<EventStore>>,
/// Current turn-loop state + since-when (unix seconds).
state: Arc<Mutex<(TurnState, i64)>>,
/// Model name passed to `claude --model`. Default `haiku`; the
/// operator can override at runtime via `POST /api/model`.
model: Arc<Mutex<String>>,
/// Last-inference token usage from the most recent turn's final
/// `assistant` event. Represents the actual context window size at
/// turn-end — the number the operator watches to decide whether to
/// compact. `None` until the first turn completes.
last_ctx_usage: Arc<Mutex<Option<TokenUsage>>>,
/// Cumulative token usage from the most recent turn's `result`
/// event (sum across every inference in the turn). This is the cost
/// signal — tool-heavy turns rebill the cached prompt per call and
/// blow past the model window. `None` until the first turn completes.
last_cost_usage: Arc<Mutex<Option<TokenUsage>>>,
/// True while the harness is parked after a rate-limit response.
/// Set by `emit_status("rate_limited")`, cleared by
/// `emit_status("online")`. Also mirrored to a sentinel file at
/// `{state_dir}/hyperhive-rate-limited` so the host-side
/// `container_view` can surface the status on the dashboard without
/// a live socket call.
rate_limited: Arc<AtomicBool>,
/// One-shot: next `run_claude` call drops `--continue`, starting
/// a fresh claude session. Set by `POST /api/new-session` from
/// the per-agent web UI; consumed (cleared back to false) by the
/// next turn. Subsequent turns resume normal `--continue`
/// behavior. Atomic so the consumer can take-and-clear without a
/// lock.
skip_continue_once: Arc<AtomicBool>,
/// Per-turn tool-call counter. Reset by the bin loop between
/// turns via `take_tool_calls`. Populated by `observe_stream` as
/// the stdout pump parses each stream-json line. Powers the
/// `tool_call_count` + `tool_call_breakdown_json` columns on the
/// per-turn stats sink.
tool_calls: Arc<Mutex<std::collections::HashMap<String, u64>>>,
/// Unix timestamp of the most recent completed turn (set by
/// `record_turn_usage`). Used by the auto-reset heuristic in
/// `turn.rs` to compute how long the session has been idle and
/// whether the prompt cache has gone cold. `0` = no turn yet.
last_turn_ended_unix: Arc<AtomicI64>,
/// Per-inference context-window size as reported by the Anthropic API
/// in the stream-json `result` event (`modelUsage.*.contextWindow`).
/// Set by the stdout pump on every completed turn. Takes precedence
/// over the Nix-configured `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars
/// for compaction watermark calculations — it reflects the actual
/// limit the model enforces, which may differ from what the operator
/// configured (e.g. 200 k active window on a 1 M cache-enabled model).
api_context_window: Arc<Mutex<Option<u64>>>,
}
impl Bus {
/// Open the events db (path from `events_db_path()`). On failure, fall back
/// to a no-store bus — the harness still works, just without persistent history.
#[must_use]
pub fn new() -> Self {
let path = events_db_path();
let store = match EventStore::open(&path) {
Ok(s) => Some(Arc::new(s)),
Err(e) => {
tracing::warn!(error = ?e, path = %path.display(), "events db open failed; running without history");
None
}
};
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
// Priority: HIVE_DEFAULT_MODEL (from hyperhive.model in agent.nix) >
// persisted runtime override > compiled-in DEFAULT_MODEL.
// The nix config always wins on rebuild; the persisted file is kept
// for within-session tracking only (see persist_model / set_model).
let initial_model = configured_model()
.map(str::to_owned)
.unwrap_or_else(|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()));
// Restore rate_limited from the sentinel file — if the harness
// crashed while parked, we should still show the right status on
// cold load until the next turn clears it.
let sentinel = crate::paths::state_dir().join("hyperhive-rate-limited");
let was_rate_limited = sentinel.exists();
Self {
tx: Arc::new(tx),
event_seq: Arc::new(AtomicU64::new(0)),
store,
state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))),
model: Arc::new(Mutex::new(initial_model)),
last_ctx_usage: Arc::new(Mutex::new(None)),
last_cost_usage: Arc::new(Mutex::new(None)),
rate_limited: Arc::new(AtomicBool::new(was_rate_limited)),
skip_continue_once: Arc::new(AtomicBool::new(false)),
tool_calls: Arc::new(Mutex::new(std::collections::HashMap::new())),
last_turn_ended_unix: Arc::new(AtomicI64::new(0)),
api_context_window: Arc::new(Mutex::new(None)),
}
}
/// Current high-water seq. Snapshot endpoints read this before
/// gathering state so the resulting (snapshot.seq, snapshot) pair
/// satisfies: any live event with seq > snapshot.seq is post-snapshot
/// (not yet reflected). Clients dedupe buffered SSE traffic against
/// this value.
#[must_use]
pub fn current_seq(&self) -> u64 {
self.event_seq.load(Ordering::SeqCst)
}
fn next_seq(&self) -> u64 {
self.event_seq.fetch_add(1, Ordering::SeqCst) + 1
}
/// Arm the one-shot: the next claude invocation will run without
/// `--continue`, dropping any prior session context. Idempotent
/// — calling twice in a row before the next turn still consumes
/// to a single fresh-start.
pub fn request_new_session(&self) {
self.skip_continue_once.store(true, Ordering::SeqCst);
}
/// Take + clear the one-shot. Returns true iff the caller should
/// run claude without `--continue` for this turn.
#[must_use]
pub fn take_skip_continue(&self) -> bool {
self.skip_continue_once.swap(false, Ordering::SeqCst)
}
/// Currently-selected claude model name. Read on every turn so a
/// `/model <name>` flip takes effect on the next turn.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn model(&self) -> String {
self.model.lock().unwrap().clone()
}
/// Switch the model for future turns. The current turn (if any)
/// keeps the model it was already running. Persisted to the agent's
/// state dir (`hyperhive-model`) so the override survives harness
/// restart and container rebuild (gone on `--purge`, matching
/// every other piece of agent state).
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn set_model(&self, name: impl Into<String>) {
let value: String = name.into();
self.model.lock().unwrap().clone_from(&value);
if let Err(e) = persist_model(&value) {
tracing::warn!(error = ?e, "model: persist failed");
}
self.emit(LiveEvent::ModelChanged { model: value });
}
/// Seed `last_ctx_usage` + `last_cost_usage` at startup without
/// emitting a SSE event. Used by the bin entrypoints to backfill
/// from the most recent `turn_stats` row so the per-agent web UI's
/// ctx + cost badges paint real numbers on cold load.
///
/// # Panics
///
/// Panics if an internal lock is poisoned.
pub fn seed_usage(&self, ctx: Option<TokenUsage>, cost: Option<TokenUsage>) {
if ctx.is_some() {
*self.last_ctx_usage.lock().unwrap() = ctx;
}
if cost.is_some() {
*self.last_cost_usage.lock().unwrap() = cost;
}
}
/// Record the just-ended turn's usage. `ctx` is the last inference's
/// usage (current context size); `cost` is the cumulative across
/// every inference in the turn (cost signal). One SSE event fires
/// per turn carrying both.
///
/// # Panics
///
/// Panics if an internal lock is poisoned.
pub fn record_turn_usage(&self, ctx: TokenUsage, cost: TokenUsage) {
*self.last_ctx_usage.lock().unwrap() = Some(ctx);
*self.last_cost_usage.lock().unwrap() = Some(cost);
self.last_turn_ended_unix.store(now_unix(), Ordering::Relaxed);
self.emit(LiveEvent::TokenUsageChanged { ctx, cost });
}
/// Unix timestamp of the most recent completed turn (`record_turn_usage`
/// call), or `0` if no turn has finished yet.
#[must_use]
pub fn last_turn_ended_unix(&self) -> i64 {
self.last_turn_ended_unix.load(Ordering::Relaxed)
}
/// Update the API-reported context-window size from the stream-json
/// `result` event's `modelUsage.*.contextWindow` field. Called by the
/// stdout pump once per completed turn. `0` is ignored (sentinel for
/// "not reported").
pub fn set_api_context_window(&self, window: u64) {
if window > 0 {
*self.api_context_window.lock().unwrap() = Some(window);
}
}
/// Return the API-reported per-inference context-window size, if the
/// harness has seen at least one completed turn for this session.
/// `None` until the first result event is processed.
#[must_use]
pub fn api_context_window(&self) -> Option<u64> {
*self.api_context_window.lock().unwrap()
}
/// Walk a stream-json value for `tool_use` blocks and bump the
/// per-turn counter for each one we find. Called by the stdout
/// pump on every parsed line. Cheap when the line isn't an
/// assistant message — the field-check short-circuits.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn observe_stream(&self, v: &serde_json::Value) {
if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
return;
}
let Some(content) = v
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
else {
return;
};
let mut counts = self.tool_calls.lock().unwrap();
for block in content {
if block.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
continue;
}
let name = block
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("<unnamed>")
.to_owned();
*counts.entry(name).or_insert(0) += 1;
}
}
/// Snapshot + clear the per-turn tool-call counter. The harness
/// calls this between turns to fold the breakdown into a
/// `turn_stats` row, then start the next turn with an empty map.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn take_tool_calls(&self) -> std::collections::HashMap<String, u64> {
std::mem::take(&mut *self.tool_calls.lock().unwrap())
}
/// Last context-size snapshot (last inference of the most recent
/// turn), or `None` if no turn has completed yet.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn last_ctx_usage(&self) -> Option<TokenUsage> {
*self.last_ctx_usage.lock().unwrap()
}
/// Last cumulative cost snapshot (sum across the most recent turn's
/// inferences), or `None` if no turn has completed yet.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn last_cost_usage(&self) -> Option<TokenUsage> {
*self.last_cost_usage.lock().unwrap()
}
/// Update the harness's authoritative turn-loop state. Records
/// the transition time so `state_snapshot` can return a since-age.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn set_state(&self, next: TurnState) {
let since;
{
let mut guard = self.state.lock().unwrap();
if guard.0 == next {
return;
}
*guard = (next, now_unix());
since = guard.1;
}
self.emit(LiveEvent::TurnStateChanged {
state: next,
since_unix: since,
});
}
/// Broadcast a status flip (online / `needs_login_*` / `rate_limited`).
/// Called by the bin entry points + `turn::wait_for_login` + the
/// `post_login_*` handlers — every site that mutates the
/// `Arc<Mutex<LoginState>>` should also call this so the web UI
/// drops its periodic /api/state poll while a turn loop is running.
///
/// Sentinel files survive harness restart so the host-side dashboard
/// can render the status without a live socket call:
/// - `"rate_limited"` writes `{state_dir}/hyperhive-rate-limited`
/// (cleared by any other status).
/// - `"needs_login_idle"` writes `{state_dir}/hyperhive-needs-login`
/// so a 401-triggered re-auth flag persists across harness restart
/// (#419). The web UI's `/login` POST handler clears it via
/// `clear_needs_login_sentinel` once the operator re-auths.
/// - `"online"` clears both sentinels — the agent is healthy again.
pub fn emit_status(&self, status: impl Into<String>) {
let status = status.into();
let rate_limited_path = crate::paths::state_dir().join("hyperhive-rate-limited");
let needs_login_path = crate::paths::state_dir().join("hyperhive-needs-login");
if status == "rate_limited" {
self.rate_limited.store(true, Ordering::Relaxed);
let _ = std::fs::write(&rate_limited_path, b"");
} else {
self.rate_limited.store(false, Ordering::Relaxed);
let _ = std::fs::remove_file(&rate_limited_path);
}
if status == "needs_login_idle" {
let _ = std::fs::write(&needs_login_path, b"");
} else if status == "online" {
// Re-auth completed (or manual flip back to online) — drop
// the sentinel. `needs_login_in_progress` is a transient
// mid-flow status and shouldn't clear yet.
let _ = std::fs::remove_file(&needs_login_path);
}
self.emit(LiveEvent::StatusChanged { status });
}
/// Returns true while the harness is parked after a rate-limit response.
#[must_use]
pub fn is_rate_limited(&self) -> bool {
self.rate_limited.load(Ordering::Relaxed)
}
/// Current state + since-when (unix seconds). Snapshot copy, no lock held.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn state_snapshot(&self) -> (TurnState, i64) {
*self.state.lock().unwrap()
}
pub fn emit(&self, event: LiveEvent) {
if let Some(store) = &self.store
&& let Err(e) = store.append(&event)
{
tracing::warn!(error = ?e, "events: append failed");
}
let envelope = BusEvent {
seq: self.next_seq(),
event,
};
// Lagged subscribers drop events — fine; the UI is a tail, not a log.
let _ = self.tx.send(envelope);
}
#[must_use]
pub fn subscribe(&self) -> broadcast::Receiver<BusEvent> {
self.tx.subscribe()
}
/// Most recent events, oldest first, capped at `HISTORY_CAPACITY`.
/// Drives the terminal pre-fill when the operator opens the agent
/// page; without a store (db open failed) this is empty.
#[must_use]
pub fn history(&self) -> Vec<LiveEvent> {
let Some(store) = &self.store else {
return Vec::new();
};
store.recent(HISTORY_CAPACITY).unwrap_or_default()
}
}
impl Default for Bus {
fn default() -> Self {
Self::new()
}
}

View file

@ -0,0 +1,775 @@
//! Background Forgejo notification poller.
//!
//! Reads `HIVE_FORGE_URL` + `{HYPERHIVE_STATE_DIR}/forge-token`, polls
//! `GET /notifications?all=false` every 30 seconds, and delivers each
//! unread notification as a broker `Wake { from: "forge" }` message so
//! claude's normal turn loop picks it up.
//!
//! Each notification is enriched with the subject body and/or latest
//! comment body so the agent sees actual content, not just a title.
//!
//! Graceful no-ops:
//! - `HIVE_FORGE_URL` not set → disabled (no forge configured)
//! - token file absent → disabled (agent has no forge account yet)
//! - HTTP errors → logged at debug, retry next tick
//!
//! After successfully delivering a notification it is marked read via
//! `PATCH /notifications/threads/{id}` so it does not re-fire. If delivery
//! fails the thread is left unread so it resurfaces next tick.
//!
//! Self-notification filtering (closes #230):
//! - New issues/PRs created by this agent (`reason == "author"` + `state == open`)
//! are silently marked read — the agent already knows it opened them.
//! - Comment notifications where the comment author matches this agent's own
//! forge login are silently marked read.
//!
//! Own login is fetched once at startup via `GET /user` and cached for the
//! lifetime of the polling loop.
//!
//! PR review formatting (closes #231):
//! - When `latest_comment_url` points to a review (the fetched JSON has a
//! `state` field like `APPROVED` / `REQUEST_CHANGES` / `COMMENT`), the
//! notification is formatted as `[PR approved #N repo]` instead of the
//! generic `[comment on PR #N repo]` so agents can action it immediately.
use std::collections::HashSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tracing::{debug, info, warn};
const POLL_INTERVAL_SECS: u64 = 30;
const HTTP_TIMEOUT_SECS: u64 = 10;
/// Maximum characters of a body/comment to include in the wake message.
const BODY_TRUNCATE: usize = 500;
/// Spawn point: called once from `hive-ag3nt serve` (agent) or
/// `hive-m1nd serve` (manager). Returns immediately if the forge is not
/// configured. Otherwise loops forever, polling every
/// `POLL_INTERVAL_SECS` seconds. Errors are never fatal.
///
/// `is_manager`: when true, wakes the inbox via `ManagerRequest::Wake`
/// instead of `AgentRequest::Wake` (the manager socket rejects the agent
/// request type).
pub async fn run(socket: PathBuf, is_manager: bool) {
let forge_url = match std::env::var("HIVE_FORGE_URL") {
Ok(u) if !u.is_empty() => u,
_ => {
debug!("forge_notify: HIVE_FORGE_URL not set — disabled");
return;
}
};
let state_dir = std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default();
let token_path = format!("{state_dir}/forge-token");
let token = match tokio::fs::read_to_string(&token_path).await {
Ok(t) => {
let t = t.trim().to_owned();
if t.is_empty() {
debug!("forge_notify: empty forge token at {token_path} — disabled");
return;
}
t
}
Err(e) => {
debug!("forge_notify: no forge token at {token_path} ({e}) — disabled");
return;
}
};
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
.build()
{
Ok(c) => c,
Err(e) => {
warn!("forge_notify: failed to build HTTP client: {e}");
return;
}
};
// Fetch own login once for self-notification filtering (closes #230).
// Falls back to empty string on failure — no filtering (safe degradation).
let own_login = {
let url = format!("{forge_url}/api/v1/user");
fetch_json(&client, &url, &token)
.await
.and_then(|v| v["login"].as_str().map(std::borrow::ToOwned::to_owned))
.unwrap_or_default()
};
if own_login.is_empty() {
warn!("forge_notify: could not resolve own login — self-notification filtering disabled");
} else {
debug!(%own_login, "forge_notify: own login resolved");
}
let mut interval = tokio::time::interval(Duration::from_secs(POLL_INTERVAL_SECS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// First tick fires immediately — skip it so we don't race the broker
// socket becoming available right at boot.
interval.tick().await;
// HIVE_FORGE_KEEP_SUBSCRIPTIONS=1 disables auto-unsubscribe for agents
// that intentionally consume the full repo notification firehose (e.g. triage).
let keep_subscriptions = std::env::var("HIVE_FORGE_KEEP_SUBSCRIPTIONS")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
// Optional reason drop-list. `HIVE_FORGE_NOTIFY_SKIP_REASONS` is a
// comma-separated list of Forgejo notification `reason` values to
// suppress (e.g. `subscribed,participating`). Notifications with
// those reasons are marked read and silently dropped; everything
// else -- including notifications with a null/unrecognised reason --
// is delivered. Drop-list is safer than an allow-list: it kills the
// firehose without risking silent misses of directed signals
// (review_requested, assigned) or future unknown reason strings.
// Configurable per-agent via `hyperhive.forge.skipNotifyReasons` in agent.nix.
let skip_reasons: Vec<String> = std::env::var("HIVE_FORGE_NOTIFY_SKIP_REASONS")
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
if skip_reasons.is_empty() {
info!(forge_url = %forge_url, "forge_notify: polling started (all reasons)");
} else {
info!(forge_url = %forge_url, skip = ?skip_reasons, "forge_notify: polling started");
}
// Repos we have already unsubscribed this process lifetime. Persists
// across polls so we don't hammer DELETE on every cycle.
let mut unsubbed_repos: HashSet<String> = HashSet::new();
loop {
interval.tick().await;
poll_once(
&client,
&forge_url,
&token,
&socket,
is_manager,
keep_subscriptions,
&mut unsubbed_repos,
&own_login,
&skip_reasons,
)
.await;
}
}
/// Fetch a JSON value from a URL using the agent's forge token. Returns
/// `None` on any HTTP or parse error (best-effort enrichment).
async fn fetch_json(
client: &reqwest::Client,
url: &str,
token: &str,
) -> Option<serde_json::Value> {
let resp = client
.get(url)
.header("Authorization", format!("token {token}"))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
resp.json().await.ok()
}
/// Map a Forgejo notification `subject.type` to a human-readable label.
/// Known values: "Pull", "Issue", "Commit", "Repository". Any unknown
/// type is passed through as-is so new Forgejo types degrade gracefully
/// rather than silently collapsing into a generic label.
fn notif_type_label(t: &str) -> &str {
match t {
"Pull" => "PR",
"Issue" => "issue",
other => other,
}
}
/// Truncate a string to `max` bytes at a char boundary, appending `…` if cut.
/// Escape ATX-style markdown headings (`# h`, `## h`, …) in a
/// comment/review body before we embed it inline in the forge-notify
/// wrapper. The wrapper is the markdown context the dashboard's
/// `marked.parse` sees; without this, a body line like `## argus
/// review` blows into a top-level h2 in the agent's chat row,
/// dwarfing the rest of the wrapper text (closes #455).
///
/// Backslash before `#` is the standard markdown escape — `\#` renders
/// as the literal character `#`, so the line is preserved verbatim
/// without claiming heading-level styling. Indented lines keep their
/// indentation. Lines that don't start with `#` (ignoring leading
/// whitespace) are passed through unchanged. Setext-style headings
/// (`heading\n===`) are not handled here — rarer in practice and
/// would need multi-line lookahead; revisit if it actually shows up.
///
/// **ATX shape strictly:** CommonMark requires a space (or end-of-line)
/// after the 1-6 leading `#`s to count as an ATX heading. Lines like
/// `#tag`, `#123`, `#!/bin/bash` are NOT headings — passing them through
/// untouched avoids the cosmetic noise argus flagged on PR #518 (`\#tag`
/// renders the same as `#tag`, but the escape is unnecessary).
///
/// **Trailing newline preserved:** `split_inclusive('\n')` keeps each
/// line's terminator so the join round-trips a body that ended in `\n`.
fn escape_md_headings(body: &str) -> String {
let mut out = String::with_capacity(body.len());
for line in body.split_inclusive('\n') {
let (content, terminator) = match line.strip_suffix('\n') {
Some(rest) => (rest, "\n"),
None => (line, ""),
};
let trimmed = content.trim_start();
if is_atx_heading(trimmed) {
let lead = &content[..content.len() - trimmed.len()];
out.push_str(lead);
out.push('\\');
out.push_str(trimmed);
} else {
out.push_str(content);
}
out.push_str(terminator);
}
out
}
/// Strict CommonMark ATX-heading detector: 1-6 leading `#`s followed
/// by either a space, tab, or end-of-line. Anything tighter (`#tag`,
/// `#123`) is a non-heading line that the renderer will not promote.
fn is_atx_heading(line: &str) -> bool {
let hashes = line.bytes().take_while(|&b| b == b'#').count();
if !(1..=6).contains(&hashes) {
return false;
}
match line.as_bytes().get(hashes) {
None => true, // bare `#` / `##` / ... on its own line
Some(b' ') | Some(b'\t') => true, // proper ATX with space/tab after #s
_ => false, // `#tag` / `#123` — not a heading
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_owned();
}
let end = s
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= max - 3)
.last()
.unwrap_or(0);
format!("{}", &s[..end])
}
/// Map a Forgejo review state to a readable action label.
/// Returns `None` for non-review states (regular comments have no `state` field;
/// `PENDING` means the review was saved but not submitted yet).
/// Forgejo review states: "APPROVED", "`REQUEST_CHANGES`", "COMMENT", "PENDING".
fn review_state_label(state: &str) -> Option<&str> {
match state {
"APPROVED" => Some("approved"),
"REQUEST_CHANGES" => Some("changes requested"),
"COMMENT" => Some("review comment"),
_ => None,
}
}
/// Build a human-readable wake message for one Forgejo notification.
/// Returns `None` when the notification is a self-echo (actor is `own_login`)
/// and should be silently discarded (and marked read by the caller).
///
/// Formats:
/// - Comment: `[comment on PR #N repo] title\nurl: ...\n\nauthor: body\nassignee: user\nreason: mention`
/// - Review: `[PR approved #N repo] title\nurl: ...\n\nreviewer: body\nassignee: user\nreason: review_requested`
/// - New item: `[new issue #N repo] title\nurl: ...\nassignee: user\nreason: author`
/// - State: `[PR merged #N repo] title\nurl: ...\nassignee: user\nreason: subscribed`
///
/// Assignees (and, for PRs, `requested_reviewers`) are appended unconditionally
/// on all issue/PR notifications (closes #256).
///
/// The `reason` field from the Forgejo notification is always appended (closes #110).
/// Forgejo emits one notification entry per reason for the same event, so including
/// it makes otherwise-identical messages distinguishable (e.g. `mention` vs
/// `subscribed` both arriving for the same PR comment).
///
/// Number is extracted from `html_url` last path segment before any `#`.
/// Repo slug (`owner/name`) is always included — agents may watch multiple repos.
async fn format_notification(
client: &reqwest::Client,
token: &str,
notif: &serde_json::Value,
own_login: &str,
) -> Option<String> {
let title = notif["subject"]["title"].as_str().unwrap_or("?");
let notif_type = notif["subject"]["type"].as_str().unwrap_or("?");
let html_url = notif["subject"]["html_url"]
.as_str()
.unwrap_or_else(|| notif["subject"]["url"].as_str().unwrap_or(""));
// Extract issue/PR number from the html_url. URL ends with /issues/N or
// /pulls/N (possibly followed by #anchor for comments). Best-effort.
let num = html_url
.split('#')
.next()
.and_then(|u| u.rsplit('/').next())
.and_then(|s| s.parse::<u64>().ok())
.map(|n| format!(" #{n}"))
.unwrap_or_default();
// Repo slug for multi-repo disambiguation. Falls back gracefully when absent.
let repo = notif["repository"]["full_name"]
.as_str()
.map(|r| format!(" {r}"))
.unwrap_or_default();
// API URLs for fetching content
let subject_api_url = notif["subject"]["url"].as_str().unwrap_or("");
let comment_api_url = notif["subject"]["latest_comment_url"].as_str().unwrap_or("");
let comment_html_url = notif["subject"]["latest_comment_html_url"]
.as_str()
.unwrap_or("");
// Always fetch subject detail for assignee/reviewer metadata (#256).
// Keeps agents informed of current ownership without a follow-up fetch.
let subject = if subject_api_url.is_empty() {
None
} else {
fetch_json(client, subject_api_url, token).await
};
let is_pr = matches!(notif_type, "Pull Request" | "Pull");
let reason = notif["reason"].as_str().unwrap_or("");
let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr, reason);
// Determine whether this notification was triggered by a comment/review or
// by creation/state-change of the subject itself.
let has_comment = !comment_api_url.is_empty() && comment_api_url != subject_api_url;
let meta = NotifMeta { title, notif_type, html_url, num, repo, meta_suffix, reason, subject, is_pr };
if has_comment {
format_comment_notification(client, token, &meta, comment_api_url, comment_html_url, own_login).await
} else {
format_state_change_notification(notif, &meta, own_login)
}
}
/// Shared notification metadata extracted from the raw Forgejo JSON.
struct NotifMeta<'a> {
title: &'a str,
notif_type: &'a str,
html_url: &'a str,
num: String,
repo: String,
meta_suffix: String,
/// Forgejo `reason` value (e.g. "mention", "assigned", "subscribed").
/// Appended to every formatted message so that multiple notifications for
/// the same event (each with a different reason) are distinguishable (closes #110).
reason: &'a str,
/// Fetched subject detail (issue/PR JSON); used for review-request detection.
subject: Option<serde_json::Value>,
is_pr: bool,
}
/// Build the `\nassignee: ...` (and optionally `\nreviewer: ...` and `\nreason: ...`) suffix
/// appended to all notification kinds.
fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool, reason: &str) -> String {
let assignees: Vec<&str> = subject
.and_then(|s| s["assignees"].as_array())
.map(|arr| arr.iter().filter_map(|a| a["login"].as_str()).collect())
.unwrap_or_default();
let assignee_line = if assignees.is_empty() {
"assignee: unassigned".to_owned()
} else {
format!("assignee: {}", assignees.join(", "))
};
// For PRs, include requested_reviewers when present.
let reviewer_line = if is_pr {
let reviewers: Vec<&str> = subject
.and_then(|s| s["requested_reviewers"].as_array())
.map(|arr| arr.iter().filter_map(|r| r["login"].as_str()).collect())
.unwrap_or_default();
if reviewers.is_empty() { None } else { Some(format!("reviewer: {}", reviewers.join(", "))) }
} else {
None
};
// Always include reason so multiple notifications for the same event
// (each with a different Forgejo reason) are distinguishable (closes #110).
let reason_line = if reason.is_empty() { None } else { Some(format!("reason: {reason}")) };
let mut out = format!("\n{assignee_line}");
if let Some(r) = reviewer_line { write!(out, "\n{r}").ok(); }
if let Some(r) = reason_line { write!(out, "\n{r}").ok(); }
out
}
/// Format a notification triggered by a new comment or review submission.
async fn format_comment_notification(
client: &reqwest::Client,
token: &str,
meta: &NotifMeta<'_>,
comment_api_url: &str,
comment_html_url: &str,
own_login: &str,
) -> Option<String> {
let payload = fetch_json(client, comment_api_url, token).await;
let actor_login = payload
.as_ref()
.and_then(|c| c["user"]["login"].as_str())
.unwrap_or("");
// Self-notification filter (#230): skip if we authored the comment/review.
if !own_login.is_empty() && actor_login == own_login {
debug!(%own_login, "forge_notify: skipping self-authored comment/review");
return None;
}
let body_text = payload
.as_ref()
.and_then(|c| c["body"].as_str())
.unwrap_or("")
.trim();
// PR review detection (#231): Forgejo review objects carry a `state` field
// with values like "APPROVED" / "REQUEST_CHANGES" / "COMMENT". Regular
// issue/PR comments have no such field. Format reviews distinctly so the
// agent knows the review outcome immediately without reading the body.
let review_state = payload
.as_ref()
.and_then(|c| c["state"].as_str())
.and_then(review_state_label);
let url = if comment_html_url.is_empty() { meta.html_url } else { comment_html_url };
let author = if actor_login.is_empty() { "?" } else { actor_login };
let NotifMeta { title, notif_type, num, repo, meta_suffix, .. } = meta;
// Escape ATX headings in the user-authored body so the embedded
// text doesn't blow into top-level h1/h2 in the wrapper message
// when the dashboard renders it (closes #455). Done once here
// because both code paths fall through the same truncate+embed
// pattern.
let escaped = escape_md_headings(body_text);
let body_for_embed = truncate(&escaped, BODY_TRUNCATE);
if let Some(review_label) = review_state {
// Review submission on a PR.
let kind = format!("PR {review_label}{num}{repo}");
let mut out = format!("[{kind}] {title}\nurl: {url}");
if body_text.is_empty() {
write!(out, "\n\nreviewer: {author}").ok();
} else {
write!(out, "\n\n{author}: {body_for_embed}").ok();
}
out.push_str(meta_suffix);
Some(out)
} else {
// Regular comment.
let kind = format!("comment on {}{num}{repo}", notif_type_label(notif_type));
let mut out = format!("[{kind}] {title}\nurl: {url}\n\n{author}: {body_for_embed}");
if out.ends_with('\n') {
out.pop();
}
out.push_str(meta_suffix);
Some(out)
}
}
/// Format a notification triggered by creation or state change of the subject.
fn format_state_change_notification(
notif: &serde_json::Value,
meta: &NotifMeta<'_>,
own_login: &str,
) -> Option<String> {
// Classification uses notif["subject"]["state"] directly — Forgejo
// returns "open" / "closed" / "merged" here. We do NOT rely on
// fetching the PR/issue detail for `merged`:
// - `subject.url` points to the *issues* endpoint, which returns
// `pull_request.merged`, not top-level `merged`.
// - Forgejo API type is "Pull" / "Issue", never "Pull Request".
let notif_state = notif["subject"]["state"].as_str().unwrap_or("");
// Self-notification filter (#230): skip new items we authored ourselves.
// `reason == "author"` combined with open state means we just opened the
// issue/PR. We do NOT filter merged/closed state changes — those are
// triggered by someone else and we want them.
let is_new = notif_state == "open" || notif_state.is_empty();
if is_new && meta.reason == "author" && !own_login.is_empty() {
debug!(%own_login, "forge_notify: skipping self-authored new item");
return None;
}
let NotifMeta { title, notif_type, html_url, num, repo, meta_suffix, reason: _, subject, is_pr } = meta;
let label = notif_type_label(notif_type);
let kind = match notif_state {
"merged" => format!("{label} merged{num}{repo}"),
"closed" => format!("{label} closed{num}{repo}"),
"open" | "" => format!("new {label}{num}{repo}"),
other => format!("{label}{num}{repo}: {other}"),
};
// Review-request detection (#253): Forgejo does not always set
// reason == "review_requested" (observed as null). Check
// requested_reviewers instead, which is reliable. If own_login is
// in the list, override the kind.
// subject and is_pr are already fetched unconditionally above (#256).
let is_review_request = is_new
&& *is_pr
&& !own_login.is_empty()
&& subject
.as_ref()
.and_then(|s| s["requested_reviewers"].as_array())
.is_some_and(|arr| arr.iter().any(|r| r["login"].as_str() == Some(own_login)));
let kind = if is_review_request {
format!("review requested{num}{repo}")
} else {
kind
};
let mut out = format!("[{kind}] {title}\nurl: {html_url}");
out.push_str(meta_suffix);
Some(out)
}
#[allow(clippy::too_many_arguments)]
async fn poll_once(
client: &reqwest::Client,
forge_url: &str,
token: &str,
socket: &Path,
is_manager: bool,
keep_subscriptions: bool,
unsubbed_repos: &mut HashSet<String>,
own_login: &str,
skip_reasons: &[String],
) {
let url = format!("{forge_url}/api/v1/notifications?all=false&limit=50");
let resp = match client
.get(&url)
.header("Authorization", format!("token {token}"))
.send()
.await
{
Ok(r) => r,
Err(e) => {
debug!("forge_notify: poll request failed: {e}");
return;
}
};
if !resp.status().is_success() {
debug!("forge_notify: poll status {}", resp.status());
return;
}
let notifications: Vec<serde_json::Value> = match resp.json().await {
Ok(v) => v,
Err(e) => {
warn!("forge_notify: response parse error: {e}");
return;
}
};
if notifications.is_empty() {
return;
}
debug!(count = notifications.len(), "forge_notify: delivering notifications");
for notif in &notifications {
let Some(id) = notif["id"].as_u64() else { continue };
// Reason drop-list: suppress noisy reasons (subscribed/participating).
// null/unknown reasons pass through — directed signals are never
// silently dropped even if Forgejo returns an unexpected value.
if !skip_reasons.is_empty() {
let reason = notif["reason"].as_str().unwrap_or("");
if !reason.is_empty() && skip_reasons.iter().any(|r| r == reason) {
debug!(%id, %reason, "forge_notify: skipping (reason in drop-list)");
mark_read(client, forge_url, token, id).await;
continue;
}
}
let body_opt = format_notification(client, token, notif, own_login).await;
// None means self-echo — mark read silently, no delivery.
let Some(body) = body_opt else {
mark_read(client, forge_url, token, id).await;
continue;
};
let delivered = if is_manager {
let req = hive_sh4re::ManagerRequest::Wake {
from: "forge".to_owned(),
body,
};
crate::client::request::<_, hive_sh4re::ManagerResponse>(socket, &req)
.await
.map(|_| ())
} else {
let req = hive_sh4re::AgentRequest::Wake {
from: "forge".to_owned(),
body,
};
crate::client::request::<_, hive_sh4re::AgentResponse>(socket, &req)
.await
.map(|_| ())
};
match delivered {
Ok(()) => {
debug!(%id, "forge_notify: delivered");
}
Err(e) => {
warn!(%id, error = ?e, "forge_notify: deliver failed — leaving unread");
continue;
}
}
// Mark as read only after successful delivery so a failed-delivery
// notification resurfaces on the next poll tick.
mark_read(client, forge_url, token, id).await;
// Auto-unsubscribe from broad repo watches when the notification
// reason is "subscribed" (agent watching the whole repo). Skipped
// when HIVE_FORGE_KEEP_SUBSCRIPTIONS=1 — triage and other firehose
// consumers set this to retain broad repo visibility.
let reason = notif["reason"].as_str().unwrap_or("");
if !keep_subscriptions && reason == "subscribed"
&& let Some(repo) = notif["repository"]["full_name"].as_str()
&& !unsubbed_repos.contains(repo) {
let unsub_url = format!("{forge_url}/api/v1/repos/{repo}/subscription");
match client
.delete(&unsub_url)
.header("Authorization", format!("token {token}"))
.send()
.await
{
Ok(r) if r.status().is_success() || r.status().as_u16() == 404 => {
debug!(%repo, "forge_notify: unsubscribed from repo watch");
unsubbed_repos.insert(repo.to_owned());
}
Ok(r) => {
debug!(%repo, status = %r.status(), "forge_notify: unsub non-2xx (ignored)");
}
Err(e) => {
debug!(%repo, error = ?e, "forge_notify: unsub request failed (ignored)");
}
}
}
}
}
/// Mark a notification thread as read. Best-effort — logs on failure but
/// does not abort the poll loop. A notification left unread will resurface
/// on the next poll tick (desirable for delivery failures; for self-echo
/// silencing we call this without prior delivery).
async fn mark_read(client: &reqwest::Client, forge_url: &str, token: &str, id: u64) {
let mark_url = format!("{forge_url}/api/v1/notifications/threads/{id}");
match client
.patch(&mark_url)
.header("Authorization", format!("token {token}"))
.send()
.await
{
Err(e) => {
warn!(%id, error = ?e, "forge_notify: mark-read request failed — notification will resurface");
}
Ok(r) if !r.status().is_success() => {
warn!(%id, status = %r.status(), "forge_notify: mark-read returned non-2xx — notification will resurface");
}
Ok(_) => {
debug!(%id, "forge_notify: marked read");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_md_headings_escapes_top_level_atx() {
// The #455 repro: argus reviews start with `## argus review`,
// which would otherwise become an h2 in the wrapper message.
assert_eq!(
escape_md_headings("## argus review\n\nlgtm."),
"\\## argus review\n\nlgtm.",
);
}
#[test]
fn escape_md_headings_escapes_all_heading_depths() {
let body = "# h1\n## h2\n### h3\n###### h6\nbody";
assert_eq!(
escape_md_headings(body),
"\\# h1\n\\## h2\n\\### h3\n\\###### h6\nbody",
);
}
#[test]
fn escape_md_headings_preserves_indent() {
// Indented "headings" inside lists / nested quotes keep
// their leading whitespace so structure isn't visually
// collapsed by the escape.
assert_eq!(
escape_md_headings(" ## indented\nbody"),
" \\## indented\nbody",
);
}
#[test]
fn escape_md_headings_passes_non_heading_lines_through() {
let body = "plain text\nwith a #hashtag in middle\n```\n# in fenced code\n```";
let escaped = escape_md_headings(body);
// Lines without leading `#` are untouched. The `# in fenced
// code` line still gets escaped (we don't track fenced-code
// state) — acceptable: inside a fenced block the escape is
// visually inert anyway because the renderer treats the
// content as literal.
assert!(escaped.contains("plain text"));
assert!(escaped.contains("with a #hashtag in middle"));
assert!(escaped.contains("\\# in fenced code"));
}
#[test]
fn escape_md_headings_handles_empty_and_whitespace_only() {
assert_eq!(escape_md_headings(""), "");
assert_eq!(escape_md_headings(" "), " ");
assert_eq!(escape_md_headings("\n\n"), "\n\n");
}
#[test]
fn escape_md_headings_skips_non_atx_hash_lines() {
// ATX requires a space after the `#`s. Lines like `#tag`,
// `#123`, `#!/bin/bash` are NOT headings — argus's PR #518
// yellow nit: don't add cosmetic noise where the renderer
// wouldn't promote the line in the first place.
let body = "#tag\n#123\n#!/bin/bash\n####### too many hashes\nbody";
let escaped = escape_md_headings(body);
// All four leading `#` lines pass through untouched: too few
// (still need space), seven `#`s (over the cap), shebang
// (no space).
assert_eq!(escaped, body);
}
#[test]
fn escape_md_headings_handles_bare_hash_lines() {
// `#` alone on a line IS a valid ATX (h1 with empty text) per
// CommonMark; escape it to match the renderer's behaviour.
assert_eq!(escape_md_headings("#"), "\\#");
assert_eq!(escape_md_headings("##"), "\\##");
assert_eq!(escape_md_headings("###"), "\\###");
}
#[test]
fn escape_md_headings_preserves_trailing_newline() {
// `split_inclusive('\n')` round-trips a body ending in a
// newline. Important for embedded forge-notify bodies whose
// source already terminates with `\n` — the wrapper's spacing
// otherwise gets eaten.
assert_eq!(escape_md_headings("## h\n"), "\\## h\n");
assert_eq!(escape_md_headings("body\n"), "body\n");
assert_eq!(escape_md_headings("no trailing"), "no trailing");
}
}

22
hive-ag3nt/src/lib.rs Normal file
View file

@ -0,0 +1,22 @@
//! Shared in-container harness code used by both `hive-ag3nt` (agent) and
//! `hive-m1nd` (manager) binaries.
pub mod client;
pub mod events;
pub mod forge_notify;
pub mod login;
pub mod login_session;
pub mod mcp;
pub mod paths;
pub mod plugins;
pub mod serve_common;
pub mod stats;
pub mod turn;
pub mod turn_stats;
pub mod web_ui;
/// Default socket path inside the container — bind-mounted by `hive-c0re`.
pub const DEFAULT_SOCKET: &str = "/run/hive/mcp.sock";
/// Default web UI port — used when `HIVE_PORT` env is unset.
pub const DEFAULT_WEB_PORT: u16 = 8042;

56
hive-ag3nt/src/login.rs Normal file
View file

@ -0,0 +1,56 @@
//! Login-state probe for the bind-mounted `~/.claude/` dir. The dir is
//! provided by hive-c0re and persists across container destroy/recreate so
//! OAuth tokens survive.
//!
//! "Has session" today means "the dir contains at least one regular file."
//! That's a heuristic: a fresh bind-mount starts empty, and `claude auth login`
//! writes credentials into the dir. We may refine later (probe for the
//! specific credentials filename, or run a no-op `claude` call) once the
//! exact layout is locked in.
use std::path::{Path, PathBuf};
/// Returns the Claude credentials directory for this agent, derived from
/// `HIVE_LABEL`. Manager ("hm1nd") uses `/root/.claude`; sub-agents use
/// `/agents/{label}/claude`. Overridable via `HYPERHIVE_CLAUDE_DIR`.
#[must_use]
pub fn default_dir() -> PathBuf {
crate::paths::claude_dir()
}
/// Returns `true` if `dir` exists and contains any regular file. Used at
/// startup to decide whether to enter the turn loop (logged in) or stay in
/// the partial-run "needs login" state.
#[must_use]
pub fn has_session(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|t| t.is_file()) {
return true;
}
}
false
}
/// Login state the harness reports to its web UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginState {
/// `~/.claude/` has credentials; turn loop is running.
Online,
/// `~/.claude/` is empty; harness is up, web UI is bound, turn loop is NOT
/// running. Operator needs to complete login from the web UI.
NeedsLogin,
}
impl LoginState {
#[must_use]
pub fn from_dir(dir: &Path) -> Self {
if has_session(dir) {
Self::Online
} else {
Self::NeedsLogin
}
}
}

View file

@ -0,0 +1,302 @@
//! `claude auth login` driver. Spawns the login command under plain stdio pipes,
//! accumulates stdout+stderr in a shared buffer (so the web UI can show
//! whatever URL/prompt claude emits), and writes paste-back codes from the
//! UI into the child's stdin.
//!
//! No PTY — we're betting `claude` produces a parseable URL on stdout and
//! accepts a code on stdin even when not on a terminal. If it refuses or
//! garbles, we'll redo this module backed by `portable-pty` (see PLAN.md
//! Phase 8).
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, Command};
const DEFAULT_CMD: &str = "claude";
const DEFAULT_ARGS: &[&str] = &["auth", "login"];
#[derive(Default)]
struct State {
/// Concatenated stdout+stderr as it streams from the child.
output: String,
/// First URL-looking substring we saw in the output. Surface this on the
/// web UI as the link the operator should open.
url: Option<String>,
/// Set when the child has exited. The web UI uses this to know whether
/// the operator can still paste a code.
finished: bool,
/// Exit status note (e.g. "exited with code 0", "killed by signal 15"),
/// shown next to a "finished" badge once the child returns.
exit_note: Option<String>,
}
/// A running `claude auth login` subprocess.
pub struct LoginSession {
child: Mutex<Child>,
/// Tokio mutex because we hold the guard across the `write_all().await`
/// in `submit_code`. The other locks are blocking-only and stay on
/// `std::sync::Mutex`.
stdin: tokio::sync::Mutex<Option<ChildStdin>>,
state: Arc<Mutex<State>>,
}
impl LoginSession {
/// Spawn the login command. The exact binary/args are configurable via
/// `HYPERHIVE_LOGIN_CMD` (single string, shell-split into argv); by
/// default we run `claude auth login`. Failing to spawn returns an error
/// before any state is registered.
///
/// # Errors
///
/// Returns an error if spawning the login command fails, or if the child's
/// stdio handles cannot be acquired.
pub fn start() -> Result<Self> {
let (cmd, args) = resolve_command();
tracing::info!(%cmd, ?args, "spawning login session");
let mut child = Command::new(&cmd)
.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// `claude` reads $HOME for the credentials dir; the bind-mount
// puts it at /root/.claude, which is already the default home
// for uid 0 inside the container. Nothing extra to set here.
.kill_on_drop(true)
.spawn()
.with_context(|| format!("spawn `{cmd}`"))?;
let stdin = child.stdin.take().context("child stdin")?;
let stdout = child.stdout.take().context("child stdout")?;
let stderr = child.stderr.take().context("child stderr")?;
let state = Arc::new(Mutex::new(State::default()));
tokio::spawn(pump(BufReader::new(stdout), state.clone(), "stdout"));
tokio::spawn(pump(BufReader::new(stderr), state.clone(), "stderr"));
Ok(Self {
child: Mutex::new(child),
stdin: tokio::sync::Mutex::new(Some(stdin)),
state,
})
}
/// Write `code` (plus a newline) to the child's stdin. Returns an error
/// if the stdin has already been closed (e.g. after the child exited or
/// after a prior submission consumed it).
///
/// # Errors
///
/// Returns an error if the login stdin is already closed, or if writing
/// to or flushing the stdin pipe fails.
pub async fn submit_code(&self, code: &str) -> Result<()> {
let mut guard = self.stdin.lock().await;
let stdin = guard.as_mut().context("login stdin already closed")?;
let line = format!("{}\n", code.trim());
stdin
.write_all(line.as_bytes())
.await
.context("write code to claude stdin")?;
stdin.flush().await.context("flush claude stdin")?;
Ok(())
}
/// Close stdin so claude sees EOF (useful if it's waiting for more input
/// after the code submit).
pub async fn close_stdin(&self) {
let _ = self.stdin.lock().await.take();
}
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn output(&self) -> String {
self.state.lock().unwrap().output.clone()
}
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn url(&self) -> Option<String> {
self.state.lock().unwrap().url.clone()
}
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn finished(&self) -> bool {
self.state.lock().unwrap().finished
}
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn exit_note(&self) -> Option<String> {
self.state.lock().unwrap().exit_note.clone()
}
/// Best-effort: poll the child once and update `finished`/`exit_note`.
/// Called by the web UI on each render so the state stays fresh without
/// running a dedicated reaper task.
///
/// # Panics
///
/// Panics if an internal lock is poisoned.
pub fn poll(&self) {
let mut child = self.child.lock().unwrap();
match child.try_wait() {
Ok(Some(status)) => {
let mut s = self.state.lock().unwrap();
s.finished = true;
s.exit_note = Some(format!("{status}"));
}
Ok(None) => {}
Err(e) => {
let mut s = self.state.lock().unwrap();
s.finished = true;
s.exit_note = Some(format!("try_wait error: {e}"));
}
}
}
/// Kill the child if it's still running. Idempotent.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn kill(&self) {
if let Err(e) = self.child.lock().unwrap().start_kill() {
tracing::warn!(error = ?e, "kill login child");
}
}
}
fn resolve_command() -> (String, Vec<String>) {
if let Ok(raw) = std::env::var("HYPERHIVE_LOGIN_CMD") {
// Whitespace-only split — no quote handling. Fine for "claude auth login"
// style overrides; if we need anything with embedded spaces we'll
// switch to shell-words.
let mut parts = raw.split_whitespace().map(str::to_owned);
if let Some(cmd) = parts.next() {
return (cmd, parts.collect());
}
}
(
DEFAULT_CMD.into(),
DEFAULT_ARGS.iter().map(|s| (*s).to_owned()).collect(),
)
}
async fn pump<R: tokio::io::AsyncRead + Unpin>(
mut reader: BufReader<R>,
state: Arc<Mutex<State>>,
tag: &'static str,
) {
let mut buf = String::new();
loop {
buf.clear();
// read_line breaks on \n; for claude's TUI output that flushes by
// line this is fine. If it ever blasts a single un-newlined blob,
// we'll miss it until EOF (acceptable for the URL surface — claude
// prints the URL on its own line).
match reader.read_line(&mut buf).await {
Ok(0) => {
state.lock().unwrap().finished = true;
break;
}
Ok(_) => {
let mut s = state.lock().unwrap();
if s.url.is_none()
&& let Some(url) = extract_url(&buf)
{
tracing::info!(%url, %tag, "login URL detected");
s.url = Some(url);
}
s.output.push_str(&buf);
}
Err(e) => {
tracing::warn!(error = ?e, %tag, "login pump read error");
let mut s = state.lock().unwrap();
s.finished = true;
s.exit_note = Some(format!("pump {tag} error: {e}"));
break;
}
}
}
}
/// Return the first `https://…` substring on the line, terminating at any
/// ASCII whitespace. Good enough for capturing claude's OAuth link without a
/// regex dependency.
fn extract_url(line: &str) -> Option<String> {
let start = line.find("https://")?;
let tail = &line[start..];
let end = tail
.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(tail.len());
let url = tail[..end].trim_end_matches(['.', ',', ')', ']']);
if url.len() > "https://".len() {
Some(url.to_owned())
} else {
None
}
}
/// Helper used by the web UI to gate "is there a session running right now"
/// without holding both this module's mutex and the `AppState`'s at once.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn drop_if_finished(slot: &Mutex<Option<Arc<LoginSession>>>) {
let mut guard = slot.lock().unwrap();
if let Some(s) = guard.as_ref() {
s.poll();
if s.finished() {
*guard = None;
}
}
}
impl Drop for LoginSession {
fn drop(&mut self) {
// kill_on_drop on the Command also ensures the child dies, but we
// belt-and-brace it in case the runtime detaches.
let _ = self.child.lock().unwrap().start_kill();
}
}
#[cfg(test)]
mod tests {
use super::extract_url;
#[test]
fn picks_first_https() {
let line = " Go to https://claude.ai/oauth/abc?xyz=1 in your browser.\n";
assert_eq!(
extract_url(line).as_deref(),
Some("https://claude.ai/oauth/abc?xyz=1"),
);
}
#[test]
fn trailing_punctuation_stripped() {
let line = "Open https://example.com/abc).\n";
assert_eq!(
extract_url(line).as_deref(),
Some("https://example.com/abc"),
);
}
#[test]
fn no_url() {
assert_eq!(extract_url("nothing here\n"), None);
}
}

1977
hive-ag3nt/src/mcp.rs Normal file

File diff suppressed because it is too large Load diff

32
hive-ag3nt/src/paths.rs Normal file
View file

@ -0,0 +1,32 @@
//! Per-agent path resolution for state and credential directories.
//!
//! All agents (including the manager "hm1nd") use `/agents/{label}/state`.
//! Claude credentials are always at `/root/.claude` for all agents.
//!
//! Both paths can be overridden via env vars (`HYPERHIVE_STATE_DIR`,
//! `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios.
use std::path::PathBuf;
/// Durable state directory for the current agent. Reads `HYPERHIVE_STATE_DIR`
/// first (always set by the meta flake to `/agents/{label}/state`); falls back
/// to the same pattern derived from `HIVE_LABEL` for dev/test environments
/// where the env var may not be set.
#[must_use]
pub fn state_dir() -> PathBuf {
if let Some(p) = std::env::var_os("HYPERHIVE_STATE_DIR") {
return PathBuf::from(p);
}
let label = std::env::var("HIVE_LABEL").unwrap_or_default();
PathBuf::from(format!("/agents/{label}/state"))
}
/// Claude credentials directory for the current agent. Always `/root/.claude`
/// because the `claude` CLI reads `$HOME/.claude` (uid 0 → `/root`), and
/// hive-c0re binds the per-agent credentials dir there for every container.
/// Overridable via `HYPERHIVE_CLAUDE_DIR` for dev / test scenarios.
#[must_use]
pub fn claude_dir() -> PathBuf {
std::env::var_os("HYPERHIVE_CLAUDE_DIR")
.map_or_else(|| PathBuf::from("/root/.claude"), PathBuf::from)
}

184
hive-ag3nt/src/plugins.rs Normal file
View file

@ -0,0 +1,184 @@
//! Boot-time `claude plugin install` driver. Reads the list declared
//! via the `hyperhive.claudePlugins` NixOS option (rendered to
//! `/etc/hyperhive/claude-plugins.json` by the harness module) and
//! shells out `claude plugin install <spec>` for each entry. Runs once
//! per harness boot before the turn loop; `claude plugin install`
//! is expected to be idempotent so reinstalling on each container
//! recreate is fine. Failures log a warning but do not abort boot —
//! we'd rather start without a plugin than refuse to serve.
//!
//! Before installing, all configured marketplaces are updated so that
//! plugin specs resolve against current index data. Marketplace update
//! failures are non-fatal — stale index is better than no install attempt.
use std::path::Path;
use tokio::process::Command;
use crate::client;
const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json";
const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json";
const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.json";
/// Add every marketplace from `/etc/hyperhive/claude-marketplaces.json`
/// via `claude plugin marketplace add <source>`. Idempotent: re-add of
/// an existing marketplace is treated as success (claude prints an
/// "already exists" message and exits non-zero on some versions).
/// Required before any `<plugin>@<marketplace>` install can resolve.
async fn add_marketplaces() {
let Ok(raw) = tokio::fs::read_to_string(MARKETPLACES_PATH).await else {
return;
};
let sources: Vec<String> = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!(path = MARKETPLACES_PATH, error = ?e, "claude-marketplaces spec parse failed; skipping");
return;
}
};
for source in sources {
match Command::new("claude")
.args(["plugin", "marketplace", "add", &source])
.output()
.await
{
Ok(out) if out.status.success() => {
tracing::info!(source = %source, "claude plugin marketplace add ok");
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("already") {
tracing::debug!(source = %source, "marketplace already added");
} else {
tracing::warn!(
source = %source,
status = ?out.status,
stderr = %stderr,
"claude plugin marketplace add failed (non-fatal)",
);
}
}
Err(e) => {
tracing::warn!(source = %source, error = ?e, "claude plugin marketplace add spawn failed");
}
}
}
}
/// Read the `hyperhive.claudePluginsAutoUpdate` flag written by the NixOS
/// module. Defaults to `false` when the file is absent or unparseable.
async fn auto_update_enabled() -> bool {
match tokio::fs::read_to_string(AUTO_UPDATE_PATH).await {
Ok(s) => serde_json::from_str::<bool>(s.trim()).unwrap_or(false),
Err(_) => false,
}
}
/// Update all configured plugin marketplaces. Non-fatal — logs a warning
/// on failure but does not abort the install sequence.
async fn update_marketplaces() {
match Command::new("claude")
.args(["plugin", "marketplace", "update"])
.output()
.await
{
Ok(out) if out.status.success() => {
tracing::info!("claude plugin marketplace update ok");
}
Ok(out) => {
tracing::warn!(
status = ?out.status,
stderr = %String::from_utf8_lossy(&out.stderr),
"claude plugin marketplace update failed (non-fatal)",
);
}
Err(e) => {
tracing::warn!(error = ?e, "claude plugin marketplace update spawn failed (non-fatal)");
}
}
}
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`. When
/// `notify_recipient` is `Some(name)`, install failures also get sent
/// as a hyperhive message to that recipient (typically `"manager"` for
/// sub-agents) so it surfaces in the inbox rather than being buried in
/// journald. The manager itself passes `None` — there's nobody above
/// it to notify.
pub async fn install_configured(socket: &Path, notify_recipient: Option<&str>) {
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
return;
};
let specs: Vec<String> = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
return;
}
};
if specs.is_empty() {
return;
}
add_marketplaces().await;
if auto_update_enabled().await {
update_marketplaces().await;
} else {
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
}
for spec in specs {
match Command::new("claude")
.args(["plugin", "install", &spec])
.output()
.await
{
Ok(out) if out.status.success() => {
tracing::info!(spec = %spec, "claude plugin install ok");
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
tracing::warn!(
spec = %spec,
status = ?out.status,
stderr = %stderr,
"claude plugin install failed",
);
if let Some(to) = notify_recipient {
notify(
socket,
to,
format!(
"claude plugin install failed for `{spec}`:\n{}",
stderr.trim()
),
)
.await;
}
}
Err(e) => {
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
if let Some(to) = notify_recipient {
notify(
socket,
to,
format!("claude plugin install spawn failed for `{spec}`: {e}"),
)
.await;
}
}
}
}
}
/// 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");
}
}

View file

@ -0,0 +1,93 @@
//! Helpers shared between `hive-ag3nt` (agent) and `hive-m1nd` (manager)
//! serve loops. Only pure functions with no wire-type dependency live here;
//! request/response-flavored helpers (`requeue_inflight`, `ack_turn`, etc.)
//! stay in each binary because they use different request enum variants.
use crate::events::Bus;
use crate::mcp::REDELIVERY_HINT;
use crate::turn::TurnOutcome;
use crate::turn_stats::TurnStatRow;
/// Assemble the per-turn wake prompt string. The role/tools/etc. live in the
/// system prompt; this is just the wake signal body. `unread` is the inbox
/// depth after this message was popped. `redelivered` prepends a "may already
/// be handled" banner.
#[must_use]
pub fn format_wake_prompt(from: &str, body: &str, unread: u64, redelivered: bool) -> String {
let banner = if redelivered { REDELIVERY_HINT } else { "" };
let pending = if unread == 0 {
String::new()
} else {
format!(
"\n\n({unread} more message(s) pending in your inbox — call `mcp__hyperhive__recv` \
with `max: {unread}` to drain them all in one round-trip before acting.)"
)
};
format!("{banner}Incoming message from `{from}`:\n---\n{body}\n---{pending}")
}
/// Current time as a Unix timestamp (seconds). Returns 0 on any error.
#[must_use]
pub fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0)
}
/// Assemble a `TurnStatRow` from the harness's per-turn state. Used by both
/// the agent and manager serve loops — the shape is identical, only the
/// post-turn count fetch helpers differ (and those stay in each binary).
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn build_row(
started_at: i64,
ended_at: i64,
duration_ms: i64,
model: String,
wake_from: String,
outcome: &TurnOutcome,
bus: &Bus,
open_threads_count: Option<u64>,
open_reminders_count: Option<u64>,
) -> TurnStatRow {
let cost = bus.last_cost_usage().unwrap_or_default();
let ctx = bus.last_ctx_usage().unwrap_or(cost);
let tool_calls = bus.take_tool_calls();
let tool_call_count: u64 = tool_calls.values().copied().sum();
let tool_call_breakdown_json = if tool_calls.is_empty() {
None
} else {
serde_json::to_string(&tool_calls).ok()
};
let (result_kind, note) = match outcome {
TurnOutcome::Ok => ("ok", None),
TurnOutcome::Compacted => ("compacted", None),
TurnOutcome::PromptTooLong => ("prompt_too_long", None),
TurnOutcome::RateLimited => ("rate_limited", None),
TurnOutcome::AuthFailed => ("auth_failed", None),
TurnOutcome::Failed(e) => ("failed", Some(format!("{e:#}"))),
};
TurnStatRow {
started_at,
ended_at,
duration_ms,
model,
wake_from,
input_tokens: cost.input_tokens,
output_tokens: cost.output_tokens,
cache_read_input_tokens: cost.cache_read_input_tokens,
cache_creation_input_tokens: cost.cache_creation_input_tokens,
last_input_tokens: ctx.input_tokens,
last_output_tokens: ctx.output_tokens,
last_cache_read_input_tokens: ctx.cache_read_input_tokens,
last_cache_creation_input_tokens: ctx.cache_creation_input_tokens,
tool_call_count,
tool_call_breakdown_json,
open_threads_count,
open_reminders_count,
result_kind,
note,
}
}

557
hive-ag3nt/src/stats.rs Normal file
View file

@ -0,0 +1,557 @@
//! Read-side aggregations over the per-agent `turn_stats.sqlite` for
//! the agent's `/stats` web page. Owned by the agent (same process
//! that writes the sink) so per-MCP extensions can register more
//! providers without the host needing to know their schemas.
//!
//! Best-effort: any sqlite error returns an empty snapshot rather than
//! propagating — the stats page is decorative, not authoritative, and
//! a missing db on a brand-new agent shouldn't 500 the route.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rusqlite::{Connection, OpenFlags};
use serde::Serialize;
use hive_sh4re::ReminderStats;
/// Window param accepted by `/api/stats?window=`. Each maps to a
/// total span + the bucket width used to roll up trend series.
#[derive(Debug, Clone, Copy)]
pub enum Window {
Hour,
FourHour,
Day,
ThreeDay,
Week,
Month,
}
impl Window {
#[must_use]
pub fn parse(s: &str) -> Self {
match s {
"1h" => Self::Hour,
"4h" => Self::FourHour,
"3d" => Self::ThreeDay,
"7d" => Self::Week,
"30d" => Self::Month,
_ => Self::Day,
}
}
fn label(self) -> &'static str {
match self {
Self::Hour => "1h",
Self::FourHour => "4h",
Self::Day => "24h",
Self::ThreeDay => "3d",
Self::Week => "7d",
Self::Month => "30d",
}
}
#[must_use]
pub fn span_secs(self) -> i64 {
match self {
Self::Hour => 3600,
Self::FourHour => 4 * 3600,
Self::Day => 24 * 3600,
Self::ThreeDay => 3 * 24 * 3600,
Self::Week => 7 * 24 * 3600,
Self::Month => 30 * 24 * 3600,
}
}
fn bucket_secs(self) -> i64 {
match self {
// 5-min buckets for 1h (12 buckets), 15-min for 4h (16 buckets),
// hourly for 24h + 3d, daily for 7d + 30d.
Self::Hour => 300,
Self::FourHour => 900,
Self::Day | Self::ThreeDay => 3600,
Self::Week | Self::Month => 24 * 3600,
}
}
}
#[derive(Debug, Serialize)]
pub struct Snapshot {
pub window: &'static str,
pub bucket_seconds: i64,
pub now: i64,
pub from: i64,
/// Total turns in the window.
pub turn_count: u64,
/// Time-bucketed trend series, oldest first. Always covers the
/// full window even for empty buckets (so charts paint a stable
/// x-axis instead of skipping gaps).
pub buckets: Vec<Bucket>,
/// Top tools by call count across the window. Capped to 10.
pub tool_breakdown: Vec<KeyCount>,
pub wake_mix: Vec<KeyCount>,
pub result_mix: Vec<KeyCount>,
/// Distinct models seen in the window, sorted. Each bucket's
/// `model_counts` keys into this set; the stats page uses it as
/// the stacked-bar series list (stable order + colours).
pub models: Vec<String>,
/// Across-window p50 / p95 / avg of `duration_ms`. Same numbers
/// as the per-bucket fields but aggregated over the whole window
/// for the headline summary chips.
pub duration_summary: DurationSummary,
/// Reminder activity stats: counts of scheduled, delivered, and
/// pending reminders over the window (fetched from the broker RPC).
/// None if the RPC call failed or hasn't been integrated yet.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reminder_stats: Option<ReminderStats>,
}
#[derive(Debug, Serialize)]
pub struct Bucket {
/// Unix timestamp of the bucket start.
pub ts: i64,
pub turn_count: u64,
pub avg_duration_ms: f64,
pub p50_duration_ms: f64,
pub p95_duration_ms: f64,
/// Sums across the bucket. JS picks how to combine them
/// (input + output for cost, etc.) so we don't bake a policy in.
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_input_tokens: u64,
pub cache_creation_input_tokens: u64,
/// Mean of `last_input_tokens` across the bucket (the context
/// size at turn-end — useful for spotting drift toward compaction).
pub avg_ctx_tokens: f64,
pub max_ctx_tokens: u64,
/// Turn count per model in this bucket. Model choice greatly
/// affects token cost, so this lets the operator line model usage
/// up against the cost series over time.
pub model_counts: HashMap<String, u64>,
}
#[derive(Debug, Serialize)]
pub struct KeyCount {
pub key: String,
pub count: u64,
}
#[derive(Debug, Default, Serialize)]
pub struct DurationSummary {
pub avg_ms: f64,
pub p50_ms: f64,
pub p95_ms: f64,
}
#[must_use]
pub fn snapshot_default(window: Window) -> Snapshot {
let path = default_path();
match snapshot(&path, window) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = ?e, path = %path.display(), "stats: snapshot failed");
empty_snapshot(window)
}
}
}
fn default_path() -> PathBuf {
crate::paths::state_dir().join("hyperhive-turn-stats.sqlite")
}
fn empty_snapshot(window: Window) -> Snapshot {
let now = now_secs();
let from = now - window.span_secs();
let buckets = fill_buckets(from, now, window.bucket_secs(), &HashMap::new());
Snapshot {
window: window.label(),
bucket_seconds: window.bucket_secs(),
now,
from,
turn_count: 0,
buckets,
tool_breakdown: Vec::new(),
wake_mix: Vec::new(),
result_mix: Vec::new(),
models: Vec::new(),
duration_summary: DurationSummary::default(),
reminder_stats: None,
}
}
fn snapshot(path: &Path, window: Window) -> Result<Snapshot> {
// Read-only open so an in-flight writer (the harness's own
// turn_stats sink) never blocks us and we can't corrupt the db
// via a query bug.
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_context(|| format!("open {} read-only", path.display()))?;
let now = now_secs();
let from = now - window.span_secs();
let bucket_secs = window.bucket_secs();
let mut stmt = conn.prepare(
"SELECT started_at, duration_ms,
input_tokens, output_tokens,
cache_read_input_tokens, cache_creation_input_tokens,
last_input_tokens,
tool_call_breakdown_json,
wake_from, result_kind, model
FROM turn_stats
WHERE started_at >= ?1
ORDER BY started_at ASC",
)?;
let rows = stmt.query_map([from], |row| {
Ok(Row {
started_at: row.get(0)?,
duration_ms: row.get::<_, i64>(1)?,
input_tokens: u64_from_i64(row.get::<_, i64>(2)?),
output_tokens: u64_from_i64(row.get::<_, i64>(3)?),
cache_read_input_tokens: u64_from_i64(row.get::<_, i64>(4)?),
cache_creation_input_tokens: u64_from_i64(row.get::<_, i64>(5)?),
last_input_tokens: u64_from_i64(row.get::<_, i64>(6)?),
tool_breakdown_json: row.get::<_, Option<String>>(7)?,
wake_from: row.get::<_, String>(8)?,
result_kind: row.get::<_, String>(9)?,
model: row.get::<_, String>(10)?,
})
})?;
let mut by_bucket: HashMap<i64, BucketAcc> = HashMap::new();
let mut tool_totals: HashMap<String, u64> = HashMap::new();
let mut wake_totals: HashMap<String, u64> = HashMap::new();
let mut result_totals: HashMap<String, u64> = HashMap::new();
let mut model_set: HashSet<String> = HashSet::new();
let mut all_durations: Vec<i64> = Vec::new();
let mut turn_count: u64 = 0;
for r in rows {
let r = r?;
turn_count += 1;
let bucket_ts = (r.started_at / bucket_secs) * bucket_secs;
let acc = by_bucket.entry(bucket_ts).or_default();
acc.turn_count += 1;
acc.durations.push(r.duration_ms.max(0));
acc.input_tokens = acc.input_tokens.saturating_add(r.input_tokens);
acc.output_tokens = acc.output_tokens.saturating_add(r.output_tokens);
acc.cache_read_input_tokens = acc
.cache_read_input_tokens
.saturating_add(r.cache_read_input_tokens);
acc.cache_creation_input_tokens = acc
.cache_creation_input_tokens
.saturating_add(r.cache_creation_input_tokens);
acc.ctx_sum = acc.ctx_sum.saturating_add(r.last_input_tokens);
acc.ctx_max = acc.ctx_max.max(r.last_input_tokens);
*acc.model_counts.entry(r.model.clone()).or_insert(0) += 1;
all_durations.push(r.duration_ms.max(0));
*wake_totals.entry(r.wake_from).or_insert(0) += 1;
*result_totals.entry(r.result_kind).or_insert(0) += 1;
model_set.insert(r.model);
if let Some(json) = r.tool_breakdown_json
&& let Ok(map) = serde_json::from_str::<HashMap<String, u64>>(&json)
{
for (k, v) in map {
*tool_totals.entry(k).or_insert(0) += v;
}
}
}
let buckets = fill_buckets(from, now, bucket_secs, &by_bucket);
let duration_summary = summarize_durations(&mut all_durations);
let mut models: Vec<String> = model_set.into_iter().collect();
models.sort_unstable();
Ok(Snapshot {
window: window.label(),
bucket_seconds: bucket_secs,
now,
from,
turn_count,
buckets,
tool_breakdown: top_n(tool_totals, 10),
wake_mix: top_n(wake_totals, 20),
result_mix: top_n(result_totals, 20),
models,
duration_summary,
reminder_stats: None, // TODO: fetch via ReminderRollup RPC
})
}
struct Row {
started_at: i64,
duration_ms: i64,
input_tokens: u64,
output_tokens: u64,
cache_read_input_tokens: u64,
cache_creation_input_tokens: u64,
last_input_tokens: u64,
tool_breakdown_json: Option<String>,
wake_from: String,
result_kind: String,
model: String,
}
#[derive(Default)]
struct BucketAcc {
turn_count: u64,
durations: Vec<i64>,
input_tokens: u64,
output_tokens: u64,
cache_read_input_tokens: u64,
cache_creation_input_tokens: u64,
ctx_sum: u64,
ctx_max: u64,
model_counts: HashMap<String, u64>,
}
fn fill_buckets(
from: i64,
now: i64,
bucket_secs: i64,
by_bucket: &HashMap<i64, BucketAcc>,
) -> Vec<Bucket> {
let start = (from / bucket_secs) * bucket_secs;
let mut out = Vec::new();
let mut ts = start;
while ts <= now {
let bucket = if let Some(acc) = by_bucket.get(&ts) {
let mut sorted = acc.durations.clone();
sorted.sort_unstable();
let avg = if sorted.is_empty() {
0.0
} else {
#[allow(clippy::cast_precision_loss)]
let sum_f = sorted.iter().sum::<i64>() as f64;
#[allow(clippy::cast_precision_loss)]
let len_f = sorted.len() as f64;
sum_f / len_f
};
let p50 = percentile(&sorted, 50);
let p95 = percentile(&sorted, 95);
let avg_ctx = if acc.turn_count == 0 {
0.0
} else {
#[allow(clippy::cast_precision_loss)]
let sum_f = acc.ctx_sum as f64;
#[allow(clippy::cast_precision_loss)]
let cnt_f = acc.turn_count as f64;
sum_f / cnt_f
};
Bucket {
ts,
turn_count: acc.turn_count,
avg_duration_ms: avg,
p50_duration_ms: p50,
p95_duration_ms: p95,
input_tokens: acc.input_tokens,
output_tokens: acc.output_tokens,
cache_read_input_tokens: acc.cache_read_input_tokens,
cache_creation_input_tokens: acc.cache_creation_input_tokens,
avg_ctx_tokens: avg_ctx,
max_ctx_tokens: acc.ctx_max,
model_counts: acc.model_counts.clone(),
}
} else {
Bucket {
ts,
turn_count: 0,
avg_duration_ms: 0.0,
p50_duration_ms: 0.0,
p95_duration_ms: 0.0,
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
avg_ctx_tokens: 0.0,
max_ctx_tokens: 0,
model_counts: HashMap::new(),
}
};
out.push(bucket);
ts += bucket_secs;
}
out
}
fn summarize_durations(all: &mut [i64]) -> DurationSummary {
if all.is_empty() {
return DurationSummary::default();
}
all.sort_unstable();
#[allow(clippy::cast_precision_loss)]
let sum_f = all.iter().sum::<i64>() as f64;
#[allow(clippy::cast_precision_loss)]
let len_f = all.len() as f64;
DurationSummary {
avg_ms: sum_f / len_f,
p50_ms: percentile(all, 50),
p95_ms: percentile(all, 95),
}
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn percentile(sorted: &[i64], pct: u8) -> f64 {
if sorted.is_empty() {
return 0.0;
}
if sorted.len() == 1 {
return sorted[0] as f64;
}
// Nearest-rank, clamped.
let rank = ((f64::from(pct) / 100.0) * (sorted.len() as f64 - 1.0)).round() as usize;
sorted[rank.min(sorted.len() - 1)] as f64
}
fn top_n(map: HashMap<String, u64>, n: usize) -> Vec<KeyCount> {
let mut v: Vec<KeyCount> = map
.into_iter()
.map(|(key, count)| KeyCount { key, count })
.collect();
v.sort_unstable_by(|a, b| b.count.cmp(&a.count).then_with(|| a.key.cmp(&b.key)));
v.truncate(n);
v
}
fn u64_from_i64(v: i64) -> u64 {
u64::try_from(v).unwrap_or(0)
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::params;
use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
fn tmp_db() -> PathBuf {
let n = SEQ.fetch_add(1, Ordering::SeqCst);
let pid = std::process::id();
std::env::temp_dir().join(format!("hyperhive-stats-test-{pid}-{n}.sqlite"))
}
fn seed_db(path: &Path, rows: &[(i64, i64, &str, &str, &str, &str)]) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
"CREATE TABLE turn_stats (
id INTEGER PRIMARY KEY,
started_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
model TEXT NOT NULL,
wake_from TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
last_input_tokens INTEGER NOT NULL DEFAULT 0,
last_output_tokens INTEGER NOT NULL DEFAULT 0,
last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
tool_call_count INTEGER NOT NULL DEFAULT 0,
tool_call_breakdown_json TEXT,
open_threads_count INTEGER,
open_reminders_count INTEGER,
result_kind TEXT NOT NULL,
note TEXT
);",
)
.unwrap();
for (started, dur, model, wake, result, tools_json) in rows {
conn.execute(
"INSERT INTO turn_stats
(started_at, ended_at, duration_ms, model, wake_from,
last_input_tokens, tool_call_breakdown_json, result_kind)
VALUES (?1, ?2, ?3, ?4, ?5, 1000, ?6, ?7)",
params![started, started + dur / 1000, dur, model, wake, tools_json, result],
)
.unwrap();
}
}
#[test]
fn snapshot_aggregates_rows() {
let db = tmp_db();
let _ = std::fs::remove_file(&db);
let now = now_secs();
seed_db(
&db,
&[
(now - 600, 5_000, "opus", "recv", "ok", r#"{"Read":2,"Bash":1}"#),
(now - 300, 10_000, "opus", "recv", "ok", r#"{"Read":3}"#),
(now - 100, 20_000, "sonnet", "operator", "failed", "{}"),
],
);
let s = snapshot(&db, Window::Day).unwrap();
assert_eq!(s.turn_count, 3);
assert_eq!(s.window, "24h");
assert_eq!(s.bucket_seconds, 3600);
let tool_map: HashMap<_, _> = s
.tool_breakdown
.iter()
.map(|kc| (kc.key.clone(), kc.count))
.collect();
assert_eq!(tool_map.get("Read").copied(), Some(5));
assert_eq!(tool_map.get("Bash").copied(), Some(1));
let wake_map: HashMap<_, _> = s
.wake_mix
.iter()
.map(|kc| (kc.key.clone(), kc.count))
.collect();
assert_eq!(wake_map.get("recv").copied(), Some(2));
assert_eq!(wake_map.get("operator").copied(), Some(1));
let result_map: HashMap<_, _> = s
.result_mix
.iter()
.map(|kc| (kc.key.clone(), kc.count))
.collect();
assert_eq!(result_map.get("ok").copied(), Some(2));
assert_eq!(result_map.get("failed").copied(), Some(1));
// Model breakdown: 2 opus + 1 sonnet, all in the same hour
// bucket given the 24h window.
assert_eq!(s.models, vec!["opus".to_string(), "sonnet".to_string()]);
let mut model_totals: HashMap<String, u64> = HashMap::new();
for b in &s.buckets {
for (k, v) in &b.model_counts {
*model_totals.entry(k.clone()).or_insert(0) += v;
}
}
assert_eq!(model_totals.get("opus").copied(), Some(2));
assert_eq!(model_totals.get("sonnet").copied(), Some(1));
// Durations: [5000, 10000, 20000] → avg ≈ 11666.67, p50 = 10000, p95 ~ 20000
assert!((s.duration_summary.avg_ms - 11_666.666_666_666_666).abs() < 1.0);
assert!((s.duration_summary.p50_ms - 10_000.0).abs() < 1.0);
assert!((s.duration_summary.p95_ms - 20_000.0).abs() < 1.0);
}
#[test]
fn empty_window_still_paints_buckets() {
let db = tmp_db();
let _ = std::fs::remove_file(&db);
seed_db(&db, &[]);
let s = snapshot(&db, Window::Day).unwrap();
assert_eq!(s.turn_count, 0);
// 24h / 1h buckets = ~24-25 buckets covering the window.
assert!(s.buckets.len() >= 24);
assert!(s.buckets.iter().all(|b| b.turn_count == 0));
}
#[test]
fn week_uses_daily_buckets() {
let db = tmp_db();
let _ = std::fs::remove_file(&db);
seed_db(&db, &[]);
let s = snapshot(&db, Window::Week).unwrap();
assert_eq!(s.window, "7d");
assert_eq!(s.bucket_seconds, 86_400);
assert!(s.buckets.len() >= 7);
}
}

753
hive-ag3nt/src/turn.rs Normal file
View file

@ -0,0 +1,753 @@
//! Per-turn claude invocation shared by `hive-ag3nt` and `hive-m1nd`. The
//! two binaries differ only in their MCP `Flavor` (agent surface vs.
//! manager surface) and their wake-prompt wording; the spawn shape,
//! arg-vector, stdin plumbing, and stream-json pumping are identical.
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{Result, bail};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use crate::events::{Bus, LiveEvent};
use crate::login::{self, LoginState};
use crate::mcp;
/// `--settings` JSON applied to every claude invocation. Lives as a
/// properly-formatted file in `prompts/claude-settings.json` so it's easy
/// to read and edit; we ship it via `include_str!`. We turn off claude's
/// in-session auto-compaction and its cross-session auto-memory because
/// hyperhive owns those concerns (`/compact` on overflow, notes
/// persistence under `/state`). Unknown keys are silently ignored by
/// claude-code; if a key gets renamed we'll spot it because the
/// corresponding behavior will start firing mid-turn again.
const CLAUDE_SETTINGS: &str = include_str!("../prompts/claude-settings.json");
/// Regex-ish marker claude-code emits when context overflows. Same string
/// bitburner-agent watches for. Empirically reliable across claude-code
/// versions; if it ever changes, compaction won't fire and we'll see a
/// claude exit with a useful error in the live view.
const PROMPT_TOO_LONG_MARKER: &str = "Prompt is too long";
/// Substrings that indicate the Anthropic API is refusing the request due
/// to a rate limit, per-account usage cap, or exhausted credit balance.
/// Matched against both stdout and stderr; any hit returns
/// `TurnOutcome::RateLimited` so the serve loop can park + retry instead
/// of propagating a hard failure that looks identical to a crash.
const RATE_LIMIT_MARKERS: &[&str] = &[
"rate_limit_error",
"overloaded_error",
"Credit balance is too low",
"Usage limit reached",
"Request rate limit exceeded",
];
/// Substrings that indicate the Anthropic API rejected the request as
/// unauthenticated — the OAuth session in `/root/.claude/` has expired
/// or been revoked. Surfaced as `TurnOutcome::AuthFailed`, which the
/// harness uses to flip the container into `needs_login_idle` so the
/// dashboard's re-auth flow takes over (closes #419). Matched against
/// both stdout JSON `error` events and stderr; the markers come from
/// claude-code's `api_retry` events (`{"error":"authentication_failed",
/// "error_status":401,...}`) and the human-readable
/// "Failed to authenticate. API Error: 401" line claude prints on giveup.
const AUTH_FAIL_MARKERS: &[&str] = &[
"\"error\":\"authentication_failed\"",
"\"error_status\":401",
"Failed to authenticate. API Error: 401",
];
/// How long to sleep after detecting a rate-limit before re-entering the
/// serve loop. Overridable via `HIVE_RATE_LIMIT_SLEEP_SECS`. Default is
/// 5 minutes — enough for most short-lived throttles; the operator can
/// tune down for tight retry scenarios or up if they're hitting sustained
/// capacity limits.
const DEFAULT_RATE_LIMIT_SLEEP_SECS: u64 = 300;
/// Assumed prompt-cache TTL. Claude caches prompt prefixes — ~5 minutes on
/// the API (pay-per-token), ~1 hour on Claude Max (subscription). When the
/// idle gap exceeds this, the cache prefix has likely expired and the next
/// turn re-uploads the full transcript regardless of whether we resume or
/// start fresh. A fresh session with a small context is therefore equally
/// cheap but gives the model a clean slate. Default is 3600s (1h) matching
/// the subscription TTL; API (pay-per-token) users should set
/// `HIVE_CACHE_TTL_SECS=300`. Override via `HIVE_CACHE_TTL_SECS`; set to
/// `0` to disable (always resume).
const DEFAULT_CACHE_TTL_SECS: u64 = 3600;
/// Synthetic wake prompt for the proactive notes-checkpoint turn. Not an
/// inbox message — the harness injects it directly so the agent gets one
/// turn to persist durable state before `/compact` collapses the
/// turn-by-turn history into a summary.
const CHECKPOINT_PROMPT: &str = "[system] Context checkpoint — no inbox message to handle.\n\n\
Your conversation context has grown large and the harness is about to run `/compact`, \
which collapses the detailed turn-by-turn history into a short summary. Anything you \
do not persist now is effectively lost after the next turn.\n\n\
Use THIS turn to flush anything worth keeping into your durable `/state` files: update \
your notes / CLAUDE.md / TODO.md with in-flight task state, decisions made, important \
file paths, and whatever you would need to resume cleanly with only a summary of this \
conversation to go on. Do not start new work or reply to anyone just write your notes \
and end the turn.";
/// The set of files claude reads on every invocation: the MCP server
/// config (`--mcp-config`), static settings (`--settings`), and the
/// pre-rendered role/tools system prompt (`--system-prompt-file`).
/// Materialised once at harness startup; shared between the turn loop
/// and the operator-driven `/compact` path so both invocations look
/// identical to claude (same MCP surface, same allowed tools, same
/// role prompt — only the stdin payload differs).
#[derive(Clone)]
pub struct TurnFiles {
pub mcp_config: PathBuf,
pub settings: PathBuf,
pub system_prompt: PathBuf,
pub flavor: mcp::Flavor,
}
impl TurnFiles {
/// Write all three files into the per-agent runtime dir alongside
/// `socket`. Idempotent — overwrites whatever was there.
///
/// # Errors
///
/// Returns an error if any of the config files cannot be written to disk.
pub async fn prepare(socket: &Path, label: &str, flavor: mcp::Flavor) -> Result<Self> {
Ok(Self {
mcp_config: write_mcp_config(socket).await?,
settings: write_settings(socket).await?,
system_prompt: write_system_prompt(socket, label, flavor).await?,
flavor,
})
}
}
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
/// `socket` is the hyperhive per-container socket (forwarded to the child
/// as `--socket <path>`); `binary_subcommand` is e.g. `"mcp"` for sub-agents
/// or `"mcp"` for the manager (both binaries name their MCP subcommand the
/// same — the differentiator is which binary `/proc/self/exe` resolves to).
///
/// # Errors
///
/// Returns an error if the config file cannot be written.
pub async fn write_mcp_config(socket: &Path) -> Result<PathBuf> {
let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive"));
tokio::fs::create_dir_all(parent).await.ok();
let path = parent.join("claude-mcp-config.json");
let exe = std::env::current_exe()
.ok()
.map_or_else(|| "hive-ag3nt".into(), |p| p.display().to_string());
let body = mcp::render_claude_config(&exe, socket);
tokio::fs::write(&path, body).await?;
tracing::info!(path = %path.display(), "wrote claude MCP config");
Ok(path)
}
/// Drop the static `--settings` JSON next to the MCP config so we can
/// pass a path (`--settings <file>`) instead of an ever-growing inline
/// blob — the CLI argv has a finite length budget.
///
/// # Errors
///
/// Returns an error if the settings file cannot be written.
pub async fn write_settings(socket: &Path) -> Result<PathBuf> {
let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive"));
tokio::fs::create_dir_all(parent).await.ok();
let path = parent.join("claude-settings.json");
tokio::fs::write(&path, CLAUDE_SETTINGS).await?;
tracing::info!(path = %path.display(), "wrote claude settings");
Ok(path)
}
/// Write the agent's / manager's static system prompt to a file next to
/// the MCP config and return the path. Passed to claude via
/// `--system-prompt-file`, replacing claude's default system prompt with
/// the role + tools instructions. Per-turn prompts become much smaller
/// (just the wake message body).
///
/// # Errors
///
/// Returns an error if the system prompt file cannot be written.
pub async fn write_system_prompt(
socket: &Path,
label: &str,
flavor: mcp::Flavor,
) -> Result<PathBuf> {
let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive"));
tokio::fs::create_dir_all(parent).await.ok();
let template = match flavor {
mcp::Flavor::Agent => include_str!("../prompts/agent.md"),
mcp::Flavor::Manager => include_str!("../prompts/manager.md"),
};
let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned());
let body = template
.replace("{label}", label)
.replace("{operator_pronouns}", &pronouns);
let path = parent.join("claude-system-prompt.md");
tokio::fs::write(&path, body).await?;
tracing::info!(path = %path.display(), "wrote claude system prompt");
Ok(path)
}
/// One claude turn's outcome. The harness uses this to decide whether to
/// transparently kick off a compaction and retry.
#[derive(Debug)]
pub enum TurnOutcome {
Ok,
/// Turn completed and proactive context-size compaction fired afterwards.
/// Treated like `Ok` for ack and failure-notification purposes; recorded
/// as `result_kind = "compacted"` in turn stats so the stats page can
/// distinguish normal turns from turns that triggered a compaction.
Compacted,
/// claude saw "Prompt is too long" — the session needs compacting.
/// Run `compact_session()` then retry the same wake-up prompt.
PromptTooLong,
/// The Anthropic API refused the request due to a rate limit, per-account
/// usage cap, or exhausted credit balance. The serve loop should park for
/// `rate_limit_sleep_secs()` and retry — NOT bubble up as a crash.
RateLimited,
/// The Anthropic API rejected the request with 401 (OAuth session
/// expired or revoked). The serve loop should flip the container
/// into `needs_login_idle` and stop driving turns until the
/// operator re-auths via the per-agent web UI (closes #419).
AuthFailed,
Failed(anyhow::Error),
}
/// How long to sleep after a rate-limit before re-entering the serve loop.
/// Reads `HIVE_RATE_LIMIT_SLEEP_SECS` if set to a valid positive integer.
#[must_use]
pub fn rate_limit_sleep_secs() -> u64 {
std::env::var("HIVE_RATE_LIMIT_SLEEP_SECS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|&v| v > 0)
.unwrap_or(DEFAULT_RATE_LIMIT_SLEEP_SECS)
}
/// Resolve the effective context-window size for watermark calculations.
/// Priority order (first wins):
/// 1. API-reported window from the last `result` event's `modelUsage.*.contextWindow`.
/// 2. `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars (Nix-configured per-model defaults).
/// 3. Hard fallback: 200 000.
///
/// The API-reported window is the authoritative per-inference active
/// context limit. It reflects what the model actually enforces — which
/// for models with large prompt caches (e.g. 1 M total cache) may be
/// significantly smaller than the cache capacity (e.g. 200 k active window
/// for `claude-sonnet-4-6`).
fn effective_context_window(bus: &Bus) -> u64 {
bus.api_context_window()
.unwrap_or_else(|| crate::events::context_window_tokens(&bus.model()))
}
/// Resolve the auto-reset watermark. Priority order:
/// 1. `HIVE_AUTO_RESET_WATERMARK_TOKENS` env var (explicit override).
/// 2. 50% of `effective_context_window(bus)`.
/// `0` disables auto-reset entirely.
fn auto_reset_watermark_tokens(bus: &Bus) -> u64 {
if let Some(v) = std::env::var("HIVE_AUTO_RESET_WATERMARK_TOKENS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
{
return v;
}
effective_context_window(bus) / 2
}
/// Resolve the assumed cache TTL: `HIVE_CACHE_TTL_SECS` if set, else
/// `DEFAULT_CACHE_TTL_SECS`.
fn cache_ttl_secs() -> u64 {
std::env::var("HIVE_CACHE_TTL_SECS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|&v| v > 0)
.unwrap_or(DEFAULT_CACHE_TTL_SECS)
}
/// Resolve the proactive-compaction watermark. Priority order:
/// 1. `HIVE_COMPACT_WATERMARK_TOKENS` env var (explicit override).
/// 2. 75% of `effective_context_window(bus)`.
/// `0` disables proactive compaction (reactive path still applies).
fn compact_watermark_tokens(bus: &Bus) -> u64 {
if let Some(v) = std::env::var("HIVE_COMPACT_WATERMARK_TOKENS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
{
return v;
}
effective_context_window(bus) * 3 / 4
}
/// Drive one turn end-to-end. Three paths layer on top of the raw `run_turn`:
///
/// - **Auto-reset (pre-turn)** — context is large AND the prompt cache has
/// gone cold (idle gap ≥ cache TTL). Resuming would re-upload the full
/// transcript uncached at the same cost as a fresh start. The harness runs
/// one checkpoint turn (agent flushes state), then arms a one-shot
/// `request_new_session` so the actual turn starts fresh.
/// - **Reactive (on overflow)** — `run_turn` returns `PromptTooLong`: the
/// session is already past the context window and *no* turn can run on it,
/// so we compact immediately and retry the same wake-up prompt once. No
/// notes-checkpoint turn is possible here — the detail is gone.
/// - **Proactive (post-turn)** — the turn finished cleanly but its context
/// size has crept past the watermark: while the session is still healthy we
/// give the agent one dedicated turn to checkpoint its `/state` notes, then
/// compact. This keeps a later turn from hitting the reactive path (where
/// there is no chance to save anything first).
///
/// Both the sub-agent and manager loops call this.
pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome {
maybe_auto_reset(bus);
let outcome = match run_turn(prompt, files, bus).await {
TurnOutcome::PromptTooLong => {
// Compact has its own three-flag surface (it's the same claude
// binary). Treat any non-Ok outcome the same as if `run_turn`
// had returned it — the serve loop already knows what to do
// with each variant, no point re-wrapping. PromptTooLong from
// /compact itself would be absurd recursion; bubble it up as
// a normal failure path.
match compact_session(files, bus).await {
TurnOutcome::Ok | TurnOutcome::Compacted => {
run_turn(prompt, files, bus).await
}
other => return other,
}
}
// Rate-limited: no point retrying immediately — bubble up so the
// serve loop can park + emit status before the next attempt.
TurnOutcome::RateLimited => return TurnOutcome::RateLimited,
other => other,
};
// Proactive: a turn just completed on a still-healthy session. If its
// context crossed the watermark, checkpoint + compact before a later
// turn overflows into the reactive path. Best-effort — never changes
// the outcome of the turn that already succeeded, but records it as
// `Compacted` so turn stats can distinguish it from a plain `Ok`.
if matches!(outcome, TurnOutcome::Ok)
&& maybe_checkpoint_and_compact(files, bus).await {
return TurnOutcome::Compacted;
}
outcome
}
/// Proactive post-turn compaction. If the last inference's context size
/// has crossed the watermark, run one notes-checkpoint turn so the agent
/// can persist durable state, then `/compact`. Best-effort: a failed
/// checkpoint or compaction is logged + surfaced as a Note but never
/// fails the turn that already succeeded. Returns `true` if compaction
/// was attempted (watermark crossed), `false` if skipped.
async fn maybe_checkpoint_and_compact(files: &TurnFiles, bus: &Bus) -> bool {
let watermark = compact_watermark_tokens(bus);
if watermark == 0 {
return false; // proactive compaction disabled
}
let Some(used) = bus.last_ctx_usage().map(|u| u.context_tokens()) else {
return false; // no usage reading yet — nothing to compare against
};
if used < watermark {
return false;
}
bus.emit(LiveEvent::Note {
text: format!(
"context at {used} tokens (watermark {watermark}) — running a \
notes-checkpoint turn before /compact"
),
});
// Give the agent one turn to flush durable state into /state. If the
// session is somehow already too far gone to run even this, fall
// through to compaction anyway — the checkpoint is best-effort.
match run_turn(CHECKPOINT_PROMPT, files, bus).await {
TurnOutcome::Ok | TurnOutcome::Compacted => {}
TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note {
text: "checkpoint turn overflowed the window — compacting without it".into(),
}),
TurnOutcome::RateLimited => bus.emit(LiveEvent::Note {
text: "checkpoint turn was rate-limited — compacting anyway".into(),
}),
TurnOutcome::AuthFailed => bus.emit(LiveEvent::Note {
text: "checkpoint turn hit 401 — skipping compaction, parking for re-login".into(),
}),
TurnOutcome::Failed(e) => bus.emit(LiveEvent::Note {
text: format!("checkpoint turn failed ({e:#}) — compacting anyway"),
}),
}
// Best-effort: never changes the outcome of the turn that already
// succeeded. Mirror the checkpoint-turn handling above — emit a Note
// for each failure mode and move on; the next real turn will surface
// the underlying issue (rate-limit / 401 / etc.) through the normal
// path anyway.
match compact_session(files, bus).await {
TurnOutcome::Ok | TurnOutcome::Compacted => {}
TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note {
text: "/compact unexpectedly returned PromptTooLong — next turn will retry".into(),
}),
TurnOutcome::RateLimited => bus.emit(LiveEvent::Note {
text: "/compact was rate-limited — next turn will park + retry".into(),
}),
TurnOutcome::AuthFailed => bus.emit(LiveEvent::Note {
text: "/compact hit 401 — next turn will trigger the re-login flow".into(),
}),
TurnOutcome::Failed(e) => {
tracing::warn!(error = %format!("{e:#}"), "post-checkpoint compact failed");
bus.emit(LiveEvent::Note {
text: format!("/compact after checkpoint failed: {e:#}"),
});
}
}
true
}
/// Pre-turn auto-reset check. If context is large AND the prompt cache has
/// gone cold (idle time >= cache TTL), arm `request_new_session` so the
/// next wake-up turn starts fresh. No preceding checkpoint turn — running
/// any turn before the reset would re-upload and re-warm the cache, which
/// defeats the cost-optimisation purpose entirely.
fn maybe_auto_reset(bus: &Bus) {
let watermark = auto_reset_watermark_tokens(bus);
if watermark == 0 {
return; // auto-reset disabled
}
let Some(ctx_tokens) = bus.last_ctx_usage().map(|u| u.context_tokens()) else {
return; // no usage reading yet — first turn, nothing to reset
};
if ctx_tokens < watermark {
return;
}
let last_ended = bus.last_turn_ended_unix();
if last_ended == 0 {
return; // no completed turn yet
}
// Compute idle seconds using the same clock as now_unix (unix epoch, i64).
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let idle_secs = now.saturating_sub(u64::try_from(last_ended).unwrap_or(0));
let ttl = cache_ttl_secs();
if idle_secs < ttl {
return;
}
bus.emit(LiveEvent::Note {
text: format!(
"context {ctx_tokens} tokens, idle {idle_secs}s >= cache TTL {ttl}s \
dropping session (cache cold, fresh start is equally cheap)"
),
});
bus.request_new_session();
}
/// Emit the per-turn `TurnEnd` event + log line. Single owner so the
/// agent and manager loops agree on outcome semantics.
pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
match outcome {
TurnOutcome::Ok | TurnOutcome::Compacted | TurnOutcome::PromptTooLong => {
bus.emit(LiveEvent::TurnEnd {
ok: true,
note: None,
});
tracing::info!("turn finished");
}
TurnOutcome::RateLimited => {
bus.emit(LiveEvent::TurnEnd {
ok: false,
note: Some("rate limited — parking until quota resets".into()),
});
tracing::warn!("turn rate-limited");
}
TurnOutcome::AuthFailed => {
bus.emit(LiveEvent::TurnEnd {
ok: false,
note: Some("authentication failed (401) — waiting for re-login".into()),
});
tracing::warn!("turn auth-failed (401)");
}
TurnOutcome::Failed(e) => {
let note = format!("{e:#}");
bus.emit(LiveEvent::TurnEnd {
ok: false,
note: Some(note.clone()),
});
tracing::warn!(error = %note, "turn failed");
}
}
}
/// Block until the bound `~/.claude/` dir contains a session, polling
/// `claude_dir` on a `poll_ms` interval (min 2s). Flips `state` to
/// `Online` when login lands; caller resumes its serve loop.
///
/// # Panics
///
/// Panics if the internal login-state lock is poisoned.
pub async fn wait_for_login(
claude_dir: &Path,
state: Arc<Mutex<LoginState>>,
bus: &Bus,
poll_ms: u64,
) {
tracing::warn!(
claude_dir = %claude_dir.display(),
"no claude session — staying in partial-run mode (web UI only)"
);
let probe = Duration::from_millis(poll_ms.max(2000));
loop {
tokio::time::sleep(probe).await;
if login::has_session(claude_dir) {
tracing::info!("claude session detected — entering turn loop");
*state.lock().unwrap() = LoginState::Online;
bus.emit_status("online");
return;
}
}
}
/// Spawn `claude` for one turn and pump `stream-json` stdout into the
/// live event bus. Prompt goes over stdin (variadic
/// `--allowedTools`/`--tools` would otherwise eat a trailing positional
/// prompt). The session is persistent across turns via `--continue` and
/// claude's in-session auto-compact is disabled via `--settings` so it
/// doesn't stall mid-turn — hyperhive owns compaction.
pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome {
match run_claude(prompt, files, bus).await {
Ok((true, _, _)) => TurnOutcome::PromptTooLong,
Ok((_, true, _)) => TurnOutcome::RateLimited,
Ok((_, _, true)) => TurnOutcome::AuthFailed,
Ok(_) => TurnOutcome::Ok,
Err(e) => TurnOutcome::Failed(e),
}
}
/// Run claude's built-in `/compact` slash command on the persistent
/// session. Takes the *same* params as `run_turn` because compact
/// re-initialises claude with the full session shape — same MCP
/// surface, same system prompt, same allowed-tools — so the post-
/// compact state matches a normal turn's. Only the prompt over stdin
/// differs (`/compact` vs the wake-up payload).
///
/// Returns the same `TurnOutcome` shape as `run_turn` so callers can
/// react identically to all three failure flags (`prompt_too_long`,
/// `rate_limited`, `auth_failed`). The reactive caller bubbles any
/// non-Ok outcome up so the serve loop's normal handling (park +
/// retry on rate-limit, flip to `needs_login` on 401, etc.) kicks in;
/// the proactive post-checkpoint caller stays best-effort and only
/// emits a Note for each failure mode.
pub async fn compact_session(files: &TurnFiles, bus: &Bus) -> TurnOutcome {
bus.emit(LiveEvent::Note {
text: "context overflow — running /compact on the persistent session".into(),
});
let outcome = match run_claude("/compact", files, bus).await {
Ok((true, _, _)) => TurnOutcome::PromptTooLong,
Ok((_, true, _)) => TurnOutcome::RateLimited,
Ok((_, _, true)) => TurnOutcome::AuthFailed,
Ok(_) => TurnOutcome::Ok,
Err(e) => TurnOutcome::Failed(e),
};
match &outcome {
TurnOutcome::Ok | TurnOutcome::Compacted => bus.emit(LiveEvent::Note {
text: "/compact done".into(),
}),
TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note {
text: "/compact reported PromptTooLong — bubbling up".into(),
}),
TurnOutcome::RateLimited => bus.emit(LiveEvent::Note {
text: "/compact was rate-limited — bubbling up".into(),
}),
TurnOutcome::AuthFailed => bus.emit(LiveEvent::Note {
text: "/compact hit 401 — bubbling up for re-login flow".into(),
}),
TurnOutcome::Failed(e) => bus.emit(LiveEvent::Note {
text: format!("/compact failed: {e:#}"),
}),
}
outcome
}
#[allow(clippy::too_many_lines)]
async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, bool, bool)> {
// Keep the last STDERR_TAIL_LINES of stderr so a non-zero exit can
// include real context in the bail message (and downstream in the
// failure notification to the manager) instead of just "exit 1".
const STDERR_TAIL_LINES: usize = 20;
let model = bus.model();
let resume = !bus.take_skip_continue();
if !resume {
bus.emit(LiveEvent::Note {
text: "fresh session (--continue suppressed for this turn)".into(),
});
}
let mut cmd = Command::new("claude");
// Spawn inside the agent's state dir so relative paths in tool calls
// (Read foo.md, Bash ls, Write notes.md) land in the durable dir
// instead of wherever the harness systemd unit started. Falls back
// silently if the dir is missing (dev / test without the bind mount).
let state_dir = crate::paths::state_dir();
if state_dir.is_dir() {
cmd.current_dir(&state_dir);
}
cmd.arg("--print")
.arg("--verbose")
.arg("--output-format")
.arg("stream-json")
.arg("--model")
.arg(&model)
.arg("--settings")
.arg(&files.settings);
if resume {
cmd.arg("--continue");
}
cmd.arg("--system-prompt-file").arg(&files.system_prompt);
cmd.arg("--mcp-config")
.arg(&files.mcp_config)
.arg("--strict-mcp-config")
.arg("--tools")
.arg(mcp::builtin_tools_arg())
.arg("--allowedTools")
.arg(mcp::allowed_tools_arg(files.flavor));
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(prompt.as_bytes()).await?;
stdin.shutdown().await.ok();
drop(stdin);
}
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let prompt_too_long = Arc::new(AtomicBool::new(false));
let rate_limited = Arc::new(AtomicBool::new(false));
let auth_failed = Arc::new(AtomicBool::new(false));
let flag_out = prompt_too_long.clone();
let flag_err = prompt_too_long.clone();
let rate_out = rate_limited.clone();
let rate_err = rate_limited.clone();
let auth_out = auth_failed.clone();
let auth_err = auth_failed.clone();
let bus_out = bus.clone();
let bus_err = bus.clone();
let pump_stdout = tokio::spawn(async move {
let mut reader = BufReader::new(stdout).lines();
// Track usage as the turn unfolds. `last_inference` overwrites on
// every assistant event so at result-time it holds the most recent
// model call's usage — the actual context size. The `result` event
// carries the cumulative-across-the-turn usage (cost signal). Both
// get handed to `record_turn_usage` together so a single SSE
// event updates both badges.
let mut last_inference: Option<crate::events::TokenUsage> = None;
while let Ok(Some(line)) = reader.next_line().await {
if line.contains(PROMPT_TOO_LONG_MARKER) {
flag_out.store(true, Ordering::Relaxed);
}
// Auth-fail check happens on the raw line first so we
// catch both the `api_retry` JSON events (which can land
// before they're fully parseable) and any stderr-shaped
// text that snuck onto stdout.
if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) {
auth_out.store(true, Ordering::Relaxed);
}
match serde_json::from_str::<serde_json::Value>(&line) {
Ok(v) => {
// Rate-limit detection: only fire on JSON `error` events,
// not on arbitrary text content. An agent discussing a past
// rate limit in its response would otherwise trigger a false
// positive (the full conversation flows through stdout as
// stream-json, so any text the model outputs is visible here).
if v.get("type").and_then(|t| t.as_str()) == Some("error") {
let raw = v.to_string();
if RATE_LIMIT_MARKERS.iter().any(|m| raw.contains(m)) {
rate_out.store(true, Ordering::Relaxed);
}
}
if let Some(u) = crate::events::TokenUsage::from_assistant_event(&v) {
last_inference = Some(u);
}
if let Some(cost) = crate::events::TokenUsage::from_stream_event(&v) {
// Fallback to `cost` if the turn somehow produced
// a result without any assistant event — keeps the
// ctx badge from going stale on a degenerate turn.
let ctx = last_inference.unwrap_or(cost);
bus_out.record_turn_usage(ctx, cost);
}
// Seed the API-reported context-window from the result
// event's `modelUsage.*.contextWindow` field. This is
// the authoritative per-inference active window used for
// compaction watermarks — it reflects what the model
// actually enforces, which may differ from the Nix
// config (e.g. 200k active window on a 1M cache model).
if let Some(w) =
crate::events::TokenUsage::context_window_from_result_event(&v)
{
bus_out.set_api_context_window(w);
}
bus_out.observe_stream(&v);
bus_out.emit(LiveEvent::Stream(v));
}
Err(_) => {
// Non-JSON stdout: raw text check is fine here since these
// are claude CLI messages, not conversation content.
if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) {
rate_out.store(true, Ordering::Relaxed);
}
bus_out.emit(LiveEvent::Note {
text: format!("(non-json) {line}"),
});
}
}
}
});
let stderr_tail: Arc<Mutex<VecDeque<String>>> =
Arc::new(Mutex::new(VecDeque::with_capacity(STDERR_TAIL_LINES)));
let tail_clone = stderr_tail.clone();
let pump_stderr = tokio::spawn(async move {
let mut reader = BufReader::new(stderr).lines();
while let Ok(Some(line)) = reader.next_line().await {
if line.contains(PROMPT_TOO_LONG_MARKER) {
flag_err.store(true, Ordering::Relaxed);
}
if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) {
rate_err.store(true, Ordering::Relaxed);
}
if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) {
auth_err.store(true, Ordering::Relaxed);
}
// Mirror to journald so post-mortems work without the web UI
// or the events sqlite. The bus event is what the dashboard
// renders; the tracing line is what `journalctl -M <c> -b`
// surfaces when claude exits non-zero.
tracing::warn!(line = %line, "claude stderr");
bus_err.emit(LiveEvent::Note {
text: format!("stderr: {line}"),
});
let mut t = tail_clone.lock().unwrap();
if t.len() >= STDERR_TAIL_LINES {
t.pop_front();
}
t.push_back(line);
}
});
let status = child.wait().await?;
let _ = pump_stdout.await;
let _ = pump_stderr.await;
let too_long = prompt_too_long.load(Ordering::Relaxed);
let is_rate_limited = rate_limited.load(Ordering::Relaxed);
let is_auth_failed = auth_failed.load(Ordering::Relaxed);
if !status.success() && !too_long && !is_rate_limited && !is_auth_failed {
let tail = stderr_tail.lock().unwrap();
if tail.is_empty() {
bail!("claude exited {status} (no stderr)");
}
let tail_str = tail.iter().cloned().collect::<Vec<_>>().join("\n");
bail!("claude exited {status}\nstderr tail:\n{tail_str}");
}
Ok((too_long, is_rate_limited, is_auth_failed))
}

View file

@ -0,0 +1,266 @@
//! Per-turn analytics sink. One sqlite row per claude turn captures:
//! identity (`model`, `wake_from`, `result_kind`), timing (`started_at`,
//! `ended_at`, `duration_ms`), cost (token counts), behaviour (tool-call
//! count + per-tool breakdown), and post-turn snapshot metrics
//! (`open_threads_count`, `open_reminders_count`).
//!
//! Lives next to `hyperhive-events.sqlite` in the agent's state dir
//! so the host-side state vacuum sweep can reach both. Schema is
//! intentionally append-only — every column has a default so future
//! additions don't break old readers; new columns land via
//! `ALTER TABLE ... ADD COLUMN ... DEFAULT ...` in the migration
//! block.
//!
//! Writes are best-effort: a failed insert logs a warning and lets
//! the turn loop continue. The next turn either succeeds or the
//! operator sees the journal trail.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use anyhow::{Context, Result};
use rusqlite::{Connection, params};
/// SQL bootstrap. CREATE TABLE IF NOT EXISTS so first-boot agents
/// and existing ones converge on the same shape. The base table is
/// fresh-install only; additive migrations land via `MIGRATIONS`
/// below as try-and-ignore ALTERs so existing dbs catch up.
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS turn_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
model TEXT NOT NULL,
wake_from TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
last_input_tokens INTEGER NOT NULL DEFAULT 0,
last_output_tokens INTEGER NOT NULL DEFAULT 0,
last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
tool_call_count INTEGER NOT NULL DEFAULT 0,
tool_call_breakdown_json TEXT,
open_threads_count INTEGER,
open_reminders_count INTEGER,
result_kind TEXT NOT NULL,
note TEXT
);
CREATE INDEX IF NOT EXISTS idx_turn_stats_started
ON turn_stats (started_at DESC);
";
/// Additive column migrations. Each runs unconditionally and ignores
/// `duplicate column name` errors — sqlite < 3.35 lacks
/// `ADD COLUMN IF NOT EXISTS`, so try-and-ignore is the portable path.
/// New columns MUST carry a default so existing rows decode.
const MIGRATIONS: &[&str] = &[
"ALTER TABLE turn_stats ADD COLUMN last_input_tokens INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE turn_stats ADD COLUMN last_output_tokens INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE turn_stats ADD COLUMN last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE turn_stats ADD COLUMN last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0",
];
/// One row to be inserted. `Option`-wrapped fields default to NULL
/// when the harness couldn't gather them (e.g. socket roundtrip for
/// `open_threads` failed) so a partial row beats no row.
#[derive(Debug, Clone)]
pub struct TurnStatRow {
pub started_at: i64,
pub ended_at: i64,
pub duration_ms: i64,
pub model: String,
pub wake_from: String,
/// Cumulative across every inference in the turn (cost signal).
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_input_tokens: u64,
pub cache_creation_input_tokens: u64,
/// Last inference's usage — the actual context size at turn end.
pub last_input_tokens: u64,
pub last_output_tokens: u64,
pub last_cache_read_input_tokens: u64,
pub last_cache_creation_input_tokens: u64,
pub tool_call_count: u64,
/// Per-tool breakdown as JSON: `{"Read":12,"Bash":3,...}`. None
/// when no tools were called (saves a sqlite write of `"{}"`).
pub tool_call_breakdown_json: Option<String>,
pub open_threads_count: Option<u64>,
pub open_reminders_count: Option<u64>,
/// `"ok" | "failed" | "prompt_too_long"`.
pub result_kind: &'static str,
pub note: Option<String>,
}
/// Thin sqlite wrapper. Cloning is cheap (Arc-shared connection).
#[derive(Clone)]
pub struct TurnStats {
inner: std::sync::Arc<Mutex<Connection>>,
}
impl TurnStats {
/// Open the per-agent stats db, creating the file + schema if
/// missing. Returns `None` when the db can't be opened (read-only
/// fs in tests, missing state dir) — the harness logs and
/// continues without a sink rather than failing the turn loop.
#[must_use]
pub fn open_default() -> Option<Self> {
let path = default_path();
match Self::open(&path) {
Ok(s) => Some(s),
Err(e) => {
tracing::warn!(
error = ?e,
path = %path.display(),
"turn_stats: open failed; per-turn analytics disabled"
);
None
}
}
}
fn open(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let conn = Connection::open(path)
.with_context(|| format!("open turn_stats db {}", path.display()))?;
conn.execute_batch(SCHEMA)
.context("apply turn_stats schema")?;
for stmt in MIGRATIONS {
// Ignore "duplicate column name" — the migration already ran.
// Any other error is logged but doesn't fail open() because the
// base schema works and we'd rather keep the harness alive than
// crash on an upgrade hiccup.
if let Err(e) = conn.execute(stmt, []) {
let msg = e.to_string();
if !msg.contains("duplicate column name") {
tracing::warn!(error = %msg, stmt, "turn_stats migration failed");
}
}
}
Ok(Self {
inner: std::sync::Arc::new(Mutex::new(conn)),
})
}
/// Insert a row. Best-effort — logs + swallows errors so a sqlite
/// hiccup (locked db, full disk) doesn't crash the harness.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn record(&self, row: &TurnStatRow) {
let conn = self.inner.lock().unwrap();
let res = conn.execute(
"INSERT INTO turn_stats (
started_at, ended_at, duration_ms, model, wake_from,
input_tokens, output_tokens,
cache_read_input_tokens, cache_creation_input_tokens,
last_input_tokens, last_output_tokens,
last_cache_read_input_tokens, last_cache_creation_input_tokens,
tool_call_count, tool_call_breakdown_json,
open_threads_count, open_reminders_count,
result_kind, note
) VALUES (
?1, ?2, ?3, ?4, ?5,
?6, ?7,
?8, ?9,
?10, ?11,
?12, ?13,
?14, ?15,
?16, ?17,
?18, ?19
)",
params![
row.started_at,
row.ended_at,
row.duration_ms,
row.model,
row.wake_from,
i64::try_from(row.input_tokens).unwrap_or(i64::MAX),
i64::try_from(row.output_tokens).unwrap_or(i64::MAX),
i64::try_from(row.cache_read_input_tokens).unwrap_or(i64::MAX),
i64::try_from(row.cache_creation_input_tokens).unwrap_or(i64::MAX),
i64::try_from(row.last_input_tokens).unwrap_or(i64::MAX),
i64::try_from(row.last_output_tokens).unwrap_or(i64::MAX),
i64::try_from(row.last_cache_read_input_tokens).unwrap_or(i64::MAX),
i64::try_from(row.last_cache_creation_input_tokens).unwrap_or(i64::MAX),
i64::try_from(row.tool_call_count).unwrap_or(i64::MAX),
row.tool_call_breakdown_json,
row.open_threads_count
.map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
row.open_reminders_count
.map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
row.result_kind,
row.note,
],
);
if let Err(e) = res {
tracing::warn!(error = ?e, "turn_stats: insert failed");
}
}
/// Token counts from the most recently inserted row, if any.
/// Returns `(ctx, cost)` — both backfill `Bus` on startup so the
/// per-agent web UI's ctx + cost badges paint with real numbers on
/// cold load instead of waiting for the next `TokenUsageChanged`
/// SSE event. Best-effort: any sqlite error returns `(None, None)`.
///
/// Pre-migration rows (before the `last_*_tokens` columns existed)
/// have last-inference zeros — those rows yield `ctx = None` so the
/// badge stays empty until the next real turn rather than showing a
/// misleading 0.
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn last_usage(
&self,
) -> (
Option<crate::events::TokenUsage>,
Option<crate::events::TokenUsage>,
) {
let conn = self.inner.lock().unwrap();
conn.query_row(
"SELECT input_tokens, output_tokens,
cache_read_input_tokens, cache_creation_input_tokens,
last_input_tokens, last_output_tokens,
last_cache_read_input_tokens, last_cache_creation_input_tokens
FROM turn_stats
ORDER BY started_at DESC
LIMIT 1",
[],
|row| {
let g = |i: usize| -> rusqlite::Result<u64> {
Ok(u64::try_from(row.get::<_, i64>(i)?).unwrap_or(0))
};
let cost = crate::events::TokenUsage {
input_tokens: g(0)?,
output_tokens: g(1)?,
cache_read_input_tokens: g(2)?,
cache_creation_input_tokens: g(3)?,
};
let last = crate::events::TokenUsage {
input_tokens: g(4)?,
output_tokens: g(5)?,
cache_read_input_tokens: g(6)?,
cache_creation_input_tokens: g(7)?,
};
let ctx = if last == crate::events::TokenUsage::default() {
None
} else {
Some(last)
};
Ok((ctx, Some(cost)))
},
)
.unwrap_or((None, None))
}
}
fn default_path() -> PathBuf {
crate::paths::state_dir().join("hyperhive-turn-stats.sqlite")
}

912
hive-ag3nt/src/web_ui.rs Normal file
View file

@ -0,0 +1,912 @@
//! Per-container HTTP UI. SPA shape: `GET /` returns a static shell;
//! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page
//! state as JSON; the JS app renders. Live events stream on
//! `/events/stream`. Action POSTs (`/send`, `/login/*`) return either a
//! 303 Redirect (for browsers that submit the form normally) or just
//! 200 OK — the JS app re-fetches `/api/state` afterwards.
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use axum::{
Form, Router,
extract::State,
http::StatusCode,
response::{
IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
},
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
use tower_http::services::ServeDir;
use crate::client;
use crate::events::Bus;
use crate::login::LoginState;
use crate::login_session::{LoginSession, drop_if_finished};
use crate::mcp;
use crate::turn::TurnFiles;
/// Live login state for the web UI. The harness updates this in place as it
/// transitions between `NeedsLogin` and `Online`; the UI reads on each
/// render.
pub type LoginStateCell = Arc<Mutex<LoginState>>;
/// Shared turn lock. The serve loop acquires this (as an async mutex) for the
/// duration of every `drive_turn` call. The `/api/compact` handler tries
/// `try_lock()` and rejects immediately if a turn is in flight, preventing
/// concurrent access to the claude session.
pub type TurnLock = Arc<tokio::sync::Mutex<()>>;
#[derive(Clone)]
struct AppState {
label: String,
login: LoginStateCell,
session: Arc<Mutex<Option<Arc<LoginSession>>>>,
bus: Bus,
socket: PathBuf,
/// Same `TurnFiles` the harness's turn loop uses. Shared so
/// `/api/compact` re-uses the exact MCP config / system prompt /
/// settings claude saw on the last regular turn — keeps the
/// session shape identical across compact + normal turns.
files: TurnFiles,
/// Prevents `/api/compact` from racing with an in-flight normal turn.
turn_lock: TurnLock,
/// VNC port read from `/etc/hyperhive/gui.json` at startup.
/// `None` when the file is absent (gui not enabled for this agent).
gui_vnc_port: Option<u16>,
}
impl AppState {
fn flavor(&self) -> Flavor {
self.files.flavor
}
}
/// Which wire protocol the per-agent UI's `/send` handler should speak.
/// Sub-agent → `AgentRequest::OperatorMsg`; manager →
/// `ManagerRequest::OperatorMsg`. Reuses the MCP-side enum so a
/// single value drives both the send protocol and (in
/// `post_compact`) the allowed-tools surface claude sees.
pub type Flavor = mcp::Flavor;
/// # Errors
///
/// Returns an error if the TCP listener cannot bind to the given port.
pub async fn serve(
label: String,
port: u16,
login: LoginStateCell,
bus: Bus,
socket: PathBuf,
files: TurnFiles,
turn_lock: TurnLock,
) -> Result<()> {
let gui_vnc_port = read_gui_json();
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
.map(PathBuf::from)
.context(
"HIVE_STATIC_DIR env var not set — point it at the merged \
per-agent dist (see hyperhive.frontend.mergedDist in nix)",
)?;
if !static_dir.is_dir() {
anyhow::bail!(
"HIVE_STATIC_DIR ({}) is not a directory",
static_dir.display()
);
}
tracing::info!(static_dir = %static_dir.display(), "web UI static dir resolved");
let state = AppState {
label,
login,
session: Arc::new(Mutex::new(None)),
bus,
socket,
files,
turn_lock,
gui_vnc_port,
};
let app = Router::new()
.route("/api/state", get(api_state))
.route("/events/stream", get(events_stream))
.route("/events/history", get(events_history))
.route("/send", post(post_send))
.route("/login/start", post(post_login_start))
.route("/login/code", post(post_login_code))
.route("/login/cancel", post(post_login_cancel))
.route("/api/cancel", post(post_cancel_turn))
.route("/api/compact", post(post_compact))
.route("/api/model", post(post_set_model))
.route("/api/new-session", post(post_new_session))
.route("/api/loose-ends", get(api_loose_ends))
.route("/api/stats", get(api_stats))
.route("/screen/ws", get(screen_ws))
.route("/icon", get(serve_icon))
// Anything else (`/`, `/stats`, `/screen`, `/static/*`)
// falls through to the merged dist. ServeDir auto-appends
// `.html` when the URL is a bare path that matches a file
// (so `/stats` → `dist/stats.html`, `/screen` → `dist/
// screen.html`). Per-agent `extraFiles` additions are
// already layered into this same directory (see
// hyperhive.frontend.mergedDist in nix).
.fallback_service(ServeDir::new(&static_dir))
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = bind_with_retry(addr, "web UI").await?;
tracing::info!(%port, "web UI listening");
axum::serve(listener, app).await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Static assets + state snapshot
// ---------------------------------------------------------------------------
/// Bind a TCP listener with `SO_REUSEADDR` set, retrying on
/// `AddrInUse` indefinitely with exponential backoff capped at 2s.
/// nspawn restarts can race the previous harness's socket release;
/// `SO_REUSEADDR` lets us reclaim a port still in `TIME_WAIT` from a
/// clean previous exit, and the retry covers the case where the
/// previous process is genuinely still alive (systemd restart-delay
/// overlap).
///
/// The retry has no attempt cap: capping was the proximate cause of
/// issue #324 — two back-to-back restarts left the previous socket
/// holding the port for longer than the ~20s the old 12-attempt
/// budget allowed, and the harness silently lost its web UI for the
/// rest of the process lifetime. Genuine port collisions are
/// preflighted host-side (`lifecycle::{spawn,rebuild}`) and surfaced
/// on the dashboard as a banner, so at this layer a persistent
/// `AddrInUse` always reflects a recoverable stale socket — retrying
/// forever is the safe choice. The first attempts log at WARN; once
/// we cross attempt 12 the level drops to INFO so a long stale
/// socket doesn't flood the journal.
async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result<tokio::net::TcpListener> {
let mut delay_ms = 250u64;
let mut attempts = 0u32;
loop {
match try_bind(addr) {
Ok(l) => {
if attempts > 0 {
tracing::info!(
%addr, attempts,
"{label}: bind succeeded after retry"
);
}
return Ok(l);
}
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
let attempt = attempts + 1;
if attempt <= 12 {
tracing::warn!(
%addr, attempt,
"{label}: AddrInUse, retrying in {delay_ms}ms"
);
} else {
tracing::info!(
%addr, attempt,
"{label}: AddrInUse still holding, retrying in {delay_ms}ms"
);
}
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
attempts += 1;
delay_ms = (delay_ms * 2).min(2000);
}
Err(e) => {
return Err(e).with_context(|| format!("bind {label} on {addr}"));
}
}
}
}
fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
let sock = match addr {
SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4()?,
SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6()?,
};
sock.set_reuseaddr(true)?;
sock.bind(addr)?;
sock.listen(1024)
}
/// This agent's icon. Serves the operator-configured SVG from
/// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix
/// option) when present, otherwise the bundled default hyperhive logo.
/// Always returns an image, so consumers (dashboard, favicon) can hit
/// `/icon` unconditionally without probing whether one is configured.
async fn serve_icon() -> impl IntoResponse {
const DEFAULT_ICON: &str = include_str!("../../branding/hyperhive.svg");
let body = std::fs::read_to_string("/etc/hyperhive/icon.svg")
.unwrap_or_else(|_| DEFAULT_ICON.to_string());
([("content-type", "image/svg+xml")], body)
}
/// Read `/etc/hyperhive/gui.json` and extract the `vnc_port` field.
/// Returns `None` if the file is absent or unparseable — GUI not enabled.
fn read_gui_json() -> Option<u16> {
let text = std::fs::read_to_string("/etc/hyperhive/gui.json").ok()?;
let val: serde_json::Value = serde_json::from_str(&text).ok()?;
val["vnc_port"].as_u64().and_then(|p| u16::try_from(p).ok())
}
/// WebSocket handler: upgrade then pump bytes between the WS client and
/// the VNC server on `127.0.0.1:<vnc_port>`. Returns 404 when gui is not
/// enabled for this agent.
async fn screen_ws(
ws: axum::extract::ws::WebSocketUpgrade,
State(state): State<AppState>,
) -> Response {
let Some(vnc_port) = state.gui_vnc_port else {
return (StatusCode::NOT_FOUND, "gui not enabled for this agent").into_response();
};
ws.on_upgrade(move |socket| relay_ws_vnc(socket, vnc_port))
}
/// Pure byte pump: forwards raw bytes between the WebSocket client and
/// the VNC TCP stream. Transparent to any RFB variant (plain, `VeNCrypt`).
async fn relay_ws_vnc(socket: axum::extract::ws::WebSocket, vnc_port: u16) {
// Import futures traits locally so they don't conflict with
// tokio_stream::StreamExt used at module scope.
use axum::extract::ws::Message;
use futures_util::{SinkExt, StreamExt as _};
let addr = format!("127.0.0.1:{vnc_port}");
let Ok(tcp) = tokio::net::TcpStream::connect(&addr).await else {
tracing::warn!(%addr, "screen/ws: could not connect to VNC server");
return;
};
let (mut tcp_rx, mut tcp_tx) = tcp.into_split();
let (mut ws_tx, mut ws_rx) = socket.split();
// WS → TCP
let ws_to_tcp = tokio::spawn(async move {
while let Some(Ok(msg)) = futures_util::StreamExt::next(&mut ws_rx).await {
match msg {
Message::Binary(data) => {
if tcp_tx.write_all(&data).await.is_err() {
break;
}
}
Message::Close(_) => break,
_ => {} // ping/pong/text: ignore
}
}
});
// TCP → WS
let tcp_to_ws = tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
loop {
match tcp_rx.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if ws_tx
.send(Message::Binary(buf[..n].to_vec().into()))
.await
.is_err()
{
break;
}
}
}
}
});
// Wait for either direction to close, then let both tasks drop.
tokio::select! {
_ = ws_to_tcp => {}
_ = tcp_to_ws => {}
}
}
#[derive(Deserialize)]
struct StatsQuery {
window: Option<String>,
}
async fn api_stats(
State(state): State<AppState>,
axum::extract::Query(q): axum::extract::Query<StatsQuery>,
) -> axum::Json<crate::stats::Snapshot> {
let window = crate::stats::Window::parse(q.window.as_deref().unwrap_or("24h"));
let mut snapshot = crate::stats::snapshot_default(window);
// Pass the window span to the reminder-stats RPC so the broker
// filters its counts to the same time range as the chart data.
let window_secs = window.span_secs();
let window_secs_u = u64::try_from(window_secs).unwrap_or(0);
snapshot.reminder_stats = fetch_reminder_stats(&state.socket, state.flavor(), window_secs_u).await;
axum::Json(snapshot)
}
#[derive(Serialize)]
struct StateSnapshot {
/// Bus seq at the moment this snapshot was assembled. Clients dedupe
/// their buffered SSE traffic against this value: events with
/// `seq <= snapshot.seq` are already reflected (or pre-date the
/// snapshot); `seq > snapshot.seq` is post-snapshot. Reset to 0 on
/// harness restart — clients treat reconnect as a fresh world.
seq: u64,
label: String,
dashboard_port: u16,
/// `"online"` | `"rate_limited"` | `"needs_login_idle"` | `"needs_login_in_progress"`.
status: &'static str,
/// Present when `status == "needs_login_in_progress"`.
session: Option<SessionView>,
/// Last N messages addressed to this agent, newest-first. Pulled
/// from the broker via the per-agent socket on each render.
/// Empty on transport failure.
inbox: Vec<hive_sh4re::InboxRow>,
/// Authoritative turn-loop state from the harness and the unix
/// timestamp the state was entered. The JS computes the age
/// client-side off this rather than tracking it from SSE events.
turn_state: crate::events::TurnState,
turn_state_since: i64,
/// Currently-active claude model name. Reflected on the page so
/// the operator can see what they just switched to (and what's
/// in flight). Mutable at runtime via `POST /api/model`.
model: String,
/// Effective context-window token budget for the current model.
/// Primary source: API-reported `modelUsage.*.contextWindow` from
/// the last result event (authoritative per-inference active window).
/// Falls back to `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars, then 200 000.
/// Consumers (e.g. dashboard badge) use this to render ctx-usage %.
context_window_tokens: u64,
/// Last-inference token usage from the most recent completed
/// turn — represents the current context-window size at turn-end.
/// `null` until the first turn finishes.
ctx_usage: Option<crate::events::TokenUsage>,
/// Cumulative token usage across the most recent turn's inferences
/// (cost signal). `null` until the first turn finishes.
cost_usage: Option<crate::events::TokenUsage>,
/// Navigation links for this agent page (issue #262). Stats is
/// always present; screen when the VNC compositor is enabled; the
/// forge profile + the agent-configs mirror repo when the agent
/// has a forge account; followed by any agent-declared
/// `hyperhive.dashboardLinks` extras (read from
/// `{state_dir}/hyperhive-dashboard-links.json`). Each URL is
/// already absolute — built server-side from the request `Host`
/// header — so the frontend just renders.
///
/// This same list is the **source of truth** for the per-agent
/// page *and* the dashboard card's icon-only nav strip: hive-c0re
/// proxies it via `GET /api/agent/{name}/links` (same-origin from
/// the dashboard JS), avoiding CORS and centralising the link
/// definitions in the agent backend.
links: Vec<AgentLink>,
}
/// One navigation link in the agent page header row. Same JSON
/// shape feeds the dashboard's icon-only nav strip via the host's
/// `GET /api/agent/{name}/links` passthrough proxy, so the agent
/// backend is the single source of truth for what links an agent
/// exposes (issue #262).
#[derive(Serialize)]
struct AgentLink {
/// `kind = Container | Forge` → path; `kind = External` → full URL.
/// The frontend prepends the right base before rendering.
url: String,
icon: String,
label: String,
kind: AgentLinkKind,
}
/// Resolution hint for `AgentLink.url`. The agent backend can't know
/// which hostname the browser sees (especially when the dashboard
/// proxies the call from a different origin), so it labels each link
/// and lets the frontend prepend the right base.
#[derive(Serialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
enum AgentLinkKind {
/// `url` is a path on the agent's container web UI (`/stats`,
/// `/screen`). Agent page: same-origin path. Dashboard:
/// `http://<host>:<container.port><url>`.
Container,
/// `url` is a path on the local Forgejo (`/<label>`,
/// `/agent-configs/<label>`). Both surfaces:
/// `http://<host>:3000<url>`.
Forge,
/// `url` is already a fully-qualified absolute URL — use as-is.
/// Agent-declared `hyperhive.dashboardLinks` extras arrive here.
External,
}
#[derive(Serialize)]
struct SessionView {
/// First `https://…` claude emitted on stdout, if any.
url: Option<String>,
/// Accumulated stdout + stderr.
output: String,
finished: bool,
exit_note: Option<String>,
}
/// Proxy this agent's loose-ends list via the per-agent socket. The
/// web UI surfaces the result as a collapsible section in the page
/// so the operator can see at a glance what's pending against the
/// agent (questions asked by it, peer questions targeting it,
/// reminders it scheduled, approvals for the manager). Same data
/// the `mcp__hyperhive__get_loose_ends` tool sees from inside the
/// container.
async fn api_loose_ends(State(state): State<AppState>) -> Response {
let loose_ends: Vec<hive_sh4re::LooseEnd> = match state.flavor() {
Flavor::Agent => {
match client::request::<_, hive_sh4re::AgentResponse>(
&state.socket,
&hive_sh4re::AgentRequest::GetLooseEnds,
)
.await
{
Ok(hive_sh4re::AgentResponse::LooseEnds { loose_ends }) => loose_ends,
Ok(hive_sh4re::AgentResponse::Err { message }) => {
return error_response(&format!("get_loose_ends: {message}"));
}
Ok(other) => return error_response(&format!("unexpected response: {other:?}")),
Err(e) => return error_response(&format!("transport: {e:#}")),
}
}
Flavor::Manager => {
match client::request::<_, hive_sh4re::ManagerResponse>(
&state.socket,
// Manager's own loose ends — the web page is the
// manager's page, not a hive-wide console.
&hive_sh4re::ManagerRequest::GetLooseEnds { agent: None },
)
.await
{
Ok(hive_sh4re::ManagerResponse::LooseEnds { loose_ends }) => loose_ends,
Ok(hive_sh4re::ManagerResponse::Err { message }) => {
return error_response(&format!("get_loose_ends: {message}"));
}
Ok(other) => return error_response(&format!("unexpected response: {other:?}")),
Err(e) => return error_response(&format!("transport: {e:#}")),
}
}
};
axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response()
}
async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
// Capture seq *before* any reads so the dedupe contract is
// "events with seq > snapshot.seq are post-snapshot, never missed."
let seq = state.bus.current_seq();
drop_if_finished(&state.session);
let login = *state.login.lock().unwrap();
let session_snapshot = state.session.lock().unwrap().clone();
let (status, session_view) = match (login, session_snapshot) {
(LoginState::Online, _) if state.bus.is_rate_limited() => ("rate_limited", None),
(LoginState::Online, _) => ("online", None),
(LoginState::NeedsLogin, None) => ("needs_login_idle", None),
(LoginState::NeedsLogin, Some(s)) => (
"needs_login_in_progress",
Some(SessionView {
url: s.url(),
output: s.output(),
finished: s.finished(),
exit_note: s.exit_note(),
}),
),
};
let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT")
.ok()
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(7000);
let inbox = recent_inbox(&state.socket, state.flavor()).await;
let (turn_state, turn_state_since) = state.bus.state_snapshot();
let model = state.bus.model();
let context_window_tokens = state
.bus
.api_context_window()
.unwrap_or_else(|| crate::events::context_window_tokens(&model));
let ctx_usage = state.bus.last_ctx_usage();
let cost_usage = state.bus.last_cost_usage();
axum::Json(StateSnapshot {
seq,
label: state.label.clone(),
dashboard_port,
status,
session: session_view,
inbox,
turn_state,
turn_state_since,
model,
context_window_tokens,
ctx_usage,
cost_usage,
links: agent_links(&state.label, state.gui_vnc_port.is_some()),
})
}
/// Build the navigation link list for the agent page header
/// (issue #262). Single source of truth: the dashboard's icon-only
/// nav strip consumes the same list via the host's
/// `GET /api/agent/{name}/links` proxy. URLs are paths (relative)
/// for Container/Forge targets and absolute for External; the
/// frontend resolves each against its `kind` so the backend never
/// has to guess the operator's browser host.
///
/// The agent harness doesn't know its own deployed sha (the meta
/// flake lock lives on the host), so the `config` link points at
/// the repo root; the dashboard renders a `deployed:<sha>` chip
/// alongside the strip so the operator still sees what's live.
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
let mut links = Vec::new();
// Note: the URLs are the actual HTML files served out of the
// frontend dist (`stats.html` / `screen.html`); after the #273
// backend/frontend split the harness serves these as static
// files via ServeDir rather than via Rust routes, so the URL
// has to be the on-disk filename.
links.push(AgentLink {
url: "/stats.html".to_owned(),
icon: "📊".to_owned(),
label: "stats".to_owned(),
kind: AgentLinkKind::Container,
});
if gui_enabled {
links.push(AgentLink {
url: "/screen.html".to_owned(),
icon: "🖥".to_owned(),
label: "screen".to_owned(),
kind: AgentLinkKind::Container,
});
}
if crate::paths::state_dir().join("forge-token").is_file() {
links.push(AgentLink {
url: format!("/{label}"),
icon: "".to_owned(),
label: "forge".to_owned(),
kind: AgentLinkKind::Forge,
});
links.push(AgentLink {
url: format!("/agent-configs/{label}"),
icon: "".to_owned(),
label: "config".to_owned(),
kind: AgentLinkKind::Forge,
});
}
// Agent-declared extras (`hyperhive.dashboardLinks` → the
// `hive-dashboard-links` NixOS oneshot writes them to
// `{state_dir}/hyperhive-dashboard-links.json`). Shape on disk
// is `{label, icon, url}` with absolute URLs — those become
// `kind = External` links, passed through verbatim.
let extras_path =
crate::paths::state_dir().join("hyperhive-dashboard-links.json");
if let Ok(text) = std::fs::read_to_string(&extras_path)
&& !text.trim().is_empty()
&& let Ok(extras) = serde_json::from_str::<Vec<ExtraLink>>(&text)
{
for e in extras {
links.push(AgentLink {
url: e.url,
icon: e.icon,
label: e.label,
kind: AgentLinkKind::External,
});
}
}
links
}
/// On-disk shape of `hyperhive-dashboard-links.json` (the
/// `hive-dashboard-links` NixOS oneshot's output). Mapped to
/// `AgentLink { kind: External }` inside `agent_links`.
#[derive(serde::Deserialize)]
struct ExtraLink {
label: String,
#[serde(default)]
icon: String,
url: String,
}
/// Best-effort: pull the last 30 messages addressed to us via the
/// per-agent / manager socket. Empty list on any transport / decode
/// failure — the inbox section is decorative, not authoritative.
async fn recent_inbox(socket: &std::path::Path, flavor: Flavor) -> Vec<hive_sh4re::InboxRow> {
const LIMIT: u64 = 30;
match flavor {
Flavor::Agent => {
match client::request::<_, hive_sh4re::AgentResponse>(
socket,
&hive_sh4re::AgentRequest::Recent { limit: LIMIT },
)
.await
{
Ok(hive_sh4re::AgentResponse::Recent { rows }) => rows,
_ => Vec::new(),
}
}
Flavor::Manager => {
match client::request::<_, hive_sh4re::ManagerResponse>(
socket,
&hive_sh4re::ManagerRequest::Recent { limit: LIMIT },
)
.await
{
Ok(hive_sh4re::ManagerResponse::Recent { rows }) => rows,
_ => Vec::new(),
}
}
}
}
/// Fetch reminder activity stats from the broker via the per-agent /
/// manager socket. Returns None on any transport / decode failure — the
/// stats are decorative, not authoritative.
async fn fetch_reminder_stats(socket: &std::path::Path, flavor: Flavor, window_secs: u64) -> Option<hive_sh4re::ReminderStats> {
match flavor {
Flavor::Agent => {
match client::request::<_, hive_sh4re::AgentResponse>(
socket,
&hive_sh4re::AgentRequest::ReminderRollup {
since_secs: window_secs,
},
)
.await
{
Ok(hive_sh4re::AgentResponse::ReminderRollup(stats)) => Some(stats),
_ => None,
}
}
Flavor::Manager => {
match client::request::<_, hive_sh4re::ManagerResponse>(
socket,
&hive_sh4re::ManagerRequest::ReminderRollup {
since_secs: window_secs,
// Manager's own stats page — its own reminders.
agent: None,
},
)
.await
{
Ok(hive_sh4re::ManagerResponse::ReminderRollup(stats)) => Some(stats),
_ => None,
}
}
}
}
// ---------------------------------------------------------------------------
// Action handlers
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct SendForm {
body: String,
}
async fn post_send(State(state): State<AppState>, Form(form): Form<SendForm>) -> Response {
let body = form.body.trim().to_owned();
if body.is_empty() {
return error_response("send: `body` required");
}
let result = match state.flavor() {
Flavor::Agent => match client::request::<_, hive_sh4re::AgentResponse>(
&state.socket,
&hive_sh4re::AgentRequest::OperatorMsg { body },
)
.await
{
Ok(hive_sh4re::AgentResponse::Ok) => Ok(()),
Ok(hive_sh4re::AgentResponse::Err { message }) => Err(message),
Ok(other) => Err(format!("unexpected response: {other:?}")),
Err(e) => Err(format!("transport: {e:#}")),
},
Flavor::Manager => match client::request::<_, hive_sh4re::ManagerResponse>(
&state.socket,
&hive_sh4re::ManagerRequest::OperatorMsg { body },
)
.await
{
Ok(hive_sh4re::ManagerResponse::Ok) => Ok(()),
Ok(hive_sh4re::ManagerResponse::Err { message }) => Err(message),
Ok(other) => Err(format!("unexpected response: {other:?}")),
Err(e) => Err(format!("transport: {e:#}")),
},
};
match result {
// 200 instead of 303 → the client doesn't refetch /api/state.
// The operator message becomes a broker `Sent` (already shown
// server-side in the dashboard); on the agent side, the
// resulting `TurnStart` SSE event drives the terminal + the
// inbox row gets consumed by the time `TurnEnd` fires the
// existing turn-end refresh.
Ok(()) => (axum::http::StatusCode::OK, "ok").into_response(),
Err(e) => error_response(&format!("send failed: {e}")),
}
}
async fn events_history(State(state): State<AppState>) -> axum::Json<serde_json::Value> {
// Capture seq *before* the read so dedupe is "drop buffered events
// you've already seen in history", never "lose an event that fired
// between the read and the timestamp." Historical rows have no
// per-row seq; only the high-water mark matters for the dedupe
// window.
let seq = state.bus.current_seq();
let events = state.bus.history();
axum::Json(serde_json::json!({ "seq": seq, "events": events }))
}
async fn events_stream(
State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
tracing::info!("sse: client subscribed");
let rx = state.bus.subscribe();
// Drop a "hello" note into the bus so every new subscriber sees at
// least one event immediately and can clear the connecting placeholder.
state.bus.emit(crate::events::LiveEvent::Note {
text: "live stream attached".into(),
});
let stream = BroadcastStream::new(rx).filter_map(|res| {
let ev = res.ok()?;
let json = serde_json::to_string(&ev).ok()?;
Some(Ok(Event::default().data(json)))
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
async fn post_login_start(State(state): State<AppState>) -> Response {
drop_if_finished(&state.session);
{
let guard = state.session.lock().unwrap();
if guard.is_some() {
return (axum::http::StatusCode::OK, "ok").into_response();
}
}
match LoginSession::start() {
Ok(session) => {
*state.session.lock().unwrap() = Some(Arc::new(session));
// Flip status from needs_login_idle → needs_login_in_progress
// so the web UI's badge + polling kick in (polling is still
// the right tool for the streaming session output during
// the login flow itself; events drop the poll for
// *everything else*).
state.bus.emit_status("needs_login_in_progress");
(axum::http::StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("login start failed: {e:#}")),
}
}
#[derive(Deserialize)]
struct CodeForm {
code: String,
}
async fn post_login_code(State(state): State<AppState>, Form(form): Form<CodeForm>) -> Response {
let session = state.session.lock().unwrap().clone();
let Some(session) = session else {
return error_response("no login session running");
};
if let Err(e) = session.submit_code(&form.code).await {
return error_response(&format!("submit code failed: {e:#}"));
}
(axum::http::StatusCode::OK, "ok").into_response()
}
async fn post_login_cancel(State(state): State<AppState>) -> Response {
let session = state.session.lock().unwrap().take();
if let Some(session) = session {
session.close_stdin().await;
session.kill();
}
// Back to needs_login_idle (LoginState unchanged, session gone).
state.bus.emit_status("needs_login_idle");
(axum::http::StatusCode::OK, "ok").into_response()
}
/// Operator-initiated session compaction. Spawns `turn::compact_session`
/// in the background — the HTTP handler returns immediately so the
/// async-form spinner can clear. Output (claude's compaction stream,
/// the "/compact done" note) lands in the live event panel like any
/// other turn. If a regular turn is in flight, claude's own session
/// lock will reject this one and we surface the error as a Note.
#[derive(Deserialize)]
struct ModelForm {
model: String,
}
/// Switch the model for future turns. The current turn (if any)
/// keeps its model; `/model <name>` applies starting with the next
/// `recv` cycle. Empty / whitespace-only inputs are rejected. No
/// claude-side validation — we just hand the string through to
/// `claude --model <name>`; an unknown model surfaces as a turn
/// failure in the live panel and the operator can revert.
async fn post_set_model(State(state): State<AppState>, Form(form): Form<ModelForm>) -> Response {
let name = form.model.trim();
if name.is_empty() {
return error_response("model: name required");
}
state.bus.set_model(name);
state.bus.emit(crate::events::LiveEvent::Note {
text: format!("operator: /model — claude model set to '{name}' for future turns"),
});
tracing::info!(%name, "operator set model");
(axum::http::StatusCode::OK, "ok").into_response()
}
async fn post_compact(State(state): State<AppState>) -> Response {
// Clone the Arc before locking so the guard's lifetime is tied to the
// clone (which we can move into the spawn) rather than to `state`.
let lock = state.turn_lock.clone();
// Reject immediately if a normal turn is in flight — concurrent access
// to the claude session is unsafe and produces garbled output.
let Ok(guard) = lock.try_lock_owned() else {
return error_response("turn in flight — wait for it to finish before compacting");
};
let bus = state.bus.clone();
let files = state.files.clone();
tokio::spawn(async move {
let _guard = guard; // keep lock alive for the duration of compaction
bus.emit(crate::events::LiveEvent::Note {
text: "operator: /compact — running on persistent session".into(),
});
bus.set_state(crate::events::TurnState::Compacting);
let outcome = crate::turn::compact_session(&files, &bus).await;
bus.set_state(crate::events::TurnState::Idle);
// Best-effort manual /compact from the operator: compact_session
// already emits a Note per outcome, so we don't need to re-emit
// here — just record any underlying error to the harness log.
if let crate::turn::TurnOutcome::Failed(e) = outcome {
tracing::warn!(error = %format!("{e:#}"), "operator /compact failed");
}
});
(axum::http::StatusCode::OK, "ok").into_response()
}
/// Cancel the in-flight claude turn. Coarse-grained: shells out
/// `pkill -INT claude` since there's at most one claude per container.
/// SIGINT (not SIGTERM) so claude flushes anything in-flight and emits a
/// final result row. Emits a Note so the operator sees the cancel
/// landed; the actual state transition back to `idle` happens when
/// `run_claude` wakes up and the harness emits `TurnEnd`.
/// Arm a one-shot: the next claude turn drops `--continue`, starting a
/// fresh session. Subsequent turns resume normal `--continue`
/// behavior. Idempotent before the next turn fires — calling twice
/// still results in a single fresh start. Useful when the
/// session-resume context is poisoned (claude went off the rails,
/// hit an unrecoverable refusal, etc.) and a full reset is cheaper
/// than asking claude to forget mid-stream.
async fn post_new_session(State(state): State<AppState>) -> Response {
state.bus.request_new_session();
state.bus.emit(crate::events::LiveEvent::Note {
text: "operator: new session armed — next turn runs without --continue".into(),
});
(axum::http::StatusCode::OK, "ok").into_response()
}
async fn post_cancel_turn(State(state): State<AppState>) -> Response {
let out = tokio::process::Command::new("pkill")
.args(["-INT", "claude"])
.output()
.await;
let note = match out {
Ok(o) if o.status.success() => "operator: /cancel — sent SIGINT to claude".to_owned(),
Ok(o) if o.status.code() == Some(1) => {
"operator: /cancel — no claude process to interrupt".to_owned()
}
Ok(o) => format!(
"operator: /cancel — pkill exited {} stderr={}",
o.status,
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => format!("operator: /cancel — pkill failed: {e}"),
};
state.bus.emit(crate::events::LiveEvent::Note { text: note });
(axum::http::StatusCode::OK, "ok").into_response()
}
fn error_response(message: &str) -> Response {
// Plain text — JS app surfaces in `alert()`, HTML wrapping would just
// be noise.
(StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response()
}

33
hive-c0re/Cargo.toml Normal file
View file

@ -0,0 +1,33 @@
[package]
name = "hive-c0re"
edition.workspace = true
version.workspace = true
# Render branding/agent-configs.svg → $OUT_DIR/agent-configs.png at
# compile time (#424). build.rs shells out to `rsvg-convert`
# (librsvg, pulled in via flake.nix' naersk nativeBuildInputs); the
# baked PNG is included via include_bytes! from forge.rs so no
# raster gets checked into git.
build = "build.rs"
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
axum.workspace = true
base64.workspace = true
reqwest.workspace = true
clap.workspace = true
hive-sh4re.workspace = true
libc = "0.2"
rusqlite.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
[dev-dependencies]
tempfile = "3"

49
hive-c0re/build.rs Normal file
View file

@ -0,0 +1,49 @@
//! Render `branding/agent-configs.svg` → `$OUT_DIR/agent-configs.png`
//! at compile time so the daemon can `include_bytes!` the PNG without
//! checking the raster into git (#424 mara: "generate png on the fly
//! or in build"). The SVG is the source of truth; the PNG is a build
//! artifact.
//!
//! Uses `rsvg-convert` from PATH (librsvg, already available in
//! nixpkgs and added to the naersk derivation's `nativeBuildInputs`
//! in `flake.nix`). For dev builds outside Nix, install librsvg via
//! your system package manager (Debian/Ubuntu: `librsvg2-bin`,
//! macOS: `brew install librsvg`).
use std::env;
use std::path::PathBuf;
use std::process::Command;
const SVG_PATH: &str = "../branding/agent-configs.svg";
const PNG_NAME: &str = "agent-configs.png";
// 300×300 to match the existing branding/hyperhive.png, which the
// Forgejo avatar endpoint accepts without resizing on upload.
const PX: &str = "300";
fn main() {
// Re-run the build script when either the SVG itself or this
// script change. We deliberately don't watch every file in
// `branding/` — only the one PNG we generate.
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed={SVG_PATH}");
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR set by cargo"));
let png_path = out_dir.join(PNG_NAME);
let status = Command::new("rsvg-convert")
.args(["--width", PX, "--height", PX, "-o"])
.arg(&png_path)
.arg(SVG_PATH)
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => panic!("rsvg-convert exited with {s} rendering {SVG_PATH}"),
Err(e) => panic!(
"failed to invoke rsvg-convert: {e}\n\
install librsvg (Debian/Ubuntu: librsvg2-bin, macOS: brew install librsvg, \
NixOS: pkgs.librsvg). The Nix derivation already pulls it in via \
flake.nix naersk-lib.buildPackage.nativeBuildInputs.",
),
}
}

778
hive-c0re/src/actions.rs Normal file
View file

@ -0,0 +1,778 @@
//! Operations that are exposed through more than one surface (the host admin
//! socket *and* the dashboard's POST endpoints). Each function takes a
//! `&Coordinator` and the request parameters; callers stitch the response
//! shape they want (HTTP redirect vs JSON).
use std::sync::Arc;
use anyhow::{Context as _, Result, bail};
use hive_sh4re::{ApprovalKind, ApprovalStatus, HelperEvent, MANAGER_AGENT};
use crate::coordinator::{Coordinator, TransientKind};
use crate::lifecycle::{self, MANAGER_NAME};
/// Approve a pending request. Marks the approval row durably, then
/// either runs the work inline (`InitConfig`, sub-second git ops) or
/// enqueues it into `rebuild_queue` so the dashboard POST returns
/// immediately while the long-running pipeline runs off-thread
/// (closes #436 — operator no longer eats a 30-90s spinner on
/// `ApplyCommit`).
///
/// Dispatch:
/// - `ApplyCommit` → `QueueKind::Rebuild` (~30-90s wall time)
/// - `UpdateMetaInputs` → `QueueKind::MetaUpdate` (~3-15s)
/// - `Spawn` → `QueueKind::Spawn` (~30-90s)
/// - `InitConfig` → inline (<1s; queue card would be noise)
///
/// The queue worker re-fetches the approval row on dispatch, runs
/// the kind-specific pipeline, and fires `ApprovalResolved` /
/// `Spawned` / `Rebuilt` / `ConfigReady` via `finish_approval`.
pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
let approval = coord.approvals.mark_approved(id)?;
tracing::info!(
%approval.id,
%approval.agent,
kind = ?approval.kind,
%approval.commit_ref,
"approval: dispatching",
);
match approval.kind {
ApprovalKind::InitConfig => {
// Sub-second git seed + forge-remote wire. Routing through
// the queue would surface a queue card that's gone before
// the operator's eyes refocus. Run inline.
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
let claude_dir = Coordinator::agent_claude_dir(&approval.agent);
let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await
}
ApprovalKind::ApplyCommit => {
coord.rebuild_queue.enqueue_full(
crate::rebuild_queue::QueueKind::Rebuild,
approval.agent.clone(),
crate::rebuild_queue::QueueSource::Approval,
format!("approval #{id} apply commit"),
None,
Vec::new(),
Some(id),
);
coord.emit_rebuild_queue_snapshot();
Ok(())
}
ApprovalKind::UpdateMetaInputs => {
// Inputs JSON-encoded into commit_ref by the manager's
// submit path — surface them on the queue entry so the
// dashboard can show *which* inputs are about to bump.
let inputs: Vec<String> =
serde_json::from_str(&approval.commit_ref).unwrap_or_default();
coord.rebuild_queue.enqueue_full(
crate::rebuild_queue::QueueKind::MetaUpdate,
approval.agent.clone(),
crate::rebuild_queue::QueueSource::Approval,
format!("approval #{id} meta input update"),
None,
inputs,
Some(id),
);
coord.emit_rebuild_queue_snapshot();
Ok(())
}
ApprovalKind::Spawn => {
coord.rebuild_queue.enqueue_full(
crate::rebuild_queue::QueueKind::Spawn,
approval.agent.clone(),
crate::rebuild_queue::QueueSource::Approval,
format!("approval #{id} spawn"),
None,
Vec::new(),
Some(id),
);
coord.emit_rebuild_queue_snapshot();
Ok(())
}
ApprovalKind::SchedulePrompt => {
// No queue card for SchedulePrompt — the work is a single
// sqlite insert, the actual "running" lifetime lives on
// the scheduled-prompts surface itself (worker fires it
// at the scheduled time). Run inline + fire
// `ApprovalResolved` so the approval row leaves Pending
// immediately.
run_approval_schedule_prompt(&coord, approval).await
}
}
}
/// Worker entry point for `ApprovalKind::ApplyCommit` queue entries.
/// Re-fetches the approval row, runs the commit pipeline (same
/// shape as the pre-#436 inline path), and fires `ApprovalResolved`
/// + the lifecycle event (`Rebuilt` / `Spawned` for first-spawn).
pub async fn run_approval_apply_commit(
coord: &Arc<Coordinator>,
queue_entry_id: Option<u64>,
approval_id: i64,
) -> Result<()> {
let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::ApplyCommit)?;
let agent_dir = coord.ensure_runtime(&approval.agent)?;
let applied_dir = Coordinator::agent_applied_dir(&approval.agent);
let claude_dir = Coordinator::agent_claude_dir(&approval.agent);
let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
coord.set_queue_step(queue_entry_id, "apply commit");
let (result, terminal_tag, is_first_spawn) = run_apply_commit(
coord,
&approval,
&agent_dir,
&applied_dir,
&claude_dir,
&notes_dir,
queue_entry_id,
)
.await;
coord.set_queue_step(queue_entry_id, "forge push");
if let Err(e) = crate::forge::push_config(&approval.agent).await {
tracing::warn!(agent = %approval.agent, error = ?e, "forge: push_config after apply failed");
}
if is_first_spawn && result.is_ok() {
coord.set_queue_step(queue_entry_id, "first-spawn forge bootstrap");
forge_after_first_spawn(coord, &approval.agent).await;
}
// `finish_approval` returns the original `result` so the queue
// worker sees Ok/Err and marks the queue entry accordingly. The
// approval row + helper events have already been fanned out.
finish_approval(coord, &approval, result, terminal_tag, is_first_spawn)
}
/// Inline (non-queued) handler for `ApprovalKind::SchedulePrompt`.
/// On approve, decode the `SchedulePromptPayload` JSON from the
/// approval's `commit_ref`, insert a row into `scheduled_prompts`
/// (with `source = Approval { id }`), and fire `ApprovalResolved`.
/// The worker takes over from here — fan-out at fire time.
async fn run_approval_schedule_prompt(
coord: &Coordinator,
approval: hive_sh4re::Approval,
) -> Result<()> {
let result: Result<()> = async {
let payload: hive_sh4re::SchedulePromptPayload =
serde_json::from_str(&approval.commit_ref)
.context("decode SchedulePromptPayload from approval.commit_ref")?;
coord
.scheduled_prompts
.submit(crate::scheduled_prompts::NewSchedule {
owner: approval.agent.clone(),
targets: payload.targets,
body: payload.body,
first_fire_at_unix: payload.first_fire_at_unix,
interval_seconds: payload.interval_seconds,
description: payload.description,
source: crate::scheduled_prompts::ScheduleSource::Approval {
id: approval.id,
},
})
.map(|_| ())
.context("insert scheduled prompt")
}
.await;
finish_approval(coord, &approval, result, None, false)
}
/// Worker entry point for `ApprovalKind::UpdateMetaInputs` queue
/// entries. Inputs come from the approval row's `commit_ref` field
/// (JSON-encoded by the manager submit path), not the queue entry's
/// `inputs` — the queue copy is for dashboard display only.
pub async fn run_approval_update_meta_inputs(
coord: &Arc<Coordinator>,
queue_entry_id: Option<u64>,
approval_id: i64,
) -> Result<()> {
let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::UpdateMetaInputs)?;
let inputs: Vec<String> = serde_json::from_str(&approval.commit_ref).unwrap_or_default();
coord.set_queue_step(queue_entry_id, "nix flake update");
let result = crate::meta::lock_update(&inputs).await;
finish_approval(coord, &approval, result, None, false)
}
/// Worker entry point for `ApprovalKind::Spawn` queue entries.
/// Differs from `run_approval_apply_commit` only in routing through
/// `lifecycle::spawn` (the deprecated direct-spawn path). Synchronous
/// in the queue worker — the previous `tokio::spawn` wrapper is gone
/// (the queue worker itself is the async task).
pub async fn run_approval_spawn(
coord: &Arc<Coordinator>,
queue_entry_id: Option<u64>,
approval_id: i64,
) -> Result<()> {
let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::Spawn)?;
let agent_dir = coord.ensure_runtime(&approval.agent)?;
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
let applied_dir = Coordinator::agent_applied_dir(&approval.agent);
let claude_dir = Coordinator::agent_claude_dir(&approval.agent);
let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
// Transient guard keeps the per-container "Spawning" pill lit while
// the worker is doing the actual nixos-container create. Auto-clears
// on the function's scope exit (success or panic).
let _guard = coord.transient_guard(&approval.agent, TransientKind::Spawning);
coord.set_queue_step(queue_entry_id, "lifecycle::spawn");
let result = lifecycle::spawn(
&approval.agent,
&coord.hyperhive_flake,
&agent_dir,
&proposed_dir,
&applied_dir,
&claude_dir,
&notes_dir,
coord.dashboard_port,
&coord.operator_pronouns,
&coord.context_window_tokens,
)
.await;
if result.is_ok() {
coord.set_queue_step(queue_entry_id, "forge user");
if let Err(e) = crate::forge::ensure_user_for(&approval.agent).await {
tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_user after spawn failed");
}
coord.set_queue_step(queue_entry_id, "forge config repo");
if let Err(e) = crate::forge::ensure_config_repo(&approval.agent).await {
tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_config_repo after spawn failed");
}
coord.set_queue_step(queue_entry_id, "forge push");
if let Err(e) = crate::forge::push_config(&approval.agent).await {
tracing::warn!(agent = %approval.agent, error = ?e, "forge: push_config after spawn failed");
}
coord.set_queue_step(queue_entry_id, "forge meta access");
if let Some(core_token) = crate::forge::core_token()
&& let Err(e) = crate::forge::meta_read_access(&approval.agent, &core_token).await
{
tracing::warn!(agent = %approval.agent, error = ?e, "forge: meta_read_access after spawn failed");
}
if let Err(e) = crate::forge::ensure_meta_remote(&approval.agent).await {
tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after spawn failed");
}
}
let final_result = finish_approval(coord, &approval, result, None, false);
coord.rescan_containers_and_emit().await;
crate::dashboard::emit_tombstones_snapshot(coord).await;
final_result
}
/// Re-fetch an approval row from sqlite for a queue-worker dispatch.
/// Bails if the row is gone (deny race), if its kind doesn't match,
/// or if the lookup itself fails. The kind check is defensive — the
/// queue's `dispatch` already routes by `QueueKind`, but the approval
/// kind is the authoritative source of truth and a mismatch points
/// at a deeper bug we'd want to surface.
fn fetch_approval_for_worker(
coord: &Coordinator,
approval_id: i64,
expected_kind: ApprovalKind,
) -> Result<hive_sh4re::Approval> {
let approval = coord
.approvals
.get(approval_id)
.map_err(|e| anyhow::anyhow!("read approval {approval_id}: {e:#}"))?
.ok_or_else(|| anyhow::anyhow!("approval {approval_id} no longer exists"))?;
if approval.kind != expected_kind {
bail!(
"approval {approval_id} kind mismatch: queue expected {expected_kind:?}, row is {actual:?}",
actual = approval.kind
);
}
Ok(approval)
}
/// Forge bookkeeping run once after the very first container spawn:
/// create the per-agent forge user, mirror the applied repo, and grant
/// read access to core/meta. Also rescans containers so the dashboard
/// reflects the post-spawn state.
async fn forge_after_first_spawn(coord: &Arc<Coordinator>, agent: &str) {
if let Err(e) = crate::forge::ensure_user_for(agent).await {
tracing::warn!(%agent, error = ?e, "forge: ensure_user after first spawn failed");
}
if let Err(e) = crate::forge::ensure_config_repo(agent).await {
tracing::warn!(%agent, error = ?e, "forge: ensure_config_repo after first spawn failed");
}
if let Some(core_token) = crate::forge::core_token()
&& let Err(e) = crate::forge::meta_read_access(agent, &core_token).await {
tracing::warn!(%agent, error = ?e, "forge: meta_read_access after first spawn failed");
}
if let Err(e) = crate::forge::ensure_meta_remote(agent).await {
tracing::warn!(%agent, error = ?e, "forge: ensure_meta_remote after first spawn failed");
}
coord.rescan_containers_and_emit().await;
crate::dashboard::emit_tombstones_snapshot(coord).await;
}
/// Inline (non-queued) handler for `ApprovalKind::InitConfig`. Just
/// seeds the proposed git repo + the per-agent dirs — sub-second
/// work that doesn't justify a queue card.
async fn run_approval_init_config(
coord: &Coordinator,
approval: hive_sh4re::Approval,
proposed_dir: std::path::PathBuf,
claude_dir: std::path::PathBuf,
notes_dir: std::path::PathBuf,
) -> Result<()> {
let result: Result<()> = async {
lifecycle::setup_proposed(&proposed_dir, &approval.agent).await?;
lifecycle::ensure_claude_dir(&claude_dir)?;
lifecycle::ensure_state_dir(&notes_dir)?;
Ok(())
}
.await;
if result.is_ok()
&& let Err(e) = crate::forge::ensure_meta_remote(&approval.agent).await
{
tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after init_config failed");
}
finish_approval(coord, &approval, result, None, false)
}
fn finish_approval(
coord: &Coordinator,
approval: &hive_sh4re::Approval,
result: Result<()>,
terminal_tag: Option<String>,
is_first_spawn: bool,
) -> Result<()> {
let (status, note, ok) = match &result {
Ok(()) => (ApprovalStatus::Approved, None, true),
Err(e) => {
let note = format!("{e:#}");
let _ = coord.approvals.mark_failed(approval.id, &note);
(ApprovalStatus::Failed, Some(note), false)
}
};
coord.notify_manager(&HelperEvent::ApprovalResolved {
id: approval.id,
agent: approval.agent.clone(),
commit_ref: approval.commit_ref.clone(),
status,
note: note.clone(),
sha: approval.fetched_sha.clone(),
tag: terminal_tag.clone(),
});
// Phase 5b: also fire on the dashboard event channel so the
// browser moves the row out of pending into history without a
// snapshot refetch. `approved` rows that succeed get the
// approval's logged resolved_at indirectly via `now_unix()`;
// failures already wrote it via mark_failed above.
let approval_kind = match approval.kind {
ApprovalKind::Spawn => "spawn",
ApprovalKind::ApplyCommit => "apply_commit",
ApprovalKind::InitConfig => "init_config",
ApprovalKind::UpdateMetaInputs => "update_meta_inputs",
ApprovalKind::SchedulePrompt => "schedule_prompt",
};
let sha_short = approval
.fetched_sha
.as_deref()
.map(|s| s[..s.len().min(12)].to_owned());
let status_str = if ok { "approved" } else { "failed" };
coord.emit_approval_resolved(
approval.id,
&approval.agent,
approval_kind,
sha_short,
status_str,
note.clone(),
approval.description.clone(),
);
// For spawn/rebuild/init_config approvals, also surface the underlying
// action so the manager knows whether the lifecycle step succeeded.
// The ApprovalResolved event already carries the same `ok` signal but
// separating it lets the manager react to the lifecycle change
// without having to special-case approvals.
match approval.kind {
ApprovalKind::InitConfig => {
if ok {
coord.notify_manager(&HelperEvent::ConfigReady {
agent: approval.agent.clone(),
});
}
}
ApprovalKind::Spawn => coord.notify_manager(&HelperEvent::Spawned {
agent: approval.agent.clone(),
ok,
note,
sha: approval.fetched_sha.clone(),
}),
ApprovalKind::ApplyCommit if is_first_spawn => {
coord.notify_manager(&HelperEvent::Spawned {
agent: approval.agent.clone(),
ok,
note,
sha: approval.fetched_sha.clone(),
});
}
ApprovalKind::ApplyCommit => coord.notify_manager(&HelperEvent::Rebuilt {
agent: approval.agent.clone(),
ok,
note,
sha: approval.fetched_sha.clone(),
tag: terminal_tag,
}),
// UpdateMetaInputs / SchedulePrompt: ApprovalResolved already
// carries the result. No separate lifecycle event needed.
ApprovalKind::UpdateMetaInputs | ApprovalKind::SchedulePrompt => {}
}
result
}
/// Tag-driven `ApplyCommit` handler. Walks the approval through the tag
/// state machine documented in `docs/approvals.md`: stamp
/// `approved/<id>` and `building/<id>` first so the audit trail
/// captures intent, then drop the candidate tree into the working dir
/// without moving HEAD, run the rebuild, and either fast-forward
/// `applied/main` to the proposal commit on success
/// (`deployed/<id>`) or annotate `failed/<id>` with the build error
/// and reset the working tree back to the last known-good main. main
/// never advances on a failed build, so a crash-and-recover doesn't
/// leave the agent pointing at a tree it can't evaluate.
#[allow(clippy::too_many_lines)] // sequential build/tag/notify pipeline; splitting would obscure the flow
async fn run_apply_commit(
coord: &Arc<Coordinator>,
approval: &hive_sh4re::Approval,
agent_dir: &std::path::Path,
applied_dir: &std::path::Path,
claude_dir: &std::path::Path,
notes_dir: &std::path::Path,
queue_entry_id: Option<u64>,
) -> (Result<()>, Option<String>, bool) {
let id = approval.id;
let proposal_ref = format!("refs/tags/proposal/{id}");
// Detect first spawn before we touch anything so we can branch on it
// throughout this function.
let is_first_spawn = !lifecycle::container_exists(&approval.agent).await;
// Defensive: submit-time should have planted proposal/<id>, but if
// the row was migrated from an older schema or the tag got pruned
// we fail early with a clear note rather than building a stale
// tree.
if let Err(e) = lifecycle::git_rev_parse(applied_dir, &proposal_ref).await {
return (
Err(anyhow::anyhow!(
"missing proposal tag {proposal_ref}: {e:#}"
)),
None,
is_first_spawn,
);
}
// Capture the currently-deployed sha so we can roll applied/main
// (and the meta lock indirectly) back if the build fails.
let prev_main_sha = match lifecycle::git_rev_parse(applied_dir, "refs/heads/main").await {
Ok(s) => s,
Err(e) => {
return (
Err(anyhow::anyhow!("read applied/main: {e:#}")),
None,
is_first_spawn,
)
}
};
coord.set_queue_step(queue_entry_id, "plant tags");
if let Err(e) = lifecycle::git_tag(applied_dir, &format!("approved/{id}"), &proposal_ref).await
{
return (
Err(anyhow::anyhow!("plant approved/{id}: {e:#}")),
None,
is_first_spawn,
);
}
if let Err(e) = lifecycle::git_tag(applied_dir, &format!("building/{id}"), &proposal_ref).await
{
return (
Err(anyhow::anyhow!("plant building/{id}: {e:#}")),
None,
is_first_spawn,
);
}
coord.set_queue_step(queue_entry_id, "fast-forward applied/main");
// Fast-forward applied/main to proposal/<id> + sync the working
// tree. Meta input pins `?ref=main`, so this is what makes nix
// re-lock to the proposal commit on the prepare_deploy step
// below. On build failure we roll main back to prev_main_sha so
// a crash leaves the agent on its last-good tree.
if let Err(e) = lifecycle::git_update_ref(applied_dir, "refs/heads/main", &proposal_ref).await {
return (
Err(anyhow::anyhow!("ff main to {proposal_ref}: {e:#}")),
None,
is_first_spawn,
);
}
if let Err(e) = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await {
// main is ahead; working tree didn't sync. Roll main back to
// keep the two consistent before bailing.
let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await;
return (
Err(anyhow::anyhow!("read-tree to main: {e:#}")),
None,
is_first_spawn,
);
}
// First spawn: sync_agents must add this agent to the meta flake
// before prepare_deploy can update its input lock (which won't
// exist yet if this is the agent's first deploy).
if is_first_spawn {
coord.set_queue_step(queue_entry_id, "meta sync_agents (first spawn)");
let agents = match lifecycle::agents_for_meta_listing_with(&approval.agent).await {
Ok(a) => a,
Err(e) => {
let _ =
lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha)
.await;
let _ = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await;
return (
Err(anyhow::anyhow!("agents_for_meta_listing_with: {e:#}")),
None,
is_first_spawn,
);
}
};
if let Err(e) = crate::meta::sync_agents(
&coord.hyperhive_flake,
coord.dashboard_port,
&coord.operator_pronouns,
&coord.context_window_tokens,
&agents,
)
.await
{
let _ =
lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await;
let _ = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await;
return (
Err(anyhow::anyhow!("meta sync_agents for first spawn: {e:#}")),
None,
is_first_spawn,
);
}
}
coord.set_queue_step(queue_entry_id, "meta prepare_deploy");
// Phase 1 of the meta two-phase deploy: relock without committing.
if let Err(e) = crate::meta::prepare_deploy(&approval.agent).await {
let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await;
let _ = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await;
return (
Err(anyhow::anyhow!("meta prepare_deploy: {e:#}")),
None,
is_first_spawn,
);
}
coord.set_queue_step(queue_entry_id, "nixos-container update");
// Container-level rebuild (or first-time create) against meta#<name>.
let build_result = lifecycle::rebuild_no_meta(
&approval.agent,
agent_dir,
applied_dir,
claude_dir,
notes_dir,
)
.await;
match build_result {
Ok(()) => {
coord.set_queue_step(queue_entry_id, "finalize deploy");
let tag = format!("deployed/{id}");
if let Err(e) = lifecycle::git_tag(applied_dir, &tag, &proposal_ref).await {
tracing::warn!(agent = %approval.agent, %id, error = ?e, "plant deployed tag failed");
}
if let Err(e) = crate::meta::finalize_deploy(
&approval.agent,
approval.fetched_sha.as_deref().unwrap_or(&proposal_ref),
&tag,
)
.await
{
// The build itself succeeded — meta lock landed but
// couldn't be committed. Surface as a soft warn so the
// operator can git-commit by hand if they care.
tracing::warn!(agent = %approval.agent, %id, error = ?e, "meta finalize_deploy failed");
}
// Wake the agent on its next turn so claude sees the
// config change took effect. Same hint pattern as
// auto_update::rebuild_agent — manager approved a
// proposal, agent picks up where it left off with the
// new env / packages.
coord.kick_agent(&approval.agent, "config update applied");
(Ok(()), Some(tag), is_first_spawn)
}
Err(e) => {
let tag = format!("failed/{id}");
let body = format!("{e:#}");
if let Err(te) =
lifecycle::git_tag_annotated(applied_dir, &tag, &proposal_ref, &body).await
{
tracing::warn!(agent = %approval.agent, %id, error = ?te, "annotate failed tag failed");
}
// Roll main back to last known-good so the on-disk state
// matches what nixos-container last successfully built.
if let Err(re) =
lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await
{
tracing::warn!(agent = %approval.agent, %id, error = ?re, "main rollback failed");
}
if let Err(re) = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await {
tracing::warn!(agent = %approval.agent, %id, error = ?re, "rollback read-tree failed");
}
// Drop the staged meta lock change so the deploy log
// only ever shows successes.
if let Err(ae) = crate::meta::abort_deploy().await {
tracing::warn!(agent = %approval.agent, %id, error = ?ae, "meta abort_deploy failed");
}
let _ = coord;
(Err(e), Some(tag), is_first_spawn)
}
}
}
/// Tear down a sub-agent container. By default this is non-destructive to
/// persistent state: the proposed/applied config repos and the Claude
/// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are
/// kept, so recreating an agent of the same name reuses prior config + creds
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`
/// is cleared because its contents (the mcp socket) don't survive restarts
/// anyway. With `purge=true` the persistent trees are also wiped — config
/// history, claude creds, notes — there is no undo.
/// Refuses the manager (declarative; would fight with the host's nixos config).
pub async fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) -> Result<()> {
if name == MANAGER_NAME || name == MANAGER_AGENT {
bail!("refusing to destroy the manager ({name})");
}
tracing::info!(%name, purge, "destroy");
// Guard auto-clears on the success path's final scope exit and on
// every early-return / cancellation along the way.
let guard = coord.transient_guard(name, TransientKind::Destroying);
lifecycle::destroy(name).await?;
coord.unregister_agent(name);
let runtime = Coordinator::agent_dir(name);
if runtime.exists() {
let _ = std::fs::remove_dir_all(&runtime);
}
if purge {
for dir in [
Coordinator::agent_state_root(name),
Coordinator::agent_applied_dir(name),
] {
if dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&dir)
{
tracing::warn!(error = ?e, dir = %dir.display(), "purge: remove failed");
}
}
}
// Meta flake: drop the agent's input + nixosConfiguration so a
// future spawn under the same name re-seeds cleanly, and so the
// meta lock doesn't reference a vanished applied repo. Log + keep
// going on failure — destroy already succeeded at the
// nixos-container level, the meta repo is just bookkeeping.
if let Err(e) = sync_meta_after_lifecycle(coord).await {
tracing::warn!(error = ?e, %name, "meta sync after destroy failed");
}
let _ = coord.approvals.fail_pending_for_agent(
name,
if purge {
"agent purged"
} else {
"agent destroyed"
},
);
drop(guard);
coord.notify_manager(&HelperEvent::Destroyed {
agent: name.to_owned(),
});
// Container row disappeared — rescan so the dashboard fires
// `ContainerRemoved` for the gone row, then emit the
// tombstones snapshot (gained one on destroy, lost one on
// purge — recompute either way).
coord.rescan_containers_and_emit().await;
crate::dashboard::emit_tombstones_snapshot(coord).await;
Ok(())
}
/// Rerender the meta flake from whatever containers still exist on
/// disk. Called after lifecycle ops that change the agent set (today:
/// destroy). Idempotent — a no-op when nothing changed.
async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> {
let agents = lifecycle::agents_for_meta_listing().await?;
crate::meta::sync_agents(
&coord.hyperhive_flake,
coord.dashboard_port,
&coord.operator_pronouns,
&coord.context_window_tokens,
&agents,
)
.await
}
pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
let approval = coord.approvals.get(id)?;
coord.approvals.mark_denied(id, note)?;
tracing::info!(%id, note, "approval denied");
let mut tag = None;
if let Some(a) = approval {
let sha = a.fetched_sha.clone();
// ApplyCommit approvals leave a `denied/<id>` tag on the
// proposal commit so rejected configs are first-class git
// objects — `git show denied/<id>` in the manager's applied
// mount yields both the tree the operator rejected and (in
// the annotated body) the reason. Spawn approvals have no
// commit to tag, so they fall through unannotated.
if matches!(a.kind, ApprovalKind::ApplyCommit) {
let applied_dir = Coordinator::agent_applied_dir(&a.agent);
let proposal_ref = format!("refs/tags/proposal/{id}");
if lifecycle::git_rev_parse(&applied_dir, &proposal_ref)
.await
.is_ok()
{
let tag_name = format!("denied/{id}");
let body = note.unwrap_or("").to_owned();
if let Err(e) =
lifecycle::git_tag_annotated(&applied_dir, &tag_name, &proposal_ref, &body)
.await
{
tracing::warn!(%id, error = ?e, "plant denied tag failed");
} else {
tag = Some(tag_name);
}
}
// Mirror the denied/<id> tag to the forge.
if let Err(e) = crate::forge::push_config(&a.agent).await {
tracing::warn!(%id, agent = %a.agent, error = ?e, "forge: push_config after deny failed");
}
}
let approval_kind = match a.kind {
ApprovalKind::Spawn => "spawn",
ApprovalKind::ApplyCommit => "apply_commit",
ApprovalKind::InitConfig => "init_config",
ApprovalKind::UpdateMetaInputs => "update_meta_inputs",
ApprovalKind::SchedulePrompt => "schedule_prompt",
};
let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned());
let description = a.description.clone();
let agent_owned = a.agent.clone();
coord.notify_manager(&HelperEvent::ApprovalResolved {
id: a.id,
agent: a.agent,
commit_ref: a.commit_ref,
status: ApprovalStatus::Denied,
note: note.map(String::from),
sha,
tag,
});
coord.emit_approval_resolved(
id,
&agent_owned,
approval_kind,
sha_short,
"denied",
note.map(String::from),
description,
);
}
Ok(())
}

View file

@ -0,0 +1,507 @@
//! Per-agent socket listener. Each socket file's existence on disk
//! authenticates the caller: connecting to `<.../agents/foo/mcp.sock>` means
//! you are `foo`.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use hive_sh4re::{AgentRequest, AgentResponse, Message};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::task::JoinHandle;
use crate::coordinator::Coordinator;
pub struct AgentSocket {
pub path: PathBuf,
pub handle: JoinHandle<()>,
}
pub fn start(agent: &str, socket_path: &Path, coord: Arc<Coordinator>) -> Result<AgentSocket> {
let agent = agent.to_owned();
if let Some(parent) = socket_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create agent socket dir {}", parent.display()))?;
}
if socket_path.exists() {
std::fs::remove_file(socket_path).context("remove stale agent socket")?;
}
let listener = UnixListener::bind(socket_path)
.with_context(|| format!("bind agent socket {}", socket_path.display()))?;
tracing::info!(%agent, socket = %socket_path.display(), "agent socket listening");
let path = socket_path.to_path_buf();
let handle = tokio::spawn(async move {
loop {
match listener.accept().await {
Ok((stream, _)) => {
let agent = agent.clone();
let coord = coord.clone();
tokio::spawn(async move {
if let Err(e) = serve(stream, agent, coord).await {
tracing::warn!(error = ?e, "agent connection failed");
}
});
}
Err(e) => {
tracing::warn!(error = ?e, "agent listener accept failed; exiting");
return;
}
}
}
});
Ok(AgentSocket { path, handle })
}
async fn serve(stream: UnixStream, agent: String, coord: Arc<Coordinator>) -> Result<()> {
let (read, mut write) = stream.into_split();
let mut reader = BufReader::new(read);
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line).await?;
if n == 0 {
return Ok(());
}
let resp = match serde_json::from_str::<AgentRequest>(line.trim()) {
Ok(req) => dispatch(&req, &agent, &coord).await,
Err(e) => AgentResponse::Err {
message: format!("parse error: {e}"),
},
};
let mut payload = serde_json::to_string(&resp)?;
payload.push('\n');
write.write_all(payload.as_bytes()).await?;
write.flush().await?;
}
}
/// Max long-poll window the caller can ask for; values above the
/// cap are clamped. 180s keeps us under typical TCP/proxy idle
/// limits while still letting agents park their turn until a
/// message arrives. Omitting `wait_seconds` (or passing `0`) means
/// "peek, don't wait" — claude can call recv whenever it wants a
/// cheap "is there anything pending?" check without blocking the
/// turn for 30 seconds. To actually park, the caller passes a
/// positive `wait_seconds`.
const RECV_LONG_POLL_MAX: std::time::Duration = std::time::Duration::from_secs(180);
/// Server-side hard cap on `Recv.max`. Bounds the size of a single
/// round-trip so a confused caller can't drain the entire inbox in
/// one go and blow past wire-buffer sizes; everything above the cap
/// silently clamps. 32 is comfortably above the burst sizes we've
/// seen in practice (post-rebuild rescue, multi-agent reply storms)
/// and well under the per-message `MESSAGE_MAX_BYTES` * N envelope
/// budget.
const RECV_BATCH_MAX: u32 = 32;
fn recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
match wait_seconds {
Some(s) => std::time::Duration::from_secs(s).min(RECV_LONG_POLL_MAX),
None => std::time::Duration::ZERO,
}
}
#[allow(clippy::too_many_lines)]
async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) -> AgentResponse {
let broker = &coord.broker;
match req {
AgentRequest::Send { to, body, in_reply_to } => {
handle_send(coord, agent, to, body, *in_reply_to)
}
AgentRequest::Recv { wait_seconds, max } => {
let cap = max.unwrap_or(1).min(RECV_BATCH_MAX) as usize;
match broker
.recv_blocking_batch(agent, recv_timeout(*wait_seconds), cap)
.await
{
Ok(deliveries) => AgentResponse::Messages {
messages: deliveries
.into_iter()
.map(|d| hive_sh4re::DeliveredMessage {
from: d.message.from,
body: d.message.body,
id: d.id,
redelivered: d.redelivered,
in_reply_to: d.message.in_reply_to,
})
.collect(),
},
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
}
}
AgentRequest::Status => match broker.count_pending(agent) {
Ok(unread) => AgentResponse::Status { unread },
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
},
AgentRequest::OperatorMsg { body } => match broker.send(&Message {
from: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
to: agent.to_owned(),
body: body.clone(),
in_reply_to: None,
}) {
Ok(()) => AgentResponse::Ok,
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
},
AgentRequest::Wake { from, body } => match broker.send(&Message {
from: from.clone(),
to: agent.to_owned(),
body: body.clone(),
in_reply_to: None,
}) {
Ok(()) => AgentResponse::Ok,
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
},
AgentRequest::Recent { limit } => match broker.recent_for(agent, *limit) {
Ok(rows) => AgentResponse::Recent { rows },
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
},
AgentRequest::Ask {
question,
options,
multi,
ttl_seconds,
to,
} => crate::questions::handle_ask(
coord,
agent,
question,
options,
*multi,
*ttl_seconds,
to.as_deref(),
)
.map_or_else(
|message| AgentResponse::Err { message },
|id| AgentResponse::QuestionQueued { id },
),
AgentRequest::Answer { id, answer } => crate::questions::handle_answer(
coord, agent, *id, answer,
)
.map_or_else(
|message| AgentResponse::Err { message },
|()| AgentResponse::Ok,
),
AgentRequest::Remind {
message,
timing,
file_path,
} => handle_remind(coord, agent, message, timing, file_path.as_deref()),
AgentRequest::GetLooseEnds => match crate::loose_ends::for_agent(coord, agent) {
Ok(loose_ends) => AgentResponse::LooseEnds { loose_ends },
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
},
AgentRequest::CountPendingReminders => {
match coord.broker.count_pending_reminders_for(agent) {
Ok(count) => AgentResponse::PendingRemindersCount { count },
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
}
}
AgentRequest::ReminderRollup { since_secs } => {
match coord.broker.reminder_rollup_for(agent, *since_secs) {
Ok(stats) => AgentResponse::ReminderRollup(stats),
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
}
}
AgentRequest::SetStatus { text } => {
let path = crate::coordinator::Coordinator::agent_notes_dir(agent)
.join("hyperhive-status");
let result = if text.trim().is_empty() {
// Empty = clear: remove the file (ignore missing).
std::fs::remove_file(&path)
.or_else(|e| if e.kind() == std::io::ErrorKind::NotFound {
Ok(())
} else {
Err(e)
})
} else {
std::fs::write(&path, format!("{}\n", text.trim()))
};
match result {
Ok(()) => {
// Kick a container rescan so the dashboard updates live.
let coord2 = Arc::clone(coord);
tokio::spawn(async move { coord2.rescan_containers_and_emit().await });
AgentResponse::Ok
}
Err(e) => AgentResponse::Err { message: format!("set_status write failed: {e}") },
}
}
AgentRequest::GetAgentMeta { name } => {
let target = name.as_deref().unwrap_or(agent);
// #432: gate status on the target's running state so a
// stopped container's stale on-disk status doesn't leak
// through. Also surface `running` itself so callers can
// tell (e.g. "iris is down" vs "iris has no status set").
let (status_text, status_set_at, running) =
crate::container_view::read_agent_status_live(target).await;
let role = if target == hive_sh4re::MANAGER_AGENT {
"manager"
} else {
"agent"
}
.to_owned();
AgentResponse::AgentMeta {
name: target.to_owned(),
role,
running,
hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake),
status_text,
status_set_at,
}
}
AgentRequest::CancelLooseEnd { kind, id } => crate::questions::handle_cancel_loose_end(
coord, agent, *kind, *id,
)
.map_or_else(
|message| AgentResponse::Err { message },
|()| AgentResponse::Ok,
),
AgentRequest::AckTurn => match broker.ack_turn(agent) {
Ok(_n) => AgentResponse::Ok,
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
},
AgentRequest::RequeueInflight => match broker.requeue_inflight(agent) {
Ok(n) => {
if n > 0 {
tracing::info!(%agent, requeued = %n, "requeued in-flight messages");
}
AgentResponse::Ok
}
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
},
}
}
/// Common Send handler shared between dispatch arms. Applies the
/// 4 KiB body cap, then routes broadcast (`to == "*"`) vs unicast
/// through their respective broker calls. Pulled out of `dispatch`
/// to keep that function under the clippy too-many-lines limit; the
/// behaviour is identical to inlining.
fn handle_send(
coord: &Arc<Coordinator>,
agent: &str,
to: &str,
body: &str,
in_reply_to: Option<i64>,
) -> AgentResponse {
if let Err(message) = crate::limits::check_size("send", body) {
return AgentResponse::Err { message };
}
if to == "*" {
let errors = coord.broadcast_send(agent, body);
return if errors.is_empty() {
AgentResponse::Ok
} else {
AgentResponse::Err {
message: format!("broadcast failed for agents: {}", errors.join(", ")),
}
};
}
match coord.broker.send(&Message {
from: agent.to_owned(),
to: to.to_owned(),
body: body.to_owned(),
in_reply_to,
}) {
Ok(()) => AgentResponse::Ok,
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
}
}
fn handle_remind(
coord: &Arc<Coordinator>,
agent: &str,
message: &str,
timing: &hive_sh4re::ReminderTiming,
file_path: Option<&str>,
) -> AgentResponse {
match store_remind(coord, agent, message, timing, file_path) {
Ok(()) => AgentResponse::Ok,
Err(message) => AgentResponse::Err { message },
}
}
/// Shared remind-storage path used by both the agent and the manager
/// dispatchers. Validates timing, applies the auto-file overflow
/// dance (see [`prepare_remind_storage`]), and writes the reminder
/// row. Returns `Ok(())` on success, or a caller-ready error string
/// the dispatcher wraps in `*Response::Err`.
/// Maximum pending (un-delivered) reminders per agent. Exceeding this
/// causes `store_remind` to return an error so the agent knows to back
/// off instead of silently dropping. Override via
/// `HIVE_REMIND_MAX_PENDING_PER_AGENT`; set to `0` to disable the cap
/// (not recommended — a runaway agent can still flood the scheduler).
const DEFAULT_REMIND_MAX_PENDING: u64 = 50;
fn remind_max_pending() -> u64 {
std::env::var("HIVE_REMIND_MAX_PENDING_PER_AGENT")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.unwrap_or(DEFAULT_REMIND_MAX_PENDING)
}
pub(crate) fn store_remind(
coord: &Arc<Coordinator>,
agent: &str,
message: &str,
timing: &hive_sh4re::ReminderTiming,
file_path: Option<&str>,
) -> Result<(), String> {
let max = remind_max_pending();
if max > 0 {
let pending = coord
.broker
.count_pending_reminders_for(agent)
.unwrap_or(0);
if pending >= max {
return Err(format!(
"reminder rejected: agent `{agent}` already has {pending} pending \
reminders (cap {max}). Cancel some via `cancel_loose_end` or wait \
for them to fire before scheduling more. Override the cap with \
`HIVE_REMIND_MAX_PENDING_PER_AGENT`."
));
}
}
let due_at = resolve_due_at(timing).map_err(|e| format!("invalid reminder timing: {e:#}"))?;
let (stored_message, stored_path) = prepare_remind_storage(agent, message, file_path)?;
let id = coord
.broker
.store_reminder(agent, &stored_message, stored_path.as_deref(), due_at)
.map_err(|e| format!("failed to store reminder: {e:#}"))?;
tracing::info!(%id, %agent, %due_at, "reminder scheduled");
Ok(())
}
/// Decide what we actually store in the reminders row, applying the
/// same byte cap as the rest of the wire protocol
/// ([`crate::limits::MESSAGE_MAX_BYTES`]). Three outcomes:
///
/// 1. Body within the cap → stored verbatim, with whatever `file_path`
/// the caller passed (None or Some). The scheduler honours
/// `file_path` at delivery time as before.
/// 2. Body over the cap, no caller `file_path` → auto-generate a path
/// under `/agents/<agent>/state/reminders/auto-<ts>.md`, write the
/// body to disk now, store a short pointer hint as the message and
/// clear `file_path` (so the scheduler doesn't re-write at
/// delivery and overwrite the body with the hint).
/// 3. Body over the cap, caller provided `file_path` → honour the
/// caller's path: write the body to it now, store the same hint
/// and clear `file_path` for the same reason as (2).
///
/// Returns `(stored_message, stored_file_path)` on success, or a
/// caller-ready error string on auto-save failure (which is the only
/// way a Remind request can be refused for size — the agent never has
/// to think about the cap).
fn prepare_remind_storage(
agent: &str,
message: &str,
file_path: Option<&str>,
) -> Result<(String, Option<String>), String> {
if message.len() <= crate::limits::MESSAGE_MAX_BYTES {
return Ok((message.to_owned(), file_path.map(str::to_owned)));
}
let req_path = match file_path {
Some(p) => p.to_owned(),
None => auto_reminder_path(agent),
};
let host_path = crate::reminder_scheduler::resolve_host_path(agent, &req_path)
.map_err(|reason| format!("auto-save path `{req_path}` rejected: {reason}"))?;
crate::reminder_scheduler::write_payload(agent, &host_path, message)
.map_err(|reason| format!("auto-save of large reminder body to `{req_path}` failed: {reason}"))?;
let hint = format!(
"[reminder body of {} bytes auto-saved to `{req_path}`; read with your filesystem tools]",
message.len()
);
Ok((hint, None))
}
/// Generate a per-agent path for an auto-saved reminder body. Uses
/// `unix_nanos` plus the agent name to keep collisions infinitesimal
/// across the agent's own state subtree (we're not stamping a hostname
/// since hive-c0re is single-host).
fn auto_reminder_path(agent: &str) -> String {
let ts_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("/agents/{agent}/state/reminders/auto-{ts_ns}.md")
}
/// Resolve the `due_at` unix timestamp for a Remind request. Returns
/// distinct error messages for each failure mode (overflow on
/// `InSeconds`, pre-epoch clock, `i64` cast wrap) so the caller can tell
/// what went wrong without inspecting the chain.
fn resolve_due_at(timing: &hive_sh4re::ReminderTiming) -> anyhow::Result<i64> {
use hive_sh4re::ReminderTiming;
match timing {
ReminderTiming::InSeconds { seconds } => {
let now = std::time::SystemTime::now();
let future = now
.checked_add(std::time::Duration::from_secs(*seconds))
.ok_or_else(|| {
anyhow::anyhow!("InSeconds overflow: {seconds}s exceeds system time range")
})?;
let duration = future
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| anyhow::anyhow!("system time before UNIX_EPOCH: {e}"))?;
i64::try_from(duration.as_secs())
.map_err(|e| anyhow::anyhow!("unix timestamp exceeds i64 range: {e}"))
}
ReminderTiming::At { unix_timestamp } => Ok(*unix_timestamp),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auto_reminder_path_format() {
let p = auto_reminder_path("damocles");
assert!(p.starts_with("/agents/damocles/state/reminders/auto-"));
assert!(
std::path::Path::new(&p)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
);
}
#[test]
fn prepare_remind_storage_passthrough_under_cap() {
let (msg, fp) = prepare_remind_storage("foo", "small body", None).unwrap();
assert_eq!(msg, "small body");
assert_eq!(fp, None);
}
#[test]
fn prepare_remind_storage_passthrough_with_caller_file_path() {
let (msg, fp) =
prepare_remind_storage("foo", "small", Some("/agents/foo/state/x.md")).unwrap();
assert_eq!(msg, "small");
assert_eq!(fp.as_deref(), Some("/agents/foo/state/x.md"));
}
}

519
hive-c0re/src/approvals.rs Normal file
View file

@ -0,0 +1,519 @@
//! Approval queue. Manager submits via `RequestApplyCommit`; the user
//! approves/denies via the host admin CLI; on approval the host runs the
//! corresponding action (Phase 5a: `lifecycle::rebuild(agent)`).
use std::path::Path;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, bail};
use hive_sh4re::{Approval, ApprovalKind, ApprovalStatus};
use rusqlite::{Connection, OptionalExtension, params};
const SCHEMA: &str = r"
CREATE TABLE IF NOT EXISTS approvals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent TEXT NOT NULL,
commit_ref TEXT NOT NULL,
requested_at INTEGER NOT NULL,
status TEXT NOT NULL,
resolved_at INTEGER,
note TEXT
);
CREATE INDEX IF NOT EXISTS idx_approvals_pending
ON approvals (id) WHERE status = 'pending';
";
/// Add the `description` column to pre-description databases. Manager-supplied
/// note shown on the dashboard approval card at submission time (distinct from
/// `note` which is set on denial/failure).
fn ensure_description_column(conn: &Connection) -> Result<()> {
let has: bool = conn
.prepare("SELECT 1 FROM pragma_table_info('approvals') WHERE name = 'description'")?
.exists([])?;
if !has {
conn.execute_batch("ALTER TABLE approvals ADD COLUMN description TEXT;")
.context("add approvals.description column")?;
}
Ok(())
}
/// Add the `kind` column to pre-Phase-8 databases. ALTER TABLE ADD COLUMN is
/// idempotent here only via a column-existence check (sqlite doesn't support
/// IF NOT EXISTS on ADD COLUMN). Defaults legacy rows to `apply_commit`,
/// which matches their actual semantics.
fn ensure_kind_column(conn: &Connection) -> Result<()> {
let has_kind: bool = conn
.prepare("SELECT 1 FROM pragma_table_info('approvals') WHERE name = 'kind'")?
.exists([])?;
if !has_kind {
conn.execute_batch(
"ALTER TABLE approvals ADD COLUMN kind TEXT NOT NULL DEFAULT 'apply_commit';",
)
.context("add approvals.kind column")?;
}
Ok(())
}
/// Same shape as `ensure_kind_column` but for `fetched_sha` — the
/// canonical sha hive-c0re vouched for at `request_apply_commit` time.
/// Distinct from `commit_ref` (manager-supplied, may not even resolve
/// in proposed by the time we approve).
fn ensure_fetched_sha_column(conn: &Connection) -> Result<()> {
let has: bool = conn
.prepare("SELECT 1 FROM pragma_table_info('approvals') WHERE name = 'fetched_sha'")?
.exists([])?;
if !has {
conn.execute_batch("ALTER TABLE approvals ADD COLUMN fetched_sha TEXT;")
.context("add approvals.fetched_sha column")?;
}
Ok(())
}
pub struct Approvals {
conn: Mutex<Connection>,
}
impl Approvals {
pub fn open(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create approvals db parent {}", parent.display()))?;
}
let conn = Connection::open(path)
.with_context(|| format!("open approvals db {}", path.display()))?;
conn.execute_batch(SCHEMA)
.context("apply approvals schema")?;
ensure_kind_column(&conn).context("migrate approvals.kind")?;
ensure_fetched_sha_column(&conn).context("migrate approvals.fetched_sha")?;
ensure_description_column(&conn).context("migrate approvals.description")?;
Ok(Self {
conn: Mutex::new(conn),
})
}
pub fn submit_kind(
&self,
agent: &str,
kind: ApprovalKind,
commit_ref: &str,
description: Option<&str>,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status, description)
VALUES (?1, ?2, ?3, ?4, 'pending', ?5)",
params![
agent,
kind_to_str(kind),
commit_ref,
now_unix(),
description
],
)?;
Ok(conn.last_insert_rowid())
}
/// Record the canonical sha hive-c0re fetched from the proposed repo
/// into applied at submission time. Idempotent on identical values.
pub fn set_fetched_sha(&self, id: i64, sha: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE approvals SET fetched_sha = ?1 WHERE id = ?2",
params![sha, id],
)?;
Ok(())
}
/// Last `limit` resolved approvals (approved / denied / failed),
/// newest-first. Drives the history tab on the dashboard.
pub fn recent_resolved(&self, limit: u64) -> Result<Vec<Approval>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description
FROM approvals
WHERE status IN ('approved', 'denied', 'failed', 'cancelled')
ORDER BY resolved_at DESC, id DESC
LIMIT ?1",
)?;
let rows = stmt.query_map([limit], row_to_approval)?;
Ok(collect_lenient(rows))
}
pub fn pending(&self) -> Result<Vec<Approval>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description
FROM approvals
WHERE status = 'pending'
ORDER BY id ASC",
)?;
let rows = stmt.query_map([], row_to_approval)?;
Ok(collect_lenient(rows))
}
pub fn get(&self, id: i64) -> Result<Option<Approval>> {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description
FROM approvals WHERE id = ?1",
params![id],
row_to_approval,
)
.optional()
.map_err(Into::into)
}
/// Mark pending -> approved (or fail if not pending). Returns the (now-updated)
/// approval so the caller can run the action and pass the agent name.
#[allow(clippy::type_complexity)]
pub fn mark_approved(&self, id: i64) -> Result<Approval> {
let conn = self.conn.lock().unwrap();
// Row shape: (agent, kind, commit_ref, requested_at, status,
// fetched_sha, description).
let current: Option<(
String,
String,
String,
i64,
String,
Option<String>,
Option<String>,
)> = conn
.query_row(
"SELECT agent, kind, commit_ref, requested_at, status, fetched_sha, description
FROM approvals WHERE id = ?1",
params![id],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
))
},
)
.optional()?;
let Some((agent, kind, commit_ref, requested_at, status, fetched_sha, description)) =
current
else {
bail!("approval {id} not found");
};
if status != "pending" {
bail!("approval {id} is {status}, not pending");
}
let resolved_at = now_unix();
conn.execute(
"UPDATE approvals SET status = 'approved', resolved_at = ?1 WHERE id = ?2",
params![resolved_at, id],
)?;
Ok(Approval {
id,
agent,
kind: kind_from_str(&kind)?,
commit_ref,
requested_at,
status: ApprovalStatus::Approved,
resolved_at: Some(resolved_at),
note: None,
fetched_sha,
description,
})
}
pub fn mark_denied(&self, id: i64, note: Option<&str>) -> Result<()> {
let conn = self.conn.lock().unwrap();
let affected = conn.execute(
"UPDATE approvals SET status = 'denied', resolved_at = ?1, note = ?2
WHERE id = ?3 AND status = 'pending'",
params![now_unix(), note, id],
)?;
if affected == 0 {
bail!("approval {id} not pending");
}
Ok(())
}
pub fn mark_failed(&self, id: i64, note: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE approvals SET status = 'failed', resolved_at = ?1, note = ?2 WHERE id = ?3",
params![now_unix(), note, id],
)?;
Ok(())
}
/// Withdraw a pending approval (closes #250). Returns the now-updated
/// row so the caller can emit `ApprovalResolved` with the right
/// kind / agent / sha. Errors if the approval isn't pending — once
/// it's approved/denied/failed/cancelled, the resolution is final.
pub fn mark_cancelled(&self, id: i64, canceller: &str) -> Result<Approval> {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
let row: Option<(
String,
String,
String,
i64,
String,
Option<String>,
Option<String>,
)> = tx
.query_row(
"SELECT agent, kind, commit_ref, requested_at, status, fetched_sha, description
FROM approvals WHERE id = ?1",
params![id],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
))
},
)
.optional()?;
let Some((agent, kind, commit_ref, requested_at, status, fetched_sha, description)) = row
else {
bail!("approval {id} not found");
};
if status != "pending" {
bail!("approval {id} is {status}, not pending");
}
let resolved_at = now_unix();
let note = format!("cancelled by {canceller}");
tx.execute(
"UPDATE approvals SET status = 'cancelled', resolved_at = ?1, note = ?2 WHERE id = ?3",
params![resolved_at, note, id],
)?;
tx.commit()?;
Ok(Approval {
id,
agent,
kind: kind_from_str(&kind)?,
commit_ref,
requested_at,
status: ApprovalStatus::Cancelled,
resolved_at: Some(resolved_at),
note: Some(note),
fetched_sha,
description,
})
}
/// Mark every pending approval for `agent` as failed (returns rows affected).
/// Used by `destroy` to clear the queue of an agent that no longer exists.
pub fn fail_pending_for_agent(&self, agent: &str, note: &str) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let n = conn.execute(
"UPDATE approvals SET status = 'failed', resolved_at = ?1, note = ?2
WHERE agent = ?3 AND status = 'pending'",
params![now_unix(), note, agent],
)?;
Ok(n)
}
}
/// Collect approval rows, dropping (and logging) any that fail to
/// deserialize. A single malformed / unknown-kind row must never blank
/// the whole list: `collect::<Result<Vec>>()` is all-or-nothing, so one
/// bad row used to make `pending()` / `recent_resolved()` error out
/// wholesale — the dashboard then rendered an empty approvals queue
/// (issue #160, an unhandled `init_config` kind poisoning every read).
fn collect_lenient(
rows: impl Iterator<Item = rusqlite::Result<Approval>>,
) -> Vec<Approval> {
rows.filter_map(|r| match r {
Ok(a) => Some(a),
Err(e) => {
tracing::warn!(error = ?e, "skipping unparseable approval row");
None
}
})
.collect()
}
fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
// Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description.
let kind: String = row.get(2)?;
let kind = match kind.as_str() {
"apply_commit" => ApprovalKind::ApplyCommit,
"spawn" => ApprovalKind::Spawn,
"init_config" => ApprovalKind::InitConfig,
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
"schedule_prompt" => ApprovalKind::SchedulePrompt,
other => {
return Err(rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Text,
format!("unknown approval kind '{other}'").into(),
));
}
};
let status: String = row.get(5)?;
let status = match status.as_str() {
"pending" => ApprovalStatus::Pending,
"approved" => ApprovalStatus::Approved,
"denied" => ApprovalStatus::Denied,
"failed" => ApprovalStatus::Failed,
"cancelled" => ApprovalStatus::Cancelled,
other => {
return Err(rusqlite::Error::FromSqlConversionFailure(
5,
rusqlite::types::Type::Text,
format!("unknown approval status '{other}'").into(),
));
}
};
Ok(Approval {
id: row.get(0)?,
agent: row.get(1)?,
kind,
commit_ref: row.get(3)?,
requested_at: row.get(4)?,
status,
resolved_at: row.get(6)?,
note: row.get(7)?,
fetched_sha: row.get(8)?,
description: row.get(9)?,
})
}
/// Stable kind→str mapping used wherever we emit `ApprovalResolved`
/// or persist a kind to sqlite. `pub(crate)` so callers like
/// `questions::handle_cancel_loose_end` don't have to duplicate the
/// match; bumping a kind here is the single source of truth.
pub(crate) fn kind_to_str(kind: ApprovalKind) -> &'static str {
match kind {
ApprovalKind::ApplyCommit => "apply_commit",
ApprovalKind::Spawn => "spawn",
ApprovalKind::InitConfig => "init_config",
ApprovalKind::UpdateMetaInputs => "update_meta_inputs",
ApprovalKind::SchedulePrompt => "schedule_prompt",
}
}
fn kind_from_str(s: &str) -> Result<ApprovalKind> {
Ok(match s {
"apply_commit" => ApprovalKind::ApplyCommit,
"spawn" => ApprovalKind::Spawn,
"init_config" => ApprovalKind::InitConfig,
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
"schedule_prompt" => ApprovalKind::SchedulePrompt,
other => bail!("unknown approval kind '{other}'"),
})
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use hive_sh4re::ApprovalKind;
fn open_temp() -> (tempfile::TempDir, std::path::PathBuf, Approvals) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("approvals.sqlite");
let db = Approvals::open(&path).expect("open approvals db");
(dir, path, db)
}
#[test]
fn init_config_approval_round_trips() {
// Regression for #160: an `init_config` row used to fail
// deserialization (row_to_approval matched only apply_commit +
// spawn), erroring out the whole `pending()` query — every
// approval then vanished from the dashboard.
let (_dir, _path, db) = open_temp();
let id = db
.submit_kind("bitburner", ApprovalKind::InitConfig, "", Some("scaffold"))
.expect("submit init_config");
let pending = db
.pending()
.expect("pending() must not error on an init_config row");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, id);
assert!(matches!(pending[0].kind, ApprovalKind::InitConfig));
}
#[test]
fn mixed_kinds_all_listed() {
let (_dir, _path, db) = open_temp();
db.submit_kind("a", ApprovalKind::ApplyCommit, "deadbeef", None)
.unwrap();
db.submit_kind("b", ApprovalKind::Spawn, "", None).unwrap();
db.submit_kind("c", ApprovalKind::InitConfig, "", None)
.unwrap();
let pending = db.pending().expect("pending");
assert_eq!(pending.len(), 3, "all three kinds must be visible");
}
#[test]
fn mark_cancelled_transitions_pending_row() {
// #250: manager withdraws a pending approval. Row leaves
// pending(), shows up in recent_resolved() with the cancelled
// status + a "cancelled by <who>" note.
let (_dir, _path, db) = open_temp();
let id = db
.submit_kind("bitburner", ApprovalKind::ApplyCommit, "cafef00d", Some("test"))
.unwrap();
let row = db.mark_cancelled(id, "manager").expect("cancel");
assert_eq!(row.id, id);
assert!(matches!(row.status, ApprovalStatus::Cancelled));
assert_eq!(row.note.as_deref(), Some("cancelled by manager"));
assert!(row.resolved_at.is_some());
assert!(db.pending().unwrap().is_empty(), "row leaves pending");
let resolved = db.recent_resolved(10).unwrap();
assert_eq!(resolved.len(), 1);
assert!(matches!(resolved[0].status, ApprovalStatus::Cancelled));
}
#[test]
fn mark_cancelled_refuses_already_resolved_row() {
// Once approved/denied/failed/cancelled the resolution is
// final — re-cancelling errors instead of silently overwriting.
let (_dir, _path, db) = open_temp();
let id = db
.submit_kind("a", ApprovalKind::Spawn, "deadbeef", None)
.unwrap();
db.mark_cancelled(id, "manager").expect("first cancel");
let err = db
.mark_cancelled(id, "manager")
.expect_err("second cancel must fail");
assert!(err.to_string().contains("not pending"), "{err}");
}
#[test]
fn unknown_kind_row_is_skipped_not_fatal() {
// A single malformed / future-kind row must not blank the
// whole list — collect_lenient skips it instead of failing.
let (_dir, path, db) = open_temp();
let good = db
.submit_kind("good", ApprovalKind::ApplyCommit, "cafe", None)
.unwrap();
let raw = Connection::open(&path).unwrap();
raw.execute(
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
VALUES ('weird', 'from_the_future', '', 0, 'pending')",
[],
)
.unwrap();
let pending = db
.pending()
.expect("pending() must survive an unparseable row");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, good);
}
}

View file

@ -0,0 +1,241 @@
//! Startup auto-update: on `hive-c0re serve` boot, rebuild every known
//! container unconditionally. `nixos-container update` is a no-op at the
//! nix level when nothing changed (same store path), so the cost of always
//! running it on startup is low and avoids the complexity of rev-marker
//! staleness (issue #179: all agents always needed update when any meta
//! commit landed).
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use crate::coordinator::Coordinator;
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
/// Marker file recording the hyperhive rev a sub-agent's container was last
/// built against. Sibling of `applied/<name>/` (rather than inside it) to
/// keep it out of the applied repo's git history. Uses a leading dot so a
/// glob over `applied/*` doesn't include it.
pub fn rev_marker_path(name: &str) -> PathBuf {
PathBuf::from(format!("/var/lib/hyperhive/applied/.{name}.hyperhive-rev"))
}
/// Resolve the current rev of `hyperhive_flake`. For a path on disk we
/// canonicalize (following symlinks) so a /etc/hyperhive → /nix/store/...
/// update yields a different string. For anything else we return None.
#[must_use]
pub fn current_flake_rev(hyperhive_flake: &str) -> Option<String> {
let path = Path::new(hyperhive_flake);
if !path.exists() {
return None;
}
std::fs::canonicalize(path)
.ok()
.map(|p| p.display().to_string())
}
/// Returns true when the applied repo has commits that have not yet been
/// deployed (i.e. the applied HEAD differs from the sha currently locked in
/// meta's flake.lock). This is the semantic the dashboard `needs_update` chip
/// conveys: "there is a config change ready to apply via rebuild."
#[must_use]
pub fn agent_config_pending(name: &str, deployed_sha: Option<&str>) -> bool {
let applied_head = std::process::Command::new("git")
.args([
"-C",
&format!("/var/lib/hyperhive/applied/{name}"),
"rev-parse",
"HEAD",
])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_owned());
match (applied_head.as_deref(), deployed_sha) {
(Some(head), Some(sha)) => !head.starts_with(sha) && !sha.starts_with(head),
_ => false,
}
}
/// Rebuild one sub-agent and refresh its marker. Used by both the startup
/// scanner and the dashboard's manual "update" button so the two paths
/// can't diverge.
///
/// `queue_entry_id` is `Some(id)` when the rebuild was dispatched from
/// the rebuild_queue worker (lets the function annotate its phase via
/// `coord.set_queue_step`) and `None` when called directly (e.g. the
/// manager-migration nudge in `ensure_manager`).
pub async fn rebuild_agent(
coord: &Arc<Coordinator>,
name: &str,
current_rev: &str,
queue_entry_id: Option<u64>,
) -> Result<()> {
tracing::info!(%name, rev = %current_rev, "rebuild agent");
let agent_dir = coord
.ensure_runtime(name)
.with_context(|| format!("ensure_runtime {name}"))?;
let applied_dir = Coordinator::agent_applied_dir(name);
let claude_dir = Coordinator::agent_claude_dir(name);
let notes_dir = Coordinator::agent_notes_dir(name);
// Suppress crash_watch during the stop+start window inside
// lifecycle::rebuild. Dashboard rebuilds already do this via
// lifecycle_action; this catches the auto-update scan + any
// other direct caller.
let guard = coord.transient_guard(name, crate::coordinator::TransientKind::Rebuilding);
coord.set_queue_step(queue_entry_id, "nixos-container update");
let result = lifecycle::rebuild(
name,
&coord.hyperhive_flake,
&agent_dir,
&applied_dir,
&claude_dir,
&notes_dir,
coord.dashboard_port,
&coord.operator_pronouns,
&coord.context_window_tokens,
)
.await;
drop(guard);
match &result {
Ok(()) => {
if let Err(e) = std::fs::write(rev_marker_path(name), current_rev) {
tracing::warn!(%name, error = ?e, "write rev marker failed");
}
coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {
agent: name.to_owned(),
ok: true,
note: None,
sha: None,
tag: None,
});
coord.set_queue_step(queue_entry_id, "forge sync");
// Run the full forge sync on every successful rebuild so
// the rebuild path is equivalent to the hive-c0re startup
// sweep: token, config-repo mirror, meta read access, and
// meta remote are all kept in sync. Recovers missing tokens
// (e.g. first-spawn seeding failed transiently) without
// requiring a full hive-c0re restart.
crate::forge::sync_agent(name, crate::forge::core_token().as_deref()).await;
// Wake the agent on its next turn so claude sees a
// "you were rebuilt — check /state/ for notes, --continue
// session intact" hint. Covers dashboard rebuild, admin
// CLI rebuild, auto-update startup scan, and the
// dashboard's meta-input update path — all of which
// route through rebuild_agent.
coord.kick_agent(name, "container rebuilt");
// Container state (needs_update, deployed_sha) may have
// shifted — rescan so dashboards drop the "needs update"
// chip without waiting for the next /api/state poll.
coord.rescan_containers_and_emit().await;
// Lock bump → meta-inputs panel needs to re-render.
crate::dashboard::emit_meta_inputs_snapshot(coord);
}
Err(e) => {
coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {
agent: name.to_owned(),
ok: false,
note: Some(format!("{e:#}")),
sha: None,
tag: None,
});
coord.rescan_containers_and_emit().await;
}
}
result
}
/// Auto-create the manager container on startup if it isn't already there.
/// hive-c0re manages hm1nd end-to-end (Phase 8 follow-up): operators no
/// longer declare `containers.hm1nd` in their host NixOS config. Bypasses
/// the approval queue — manager is required infrastructure. Idempotent.
pub async fn ensure_manager(coord: &Arc<Coordinator>) -> Result<()> {
let existing = lifecycle::list().await.unwrap_or_default();
let current_rev = current_flake_rev(&coord.hyperhive_flake);
if existing.iter().any(|c| c == MANAGER_NAME) {
// Container exists already. If it predates the unified lifecycle
// (no applied flake on disk) we must rebuild — otherwise it's
// running whatever the host-declarative config was at create
// time, with a wrong systemd unit and port.
let applied_flake = Coordinator::agent_applied_dir(MANAGER_NAME).join("flake.nix");
if !applied_flake.exists()
&& let Some(rev) = current_rev.as_ref()
{
tracing::warn!(
"manager container exists but no applied flake — forcing rebuild to migrate"
);
let coord_clone = coord.clone();
if let Err(e) = rebuild_agent(&coord_clone, MANAGER_NAME, rev.as_str(), None).await {
tracing::warn!(error = ?e, "manager migration rebuild failed");
}
} else {
tracing::debug!("manager container already present");
}
return Ok(());
}
tracing::info!("manager container missing — spawning");
let runtime = coord.ensure_runtime(MANAGER_NAME)?;
let proposed = Coordinator::agent_proposed_dir(MANAGER_NAME);
let applied = Coordinator::agent_applied_dir(MANAGER_NAME);
let claude_dir = Coordinator::agent_claude_dir(MANAGER_NAME);
let notes_dir = Coordinator::agent_notes_dir(MANAGER_NAME);
lifecycle::spawn(
MANAGER_NAME,
&coord.hyperhive_flake,
&runtime,
&proposed,
&applied,
&claude_dir,
&notes_dir,
coord.dashboard_port,
&coord.operator_pronouns,
&coord.context_window_tokens,
)
.await?;
if let Some(rev) = current_rev {
let _ = std::fs::write(rev_marker_path(MANAGER_NAME), &rev);
}
Ok(())
}
/// Rebuild every container on startup. Sequential to avoid nix-store sqlite
/// races and keep logs readable. Returns Ok even if some rebuilds failed.
pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
// Bump meta's hyperhive input up-front so per-agent rebuilds build
// against the latest base. Non-fatal on failure.
if let Err(e) = crate::meta::lock_update_hyperhive().await {
tracing::warn!(error = ?e, "auto-update: meta lock_update_hyperhive failed");
}
let containers = match lifecycle::list().await {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = ?e, "auto-update: nixos-container list failed");
return Ok(());
}
};
let _current_rev = current_flake_rev(&coord.hyperhive_flake).unwrap_or_default();
tracing::info!(agents = containers.len(), "auto-update: queueing all on startup");
for container in containers {
let logical = if container == MANAGER_NAME {
Some(MANAGER_NAME.to_owned())
} else {
container.strip_prefix(AGENT_PREFIX).map(str::to_owned)
};
let Some(name) = logical else { continue };
coord.rebuild_queue.enqueue(
crate::rebuild_queue::QueueKind::Rebuild,
name,
crate::rebuild_queue::QueueSource::AutoUpdate,
"startup sweep".to_owned(),
None,
);
}
coord.emit_rebuild_queue_snapshot();
Ok(())
}

1133
hive-c0re/src/broker.rs Normal file

File diff suppressed because it is too large Load diff

27
hive-c0re/src/client.rs Normal file
View file

@ -0,0 +1,27 @@
use std::path::Path;
use anyhow::{Context, Result, bail};
use hive_sh4re::{HostRequest, HostResponse};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
pub async fn request(socket: &Path, req: HostRequest) -> Result<HostResponse> {
let stream = UnixStream::connect(socket)
.await
.with_context(|| format!("connect to {}", socket.display()))?;
let (read, mut write) = stream.into_split();
let mut payload = serde_json::to_string(&req)?;
payload.push('\n');
write.write_all(payload.as_bytes()).await?;
write.flush().await?;
let mut reader = BufReader::new(read);
let mut line = String::new();
reader.read_line(&mut line).await?;
if line.is_empty() {
bail!("server closed connection without responding");
}
let resp: HostResponse = serde_json::from_str(line.trim())?;
Ok(resp)
}

View file

@ -0,0 +1,412 @@
//! `ContainerView` + the snapshot builder that turns
//! `nixos-container list` (plus per-agent state on disk) into the row
//! shape the dashboard renders. Extracted from `dashboard.rs` so the
//! coordinator's rescan-and-emit helper can build the same view and
//! diff against the last snapshot to fire
//! `ContainerStateChanged` / `ContainerRemoved` events.
use std::collections::HashMap;
use std::path::Path;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use crate::coordinator::Coordinator;
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
/// An agent-declared extra navigation link surfaced on the dashboard card.
/// Written by the `hive-dashboard-links` NixOS oneshot into
/// `{state_dir}/hyperhive-dashboard-links.json` and read by `build_all`.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Default)]
pub struct DashboardLink {
pub label: String,
#[serde(default)]
pub icon: String,
pub url: String,
}
#[derive(Serialize, Clone, PartialEq, Eq, Debug)]
#[allow(clippy::struct_excessive_bools)]
pub struct ContainerView {
/// Logical agent name (no `h-` prefix). Used in action URLs.
pub name: String,
/// Container name as nixos-container sees it (`h-foo`, `hm1nd`).
pub container: String,
pub is_manager: bool,
pub port: u16,
pub running: bool,
pub needs_update: bool,
pub needs_login: bool,
/// First 12 chars of the sha the meta flake currently has locked
/// for this agent's input.
#[serde(skip_serializing_if = "Option::is_none")]
pub deployed_sha: Option<String>,
/// Count of this agent's pending reminders. Computed during
/// `build_all` via `Broker::count_pending_reminders_for`; the
/// dashboard renders a small chip when > 0. Updates with the
/// 10s `crash_watch` rescan + every container mutation site;
/// not real-time on remind/cancel-reminder but close enough.
#[serde(default)]
pub pending_reminders: u64,
/// Context-window size (prompt tokens) from the agent's most recent
/// completed turn, read directly from the turn-stats `SQLite`.
/// `None` when the file is absent or the agent has no turns yet.
/// Stale by up to one crash-watch cycle (~10s); good enough for
/// the "which agent is close to the window?" dashboard glance.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ctx_tokens: Option<u64>,
/// Context-window size (tokens) for the model this agent ran on its
/// most recent turn — the model name from the last turn-stats row
/// resolved against the host's per-model `contextWindowTokens`
/// config. Lets the dashboard derive the ctx badge thresholds
/// (75% / 50% of the window, matching the harness compaction
/// watermarks) instead of hardcoding them. `None` when the agent
/// has no turns yet or no config key matches the model. (issue #66)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window_tokens: Option<u64>,
/// True while the harness is parked after an API rate-limit response.
/// Detected via the sentinel file `{state_dir}/hyperhive-rate-limited`
/// that the harness writes in `Bus::emit_status("rate_limited")` and
/// removes when it resumes. Stale by up to one crash-watch cycle.
#[serde(default)]
pub rate_limited: bool,
/// Extra navigation links declared by the agent via
/// `hyperhive.dashboardLinks` in `agent.nix`. Written to
/// `{state_dir}/hyperhive-dashboard-links.json` by the
/// `hive-dashboard-links` oneshot at container boot. Empty when
/// the file is absent or the agent declares no links.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_links: Vec<DashboardLink>,
/// Free-text status set by the agent via `mcp__hyperhive__set_status`.
/// Persisted to `{state_dir}/hyperhive-status`. `None` when the file
/// is absent or empty — the agent hasn't set one yet.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_text: Option<String>,
/// Unix timestamp (seconds since epoch) when the status was last written.
/// Derived from the `hyperhive-status` file's mtime. `None` when no
/// status is set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_set_at: Option<i64>,
/// Name of this agent's parent in the agent hierarchy (#361). `None`
/// marks the agent as root-level; the dashboard renders it without
/// indentation. Sourced from `meta/topology.json` (single source of
/// truth, hive-c0re-owned) — NOT from per-agent agent.nix, because
/// an agent shouldn't be able to unilaterally declare its own place
/// in the tree.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
}
/// Build the full container list. Wraps `lifecycle::list()` and
/// resolves every per-agent attribute the dashboard surfaces.
pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
let raw = lifecycle::list().await.unwrap_or_default();
let locked = read_meta_locked_revs();
// Pull the topology map once and look up each agent's parent below.
// Empty / absent topology.json → every agent root-level (matches
// the pre-#361 status quo for fresh installs).
let topology = crate::topology::read();
let mut out = Vec::new();
for c in &raw {
let (logical, is_manager) = if c == MANAGER_NAME {
(MANAGER_NAME.to_owned(), true)
} else if let Some(n) = c.strip_prefix(AGENT_PREFIX) {
(n.to_owned(), false)
} else {
continue;
};
let deployed_full = locked.get(&format!("agent-{logical}")).map(std::string::String::as_str);
let needs_update = crate::auto_update::agent_config_pending(&logical, deployed_full);
let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned());
// Recipient name the broker uses for this agent — sub-agents
// are addressed by logical name, the manager by the
// MANAGER_AGENT constant. Mirrors the rest of the broker
// surface so the count matches what `mcp__hyperhive__remind`
// queued.
let reminder_recipient = if is_manager {
hive_sh4re::MANAGER_AGENT
} else {
logical.as_str()
};
let pending_reminders = coord
.broker
.count_pending_reminders_for(reminder_recipient)
.unwrap_or(0);
let extra_links = read_dashboard_links(&logical);
let parent = topology.get(&logical).cloned().flatten();
let running = lifecycle::is_running(&logical).await;
// Live-only fields (#432) — only meaningful while the harness
// is up. When the container is stopped, sentinel files +
// turn-stats rows + the on-disk status blob are all stale
// snapshots from before the stop, so we clear them here
// rather than letting the dashboard / `get_agent_meta` surface
// misleading values. Static / declared fields (extra_links,
// deployed_sha, pending_reminders, needs_update, parent) stay
// populated regardless of run state.
let (needs_login, ctx_tokens, context_window_tokens, rate_limited, status_text, status_set_at) =
if running {
// needs_login fires when EITHER the claude session dir is
// missing (boot-time / fresh container) OR the harness wrote
// the auth-failed sentinel because a turn hit 401 (#419). The
// manager has its own session lifecycle and never participates
// in needs_login.
let needs_login = !is_manager
&& (!claude_has_session(&Coordinator::agent_claude_dir(&logical))
|| auth_failed_sentinel(&logical));
let last_turn = read_last_turn(&logical);
let ctx_tokens = last_turn.as_ref().map(|(toks, _)| *toks);
let context_window_tokens = last_turn
.as_ref()
.and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens));
let rate_limited = is_rate_limited(&logical);
let (status_text, status_set_at) = read_status(&logical);
(needs_login, ctx_tokens, context_window_tokens, rate_limited, status_text, status_set_at)
} else {
(false, None, None, false, None, None)
};
out.push(ContainerView {
port: lifecycle::agent_web_port(&logical),
running,
container: c.clone(),
name: logical,
is_manager,
needs_update,
needs_login,
deployed_sha,
pending_reminders,
ctx_tokens,
context_window_tokens,
rate_limited,
extra_links,
status_text,
status_set_at,
parent,
});
}
out
}
/// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true
/// if the agent's bound `~/.claude/` dir on disk contains any regular
/// file. Reads each `build_all()` so a login driven from the agent's
/// own web UI reflects on the next snapshot.
pub fn claude_has_session(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries
.flatten()
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
}
/// Read agent-declared extra dashboard links from
/// `{state_dir}/hyperhive-dashboard-links.json`. Returns an empty vec when
/// the file is absent, empty, or unparseable — best-effort, never panics.
fn read_dashboard_links(name: &str) -> Vec<DashboardLink> {
let path = Coordinator::agent_notes_dir(name).join("hyperhive-dashboard-links.json");
let text = match std::fs::read_to_string(&path) {
Ok(t) if !t.trim().is_empty() => t,
_ => return Vec::new(),
};
serde_json::from_str::<Vec<DashboardLink>>(&text).unwrap_or_default()
}
/// Returns true if the agent's harness is currently parked after an API
/// rate-limit response. Detected via the sentinel file written by
/// `hive_ag3nt::events::Bus::emit_status("rate_limited")`.
fn is_rate_limited(name: &str) -> bool {
Coordinator::agent_notes_dir(name)
.join("hyperhive-rate-limited")
.exists()
}
/// True when the harness wrote `{state_dir}/hyperhive-needs-login`
/// after a 401 mid-turn. Lets the dashboard surface `needs_login` for
/// agents whose `/root/.claude/` dir still exists (so
/// `claude_has_session` returns true) but whose OAuth credentials
/// inside it have actually expired (#419).
fn auth_failed_sentinel(name: &str) -> bool {
Coordinator::agent_notes_dir(name)
.join("hyperhive-needs-login")
.exists()
}
/// Read the agent's free-text status and the Unix timestamp when it was last set
/// (derived from the file's mtime). Returns `(None, None)` when the file is absent
/// or empty. `pub` so `agent_server` and `manager_server` can populate `AgentMeta`.
///
/// NB: callers building `AgentMeta` for a *stopped* container should
/// clear the result — the on-disk status is a stale snapshot from
/// before the stop (#432). Use `read_agent_status_live` for that.
pub fn read_agent_status(name: &str) -> (Option<String>, Option<i64>) {
let path = Coordinator::agent_notes_dir(name).join("hyperhive-status");
let meta = std::fs::metadata(&path).ok();
let s = std::fs::read_to_string(&path).ok();
let text = s.as_deref().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned);
let mtime = meta.and_then(|m| {
m.modified().ok().and_then(|t| {
t.duration_since(std::time::UNIX_EPOCH).ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
})
});
if text.is_none() { (None, None) } else { (text, mtime) }
}
fn read_status(name: &str) -> (Option<String>, Option<i64>) {
read_agent_status(name)
}
/// Wraps `read_agent_status` with the same "stopped containers have
/// stale state" gate `build_all` uses (#432). Returns
/// `(None, None, false)` when the container isn't running so callers
/// don't have to know about the sentinel rules — they just hand back
/// what we give them.
///
/// Returned tuple is `(status_text, status_set_at, running)`. The
/// `name` argument is the broker-side recipient — `MANAGER_AGENT` for
/// the manager, the logical agent name otherwise — so callers can
/// reuse the same string they used to look the agent up.
pub async fn read_agent_status_live(name: &str) -> (Option<String>, Option<i64>, bool) {
// The lifecycle helper wants the on-disk name (`hm1nd` for the
// manager, the bare logical name for sub-agents) and internally
// adds the `h-` prefix. Map the broker-side `MANAGER_AGENT`
// sentinel back to the lifecycle name here so callers don't have
// to bother.
let lifecycle_name = if name == hive_sh4re::MANAGER_AGENT {
lifecycle::MANAGER_NAME
} else {
name
};
if !lifecycle::is_running(lifecycle_name).await {
return (None, None, false);
}
let (text, set_at) = read_agent_status(name);
(text, set_at, true)
}
/// Read the agent's most recent completed turn from its turn-stats
/// `SQLite`: the context-window size (prompt tokens) and the model name.
/// Returns `None` when the file is absent or has no rows. Best-effort
/// — any database error silently yields `None` so a missing or
/// corrupt file never blocks `build_all`.
///
/// Context tokens sum the prompt-side fields (`last_input_tokens`,
/// `last_cache_read_input_tokens`, `last_cache_creation_input_tokens`),
/// mirroring `hive_ag3nt::events::TokenUsage::context_tokens`.
fn read_last_turn(name: &str) -> Option<(u64, String)> {
let path = Coordinator::agent_notes_dir(name).join("hyperhive-turn-stats.sqlite");
let conn = Connection::open_with_flags(
&path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.ok()?;
conn.query_row(
"SELECT last_input_tokens + last_cache_read_input_tokens + last_cache_creation_input_tokens, model \
FROM turn_stats ORDER BY started_at DESC LIMIT 1",
[],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
)
.ok()
.and_then(|(toks, model)| Some((u64::try_from(toks).ok()?, model)))
}
/// Resolve a model name to its context-window size using the host's
/// per-model `contextWindowTokens` config. Mirrors the harness's
/// `events::context_window_tokens` substring match: the first config
/// key (lowercased, non-empty) that is a substring of the lowercased
/// model name wins. `None` when nothing matches.
fn resolve_ctx_window(model: &str, per_model: &HashMap<String, u64>) -> Option<u64> {
let m = model.to_ascii_lowercase();
per_model
.iter()
.find(|(key, _)| {
let k = key.to_ascii_lowercase();
!k.is_empty() && m.contains(&k)
})
.map(|(_, &tokens)| tokens)
}
/// Map of `agent-<n>` → locked sha from meta's flake.lock. Used to
/// render the `deployed:<sha12>` chip per container row.
fn read_meta_locked_revs() -> HashMap<String, String> {
let mut out = HashMap::new();
let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else {
return out;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
return out;
};
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
return out;
};
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
return out;
};
let Some(root_inputs) = nodes
.get(root_name)
.and_then(|n| n.get("inputs"))
.and_then(|v| v.as_object())
else {
return out;
};
for alias in root_inputs.keys() {
let target_name = match root_inputs.get(alias) {
Some(serde_json::Value::String(s)) => s.clone(),
_ => continue,
};
if let Some(rev) = nodes
.get(&target_name)
.and_then(|n| n.get("locked"))
.and_then(|v| v.get("rev"))
.and_then(|v| v.as_str())
{
out.insert(alias.clone(), rev.to_owned());
}
}
out
}
#[cfg(test)]
mod tests {
use super::resolve_ctx_window;
use std::collections::HashMap;
fn cfg() -> HashMap<String, u64> {
[
("haiku".to_owned(), 200_000),
("sonnet".to_owned(), 1_000_000),
("opus".to_owned(), 1_000_000),
]
.into_iter()
.collect()
}
#[test]
fn resolves_family_substring() {
assert_eq!(resolve_ctx_window("claude-3-5-haiku-20241022", &cfg()), Some(200_000));
assert_eq!(resolve_ctx_window("claude-sonnet-4-5", &cfg()), Some(1_000_000));
assert_eq!(resolve_ctx_window("claude-opus-4-1", &cfg()), Some(1_000_000));
}
#[test]
fn resolution_is_case_insensitive() {
assert_eq!(resolve_ctx_window("Claude-Sonnet-4", &cfg()), Some(1_000_000));
}
#[test]
fn unknown_model_yields_none() {
assert_eq!(resolve_ctx_window("some-other-llm", &cfg()), None);
}
#[test]
fn empty_config_yields_none() {
assert_eq!(resolve_ctx_window("claude-3-5-haiku", &HashMap::new()), None);
}
#[test]
fn empty_key_is_skipped() {
let mut m = HashMap::new();
m.insert(String::new(), 999);
assert_eq!(resolve_ctx_window("claude-3-5-haiku", &m), None);
}
}

View file

@ -0,0 +1,803 @@
//! Runtime state + config shared between the host admin socket, the manager
//! socket, and the per-agent sockets: the broker, configured `agent_flake`,
//! and the map of registered agent sockets.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use tokio::sync::{broadcast, watch};
use crate::agent_server::{self, AgentSocket};
use crate::approvals::Approvals;
use crate::broker::Broker;
use crate::container_view::{self, ContainerView};
use crate::dashboard_events::DashboardEvent;
use crate::operator_questions::OperatorQuestions;
/// Capacity of the dashboard event channel. Slow browser subscribers
/// (idle tab, throttled connection) drop frames past this — that's
/// fine, the seq dedupe makes a reconnect resync safe.
const DASHBOARD_CHANNEL: usize = 256;
const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents";
const MANAGER_RUNTIME_ROOT: &str = "/run/hyperhive/manager";
/// Manager-editable per-agent config repos. Bind-mounted RW into the manager
/// container as `/agents/<name>/`. Hive-c0re only writes to these on first
/// spawn (initial commit); after that it's manager-only.
const AGENT_STATE_ROOT: &str = "/var/lib/hyperhive/agents";
/// Hive-c0re-only authoritative per-agent config repos. Containers build from
/// these. Manager has no filesystem access; the only way to update is via
/// `request_apply_commit` + user approval.
const APPLIED_STATE_ROOT: &str = "/var/lib/hyperhive/applied";
pub struct Coordinator {
pub broker: Arc<Broker>,
pub approvals: Arc<Approvals>,
pub questions: Arc<OperatorQuestions>,
/// Scheduled-prompts queue (#444). One sqlite connection,
/// internal mutex; the worker drains due rows and the manager
/// handlers insert / cancel through the same handle.
pub scheduled_prompts: Arc<crate::scheduled_prompts::ScheduledPrompts>,
/// URL of the hyperhive flake (no fragment). Inlined into per-agent
/// `flake.nix` files as `inputs.hyperhive.url`.
pub hyperhive_flake: String,
/// TCP port the host's hive-c0re dashboard listens on. Inlined into
/// each per-agent flake so the agent's web UI can build the right
/// rebuild-button URL pointing back at the dashboard.
pub dashboard_port: u16,
/// Operator pronouns (free text) — `she/her` by default, set via
/// the NixOS module option `services.hive-c0re.operatorPronouns`.
/// Reaches each container as the `HIVE_OPERATOR_PRONOUNS` env var
/// (injected into systemd.services.<harness>.environment by the
/// meta flake); the harness substitutes it into the agent /
/// manager system prompt at boot.
pub operator_pronouns: String,
/// Per-model context-window sizes in tokens. Set via the host-level
/// `services.hive-c0re.contextWindowTokens` NixOS option; injected
/// into each container as `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>`
/// by the meta flake renderer. The harness uses these to derive
/// compaction / auto-reset watermarks and exposes the active value
/// on `/api/state` as `context_window_tokens`.
pub context_window_tokens: std::collections::HashMap<String, u64>,
agents: Mutex<HashMap<String, AgentSocket>>,
/// Agents whose lifecycle action (currently just spawn) is in flight.
/// Read by the dashboard to render a spinner; cleared when the action
/// resolves (success or failure).
transient: Mutex<HashMap<String, TransientState>>,
/// Tombstone for transients that have JUST been cleared. The
/// crash watcher polls every 10s and would race the
/// drop-clears-immediately path of `TransientGuard`: an operator
/// kill / restart sets `Stopping` → runs `nixos-container stop` →
/// drop clears the transient → poll fires next tick and sees the
/// container missing-from-running with no active transient →
/// spurious "container stopped without an operator action"
/// message (closes #425).
///
/// `clear_transient` stamps the cleared kind here with an
/// `Instant`; `recent_transient_within(grace)` returns the set of
/// agents whose tombstone is still inside the grace window. Crash
/// watcher consults both this and the active map before declaring
/// a stop deliberate.
recent_transient: Mutex<HashMap<String, (TransientKind, std::time::Instant)>>,
/// Unified wire-facing event channel feeding the dashboard SSE
/// stream. Carries broker messages (mirrored from `broker.subscribe`
/// by the forwarder task in `main.rs`) and dashboard-only mutation
/// events (approval added/resolved, question added/answered, etc.).
/// Snapshot endpoints capture `event_seq` before reading state so
/// the client can dedupe its buffered live traffic against the
/// snapshot.
dashboard_events: broadcast::Sender<DashboardEvent>,
event_seq: AtomicU64,
/// Count of dashboard-triggered `meta-update` runs currently in
/// flight. `post_meta_update` returns 200 immediately and does the
/// multi-minute `nix flake update` + agent-rebuild ripple in a
/// background task, so without this the META INPUTS panel showed no
/// sign anything was happening (issue #259). Held via
/// `MetaUpdateGuard`; a count > 0 surfaces on `/api/state` as
/// `meta_update_running` and via the `MetaUpdateRunning` event.
meta_updates_active: AtomicU64,
/// Last container snapshot seen by `rescan_containers_and_emit`,
/// keyed by `ContainerView.name`. The rescan diffs a fresh
/// `container_view::build_all` against this map and emits one
/// `ContainerStateChanged` per added/changed row and one
/// `ContainerRemoved` per disappeared row. Async — guarded by a
/// tokio mutex so the rescan can `await` `lifecycle::list` /
/// `is_running` without blocking other coordinator paths.
last_containers: tokio::sync::Mutex<HashMap<String, ContainerView>>,
/// Global rebuild queue. Every long-running container/meta op
/// (rebuild, meta-update, first-spawn) goes through this queue so
/// hive-c0re runs at most one at a time and the dashboard can
/// render a single ordered view of pending + running work. See
/// `rebuild_queue.rs` for the dedup rules + history retention.
pub rebuild_queue: Arc<crate::rebuild_queue::RebuildQueue>,
/// Shutdown signal broadcast to all background tasks. Sending
/// `true` asks every loop to exit after its current work item.
/// Use `shutdown_rx()` to subscribe; `request_shutdown()` to fire.
shutdown_tx: watch::Sender<bool>,
}
/// Per-agent in-progress state that the dashboard surfaces between approve
/// click and container ready.
#[derive(Debug, Clone)]
pub struct TransientState {
pub kind: TransientKind,
pub since: std::time::Instant,
}
/// RAII handle returned by `Coordinator::transient_guard`. Cleared on
/// drop — including drop-via-cancellation, the path that bare
/// `set_transient` / `clear_transient` pairs leaked through. Holds an
/// `Arc<Coordinator>` so the guard is freely returnable / movable.
pub struct TransientGuard {
coord: Arc<Coordinator>,
name: String,
}
impl Drop for TransientGuard {
fn drop(&mut self) {
self.coord.clear_transient(&self.name);
}
}
/// RAII guard for the `meta-update` in-progress flag, held for the
/// duration of a `run_meta_update` background task. Created by
/// `Coordinator::meta_update_guard`. Drop decrements the active-run
/// count; the count crossing back to 0 emits
/// `MetaUpdateRunning { running: false }`, so a concurrent pair of
/// updates only flips the dashboard flag once.
pub struct MetaUpdateGuard {
coord: Arc<Coordinator>,
}
impl Drop for MetaUpdateGuard {
fn drop(&mut self) {
if self
.coord
.meta_updates_active
.fetch_sub(1, Ordering::SeqCst)
== 1
{
self.coord
.emit_dashboard_event(DashboardEvent::MetaUpdateRunning {
seq: self.coord.next_seq(),
running: false,
});
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum TransientKind {
/// `lifecycle::spawn` is running (nixos-container create + update + start).
Spawning,
/// `lifecycle::start` is running.
Starting,
/// `lifecycle::kill` is running.
Stopping,
/// `lifecycle::restart` is running.
Restarting,
/// `lifecycle::rebuild` is running (nixos-container update).
Rebuilding,
/// `actions::destroy` is running.
Destroying,
}
impl TransientKind {
/// Wire/UI label. Matches the strings the dashboard already
/// renders in the transient spinner.
pub fn as_str(self) -> &'static str {
match self {
TransientKind::Spawning => "spawning",
TransientKind::Starting => "starting",
TransientKind::Stopping => "stopping",
TransientKind::Restarting => "restarting",
TransientKind::Rebuilding => "rebuilding",
TransientKind::Destroying => "destroying",
}
}
}
impl Coordinator {
pub fn open(
db_path: &Path,
hyperhive_flake: String,
dashboard_port: u16,
operator_pronouns: String,
context_window_tokens: std::collections::HashMap<String, u64>,
) -> Result<Self> {
let broker = Broker::open(db_path).context("open broker")?;
let approvals = Approvals::open(db_path).context("open approvals")?;
let questions = OperatorQuestions::open(db_path).context("open operator_questions")?;
let scheduled_prompts = crate::scheduled_prompts::ScheduledPrompts::open(db_path)
.context("open scheduled_prompts")?;
let (dashboard_events, _) = broadcast::channel(DASHBOARD_CHANNEL);
let (shutdown_tx, _) = watch::channel(false);
Ok(Self {
broker: Arc::new(broker),
approvals: Arc::new(approvals),
questions: Arc::new(questions),
scheduled_prompts: Arc::new(scheduled_prompts),
hyperhive_flake,
dashboard_port,
operator_pronouns,
context_window_tokens,
agents: Mutex::new(HashMap::new()),
transient: Mutex::new(HashMap::new()),
recent_transient: Mutex::new(HashMap::new()),
dashboard_events,
event_seq: AtomicU64::new(0),
meta_updates_active: AtomicU64::new(0),
last_containers: tokio::sync::Mutex::new(HashMap::new()),
rebuild_queue: Arc::new(crate::rebuild_queue::RebuildQueue::new()),
shutdown_tx,
})
}
/// Emit a `RebuildQueueChanged` snapshot event. Called from the
/// queue mutation helpers (`enqueue` / `finish` / `cancel`-adjacent
/// wrappers below) and the worker so every state transition
/// surfaces on the dashboard without extra plumbing.
pub fn emit_rebuild_queue_snapshot(self: &Arc<Self>) {
let queue = self.rebuild_queue.snapshot();
self.emit_dashboard_event(DashboardEvent::RebuildQueueChanged {
seq: self.next_seq(),
queue,
});
}
/// Update the `step` label on a running queue entry and (if it
/// actually changed) re-emit the queue snapshot so the dashboard
/// renders the new phase. Returns `true` when the label was new
/// and an emit fired, mostly for tracing/logging callers; safe to
/// ignore. No-op when `id` is `None` (e.g. callers that aren't
/// running from the queue worker) or when the row isn't `Running`.
pub fn set_queue_step(self: &Arc<Self>, id: Option<u64>, step: &str) {
let Some(id) = id else { return };
if self.rebuild_queue.set_step(id, step) {
self.emit_rebuild_queue_snapshot();
}
}
/// Subscribe to the shutdown watch channel. Background tasks call
/// this at spawn time and break their loop when the receiver
/// transitions to `true` (via `Coordinator::request_shutdown`).
/// A closed channel (i.e. the Coordinator was dropped) also
/// signals tasks to exit.
pub fn shutdown_rx(&self) -> watch::Receiver<bool> {
self.shutdown_tx.subscribe()
}
/// Signal all background tasks to exit cleanly. The tasks break
/// out of their poll loop after completing their current work item.
/// Best-effort — does nothing if all receivers have already been
/// dropped (e.g. process is already mid-shutdown).
pub fn request_shutdown(&self) {
let _ = self.shutdown_tx.send(true);
}
/// Subscribe to the unified dashboard event channel. Used by the
/// `/dashboard/stream` SSE handler and by the broker-to-dashboard
/// forwarder task.
pub fn dashboard_subscribe(&self) -> broadcast::Receiver<DashboardEvent> {
self.dashboard_events.subscribe()
}
/// Stamp the next sequence number. Each emission of a
/// `DashboardEvent` should fill its `seq` with `next_seq()` so the
/// frame the wire carries is the one the client uses to dedupe.
pub fn next_seq(&self) -> u64 {
self.event_seq.fetch_add(1, Ordering::SeqCst) + 1
}
/// Current high-water seq. Snapshot endpoints read this *before*
/// gathering state so the (snapshot.seq, snapshot) pair satisfies:
/// any frame with `seq > snapshot.seq` is post-snapshot. The seq
/// captured here may grow during snapshot construction — clients
/// may double-apply such events, which renderers must tolerate.
pub fn current_seq(&self) -> u64 {
self.event_seq.load(Ordering::SeqCst)
}
/// Broadcast a freshly-built `DashboardEvent` (caller fills `seq`
/// via `next_seq()`). Returns silently when there are no
/// subscribers — the dashboard channel is best-effort presentation
/// plumbing, not a delivery guarantee.
pub fn emit_dashboard_event(&self, event: DashboardEvent) {
let _ = self.dashboard_events.send(event);
}
/// Mark a `meta-update` as in flight and return an RAII guard that
/// clears it on drop (including drop-via-panic). The first
/// concurrent run emits `MetaUpdateRunning { running: true }`; the
/// last one to finish emits `running: false`. The dashboard's META
/// INPUTS panel reads the flag to show a disabled "updating…"
/// state while the lock bump + rebuild ripple runs (issue #259).
pub fn meta_update_guard(self: &Arc<Self>) -> MetaUpdateGuard {
if self.meta_updates_active.fetch_add(1, Ordering::SeqCst) == 0 {
self.emit_dashboard_event(DashboardEvent::MetaUpdateRunning {
seq: self.next_seq(),
running: true,
});
}
MetaUpdateGuard {
coord: Arc::clone(self),
}
}
/// True while at least one dashboard-triggered `meta-update` is
/// running. Surfaced on `/api/state` as `meta_update_running` so a
/// client that cold-loads mid-update sees the in-progress state.
pub fn meta_update_in_progress(&self) -> bool {
self.meta_updates_active.load(Ordering::SeqCst) > 0
}
/// Emit `ApprovalAdded` immediately after the row is inserted in
/// sqlite. Caller passes the diff text it already computed (or
/// `None` for spawn approvals which carry no diff).
pub fn emit_approval_added(
&self,
id: i64,
agent: &str,
approval_kind: &'static str,
sha_short: Option<String>,
diff: Option<String>,
description: Option<String>,
) {
self.emit_dashboard_event(DashboardEvent::ApprovalAdded {
seq: self.next_seq(),
id,
agent: agent.to_owned(),
approval_kind,
sha_short,
diff,
description,
});
}
/// Emit `ApprovalResolved` after `mark_approved` / `mark_denied` /
/// `mark_failed` lands. `resolved_at` is stamped from the system
/// clock here so call sites don't repeat the conversion; if you
/// already have an authoritative timestamp from the db update,
/// the tiny skew between "row updated" and "event emitted" is
/// presentation-only and doesn't matter to clients.
#[allow(clippy::too_many_arguments)]
pub fn emit_approval_resolved(
&self,
id: i64,
agent: &str,
approval_kind: &'static str,
sha_short: Option<String>,
status: &'static str,
note: Option<String>,
description: Option<String>,
) {
let resolved_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
self.emit_dashboard_event(DashboardEvent::ApprovalResolved {
seq: self.next_seq(),
id,
agent: agent.to_owned(),
approval_kind,
sha_short,
status,
resolved_at,
note,
description,
});
}
/// Emit `QuestionAdded` after a question is inserted. Fires for
/// both operator-targeted (`target = None`) and peer-to-peer
/// (`target = Some(agent)`) threads — the dashboard surfaces
/// both, distinguishing visually + offering operator override.
#[allow(clippy::too_many_arguments)]
pub fn emit_question_added(
&self,
id: i64,
asker: &str,
question: &str,
options: &[String],
multi: bool,
deadline_at: Option<i64>,
target: Option<&str>,
) {
let asked_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
let question_refs = crate::dashboard::scan_validated_paths(question);
self.emit_dashboard_event(DashboardEvent::QuestionAdded {
seq: self.next_seq(),
id,
asker: asker.to_owned(),
question: question.to_owned(),
options: options.to_vec(),
multi,
asked_at,
deadline_at,
target: target.map(str::to_owned),
question_refs,
});
}
/// Emit `QuestionResolved` when a question transitions to
/// answered (operator answer, peer answer, operator override on
/// a peer thread, operator cancel, or ttl watchdog). Both
/// operator-targeted and peer threads fire so the dashboard's
/// derived store can move the row from pending to history.
pub fn emit_question_resolved(
&self,
id: i64,
answer: &str,
answerer: &str,
cancelled: bool,
target: Option<&str>,
) {
let answered_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
let answer_refs = crate::dashboard::scan_validated_paths(answer);
self.emit_dashboard_event(DashboardEvent::QuestionResolved {
seq: self.next_seq(),
id,
answer: answer.to_owned(),
answerer: answerer.to_owned(),
answered_at,
cancelled,
target: target.map(str::to_owned),
answer_refs,
});
}
/// Rebuild the per-container snapshot, diff it against the last
/// one cached on `self`, and emit one
/// `DashboardEvent::ContainerStateChanged` per added/changed row
/// and one `DashboardEvent::ContainerRemoved` per disappeared row.
/// Call after any mutation that could affect what
/// `nixos-container list` returns or what a row's
/// `running` / `needs_update` / `needs_login` / `deployed_sha`
/// resolves to — lifecycle ops, destroy, approve (post-spawn),
/// rebuild, meta-update, and the crash-watcher's periodic poll.
/// Cheap when nothing changed (one `nixos-container list` + a
/// `HashMap` diff + zero emits).
pub async fn rescan_containers_and_emit(self: &Arc<Self>) {
let fresh = container_view::build_all(self).await;
let mut last = self.last_containers.lock().await;
let mut changed_or_new = Vec::new();
let mut removed = Vec::new();
// Diff into change vs. add.
for view in &fresh {
match last.get(&view.name) {
Some(prev) if prev == view => {} // unchanged
_ => changed_or_new.push(view.clone()),
}
}
// Anything in `last` but not in `fresh` is gone.
let fresh_names: std::collections::HashSet<&str> =
fresh.iter().map(|c| c.name.as_str()).collect();
for name in last.keys() {
if !fresh_names.contains(name.as_str()) {
removed.push(name.clone());
}
}
// Rebuild the cache from the fresh snapshot.
last.clear();
for c in fresh {
last.insert(c.name.clone(), c);
}
drop(last);
for c in changed_or_new {
self.emit_dashboard_event(DashboardEvent::ContainerStateChanged {
seq: self.next_seq(),
container: c,
});
}
for name in removed {
self.emit_dashboard_event(DashboardEvent::ContainerRemoved {
seq: self.next_seq(),
name,
});
}
}
/// Read-only snapshot of the last cached container view. Used by
/// `/api/state` to cold-load page-open clients without re-running
/// `nixos-container list` themselves; the
/// `rescan_containers_and_emit` calls keep this fresh.
pub async fn containers_snapshot(&self) -> Vec<ContainerView> {
let last = self.last_containers.lock().await;
let mut out: Vec<ContainerView> = last.values().cloned().collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn register_agent(self: &Arc<Self>, name: &str) -> Result<PathBuf> {
// Idempotent: drop any existing listener so re-registration (e.g. on rebuild,
// or after a hive-c0re restart cleared /run/hyperhive) gets a fresh socket.
self.unregister_agent(name);
let agent_dir = Self::agent_dir(name);
std::fs::create_dir_all(&agent_dir)
.with_context(|| format!("create agent dir {}", agent_dir.display()))?;
let socket_path = Self::socket_path(name);
// Hand the full Coordinator to the per-agent socket — it
// needs broker + operator_questions to handle the agent-side
// `ask` / `answer` tools, not just the broker.
let socket = agent_server::start(name, &socket_path, self.clone())?;
self.agents.lock().unwrap().insert(name.to_owned(), socket);
Ok(agent_dir)
}
pub fn unregister_agent(&self, name: &str) {
if let Some(socket) = self.agents.lock().unwrap().remove(name) {
socket.handle.abort();
let _ = std::fs::remove_file(&socket.path);
}
}
pub fn list_agents(&self) -> Vec<String> {
self.agents.lock().unwrap().keys().cloned().collect()
}
/// Mark an agent as in-progress (only one state per agent for now).
///
/// Prefer `transient_guard` when possible — it auto-clears on drop
/// even if the surrounding future is cancelled (HTTP request
/// aborted, runtime shutdown mid-rebuild, panic between set and
/// clear). The bare `set_transient` / `clear_transient` pair leaks
/// the transient on any of those paths and the dashboard then
/// shows the agent stuck in "rebuilding…" forever.
pub fn set_transient(&self, name: &str, kind: TransientKind) {
self.transient.lock().unwrap().insert(
name.to_owned(),
TransientState {
kind,
since: std::time::Instant::now(),
},
);
// Live-update dashboards. `since_unix` is wall-clock so the
// browser can tick "Ns spawning…" without polling. The
// intra-process map keeps using `Instant` for monotonicity.
let since_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
self.emit_dashboard_event(DashboardEvent::TransientSet {
seq: self.next_seq(),
name: name.to_owned(),
transient_kind: kind.as_str(),
since_unix,
});
}
pub fn clear_transient(&self, name: &str) {
let removed = self.transient.lock().unwrap().remove(name);
if let Some(state) = removed {
// Stamp the tombstone so the crash watcher can still see
// "operator kicked this off recently" on its next 10s poll
// — without this, the clear-then-poll race produced a
// spurious ContainerCrash on every operator stop/restart
// (#425). Old entries get reaped lazily on read so the map
// doesn't grow unbounded.
self.recent_transient
.lock()
.unwrap()
.insert(name.to_owned(), (state.kind, std::time::Instant::now()));
self.emit_dashboard_event(DashboardEvent::TransientCleared {
seq: self.next_seq(),
name: name.to_owned(),
});
}
}
/// Set of agents whose transient was cleared within the last
/// `grace` seconds — i.e. agents the operator just acted on,
/// whose stop the crash watcher should NOT classify as a crash.
/// Lazily reaps entries older than `grace` so the map stays
/// bounded by the active agent count.
pub fn recent_transient_within(&self, grace: std::time::Duration) -> HashMap<String, TransientKind> {
let now = std::time::Instant::now();
let mut map = self.recent_transient.lock().unwrap();
map.retain(|_, (_, ts)| now.duration_since(*ts) <= grace);
map.iter().map(|(k, (kind, _))| (k.clone(), *kind)).collect()
}
/// Set a transient state and return a guard that clears it on drop.
/// Use this from any path where the surrounding future could be
/// cancelled or panic between set and clear (HTTP handlers, spawned
/// tasks). The guard's `Drop` runs even on task cancellation, so
/// the dashboard's spinner can't get pinned forever.
pub fn transient_guard(self: &Arc<Self>, name: &str, kind: TransientKind) -> TransientGuard {
self.set_transient(name, kind);
TransientGuard {
coord: self.clone(),
name: name.to_owned(),
}
}
pub fn transient_snapshot(&self) -> HashMap<String, TransientState> {
self.transient.lock().unwrap().clone()
}
/// Drop a system message into the given agent's inbox. Wakes the
/// turn loop with a "you were just (re)started" hint — operator
/// caused the transition, agent picks up where it left off
/// (notes are in the bind-mounted state dir, last turn is in
/// --continue's session). Best-effort; broker errors are logged
/// but don't propagate.
pub fn kick_agent(&self, name: &str, reason: &str) {
// Sub-agents bind their state at /agents/<name>/state. The
// manager has both /state (legacy mount) and /agents
// bind-mounted, so /agents/<name>/state resolves there too —
// use that uniformly so the wake message has one canonical
// path that works everywhere.
let body = format!(
"{reason}\n\nYou were just (re)started by the operator. \
If you were mid-task, check `/agents/{name}/state/` for \
your notes and pick up where you left off. claude's \
`--continue` session is intact, so prior context is \
still in your window."
);
if let Err(e) = self.broker.send(&hive_sh4re::Message {
from: hive_sh4re::SYSTEM_SENDER.to_owned(),
to: name.to_owned(),
body,
in_reply_to: None,
}) {
tracing::warn!(error = ?e, %name, "kick_agent: broker.send failed");
}
}
/// Push a `HelperEvent` into the manager's inbox. Encoded as JSON in
/// `Message::body`; sender = `SYSTEM_SENDER`. The manager harness
/// recognises the sender and parses the body. Best-effort: a serde or
/// broker error is logged but does not propagate.
pub fn notify_manager(&self, event: &hive_sh4re::HelperEvent) {
self.notify_agent(hive_sh4re::MANAGER_AGENT, event);
}
/// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded
/// the same way as `notify_manager` (sender = `SYSTEM_SENDER`,
/// body = JSON-encoded event). Used to route `QuestionAnswered`
/// events back to the agent that called `ask`, `QuestionAsked`
/// events to the target of a peer question, etc.
pub fn notify_agent(&self, agent: &str, event: &hive_sh4re::HelperEvent) {
self.notify_agent_from(hive_sh4re::SYSTEM_SENDER, agent, event);
}
/// Same as `notify_agent` but with an explicit sender. Use this
/// when the event originates from a known agent or the operator
/// (e.g. `QuestionAnswered` — the answerer should be the `from`,
/// not `system`) so the recipient's terminal shows the right name.
pub fn notify_agent_from(&self, from: &str, agent: &str, event: &hive_sh4re::HelperEvent) {
let body = match serde_json::to_string(event) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = ?e, "failed to encode helper event");
return;
}
};
if let Err(e) = self.broker.send(&hive_sh4re::Message {
from: from.to_owned(),
to: agent.to_owned(),
body,
in_reply_to: None,
}) {
tracing::warn!(error = ?e, target = %agent, "failed to push helper event");
}
}
/// Deliver `body` to every currently-registered agent except the sender,
/// appending the standard broadcast hint. Returns a list of per-agent
/// error strings for any that failed (empty = all ok).
pub fn broadcast_send(&self, from: &str, body: &str) -> Vec<String> {
const HINT: &str =
"\n\n⚠️ _hint: this was a broadcast and may not need any action from you_";
let broadcast_body = format!("{body}{HINT}");
let mut errors = Vec::new();
for agent_name in self.list_agents() {
if agent_name == from {
continue;
}
if let Err(e) = self.broker.send(&hive_sh4re::Message {
from: from.to_owned(),
to: agent_name.clone(),
body: broadcast_body.clone(),
in_reply_to: None,
}) {
errors.push(format!("{agent_name}: {e}"));
}
}
errors
}
pub fn agent_dir(name: &str) -> PathBuf {
PathBuf::from(format!("{AGENT_RUNTIME_ROOT}/{name}"))
}
pub fn socket_path(name: &str) -> PathBuf {
Self::agent_dir(name).join("mcp.sock")
}
pub fn manager_dir() -> PathBuf {
PathBuf::from(MANAGER_RUNTIME_ROOT)
}
pub fn manager_socket_path() -> PathBuf {
Self::manager_dir().join("mcp.sock")
}
/// Ensure a runtime dir + (for sub-agents) per-agent socket exists. For
/// the manager, `manager_server::start` owns the socket — just return
/// the dir. For sub-agents this is `register_agent` (creates a fresh
/// listener bound to `socket_path(name)`). Source directory of the
/// `/run/hive/mcp.sock` bind that ends up in `set_nspawn_flags`.
pub fn ensure_runtime(self: &Arc<Self>, name: &str) -> Result<PathBuf> {
if name == crate::lifecycle::MANAGER_NAME {
let dir = Self::manager_dir();
std::fs::create_dir_all(&dir)
.with_context(|| format!("create manager dir {}", dir.display()))?;
return Ok(dir);
}
self.register_agent(name)
}
/// Per-agent state root (parent of `config/`, future `prompts/`, etc.).
pub fn agent_state_root(name: &str) -> PathBuf {
PathBuf::from(format!("{AGENT_STATE_ROOT}/{name}"))
}
/// Manager-editable proposed config repo. Bind-mounted into the manager
/// container as `/agents/<name>/config/`.
pub fn agent_proposed_dir(name: &str) -> PathBuf {
Self::agent_state_root(name).join("config")
}
/// Per-agent Claude credentials dir. Bind-mounted RW into the agent
/// container at `/root/.claude` so OAuth state survives container
/// destroy/recreate. Each agent owns its own token lineage — sharing
/// would break on the first refresh-token rotation.
pub fn agent_claude_dir(name: &str) -> PathBuf {
Self::agent_state_root(name).join("claude")
}
/// Per-agent durable knowledge dir. Bind-mounted RW into the agent
/// container at `/state`. Survives destroy/recreate alongside the
/// claude dir. Agents are told (via the system prompt) to write
/// long-lived notes / scratch state here.
pub fn agent_notes_dir(name: &str) -> PathBuf {
Self::agent_state_root(name).join("state")
}
/// Authoritative applied config repo. Hive-c0re-only.
pub fn agent_applied_dir(name: &str) -> PathBuf {
PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}"))
}
/// Enumerate names that have a persistent state dir under
/// `/var/lib/hyperhive/agents/` (i.e. config / claude creds /
/// notes survive). Includes both currently-existing containers and
/// destroyed-but-kept tombstones; callers filter the latter by
/// subtracting `lifecycle::list()`.
#[must_use]
pub fn kept_state_names() -> Vec<String> {
let Ok(rd) = std::fs::read_dir(AGENT_STATE_ROOT) else {
return Vec::new();
};
let mut out: Vec<String> = rd
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
out.sort();
out
}
}

View file

@ -0,0 +1,229 @@
//! Per-container state watcher. Polls every managed container on a
//! fixed interval, tracks two orthogonal state-sets across ticks,
//! and emits a `HelperEvent` to the manager on each transition:
//!
//! - **running**: container is up. running → stopped without an
//! operator-initiated transient (`Stopping` / `Restarting` /
//! `Destroying` / `Rebuilding`) → `ContainerCrash`.
//! - **logged-in**: claude session dir is populated. ! → ✓ →
//! `LoggedIn`; ✓ → ! → `NeedsLogin` (rare — usually only fires
//! on a fresh spawn / purge).
//!
//! `NeedsUpdate` events are now fired from the apply-commit path
//! directly rather than via rev-marker polling (issue #179 cleanup).
//!
//! D-Bus subscription would be lower-latency for the first axis,
//! but polling is simpler and a 10s detection delay is fine.
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use crate::container_view::claude_has_session;
use crate::coordinator::{Coordinator, TransientKind};
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
const POLL_INTERVAL: Duration = Duration::from_secs(10);
/// How long an operator-initiated transient stays "recently cleared"
/// for the purpose of suppressing crash events. Three full
/// `POLL_INTERVAL`s gives the post-lifecycle path comfortable
/// breathing room — the watcher will have polled at least twice
/// inside the window even with worst-case timer skew (#425).
const RECENT_TRANSIENT_GRACE: Duration = Duration::from_secs(30);
pub fn spawn(coord: Arc<Coordinator>) {
let mut shutdown = coord.shutdown_rx();
tokio::spawn(async move {
let mut prev_running: HashSet<String> = HashSet::new();
let mut prev_logged_in: HashSet<String> = HashSet::new();
let mut prev_sub_agents: HashSet<String> = HashSet::new();
let mut seeded = false;
loop {
let raw = lifecycle::list().await.unwrap_or_default();
let mut current_running = HashSet::new();
let mut current_logged_in = HashSet::new();
let mut sub_agents: Vec<String> = Vec::new();
for c in &raw {
let logical = if c == MANAGER_NAME {
MANAGER_NAME.to_owned()
} else if let Some(n) = c.strip_prefix(AGENT_PREFIX) {
n.to_owned()
} else {
continue;
};
if logical != MANAGER_NAME {
sub_agents.push(logical.clone());
}
if lifecycle::is_running(&logical).await {
current_running.insert(logical.clone());
}
if logical != MANAGER_NAME
&& claude_has_session(&Coordinator::agent_claude_dir(&logical))
{
current_logged_in.insert(logical.clone());
}
}
if seeded {
emit_crash_transitions(&coord, &prev_running, &current_running);
emit_login_transitions(
&coord,
&prev_logged_in,
&current_logged_in,
&sub_agents,
&prev_sub_agents,
);
}
// Periodic container rescan — catches state flips that
// happen outside our mutation surface (operator runs
// `nixos-container stop` over ssh, agent logs in via its
// own web UI, etc.) so the dashboard converges within one
// POLL_INTERVAL. Idempotent + cheap when nothing changed.
coord.rescan_containers_and_emit().await;
prev_running = current_running;
prev_logged_in = current_logged_in;
prev_sub_agents = sub_agents.into_iter().collect();
seeded = true;
tokio::select! {
() = tokio::time::sleep(POLL_INTERVAL) => {}
_ = shutdown.changed() => {
tracing::info!("crash watcher: shutdown signal received");
break;
}
}
}
});
}
fn emit_crash_transitions(coord: &Coordinator, prev: &HashSet<String>, current: &HashSet<String>) {
let transients = coord.transient_snapshot();
// Operator actions whose RAII guard already cleared but only just;
// suppresses the race where `lifecycle::kill` returns + drops the
// guard between two crash-watch polls (closes #425).
let recent = coord.recent_transient_within(RECENT_TRANSIENT_GRACE);
for stopped in prev.difference(current) {
let active = transients.get(stopped).map(|st| st.kind);
let recently_cleared = recent.get(stopped).copied();
if is_deliberate_stop(active, recently_cleared) {
continue;
}
tracing::warn!(agent = %stopped, "container crash detected");
coord.notify_manager(&hive_sh4re::HelperEvent::ContainerCrash {
agent: stopped.clone(),
note: Some("container stopped without an operator action".into()),
});
}
}
/// Pure classifier: did the operator stop / restart / destroy /
/// rebuild this container, or did it crash? Splits the matcher out so
/// it has a focused unit test (#425) without needing a Coordinator
/// fixture. `active` is the currently-set transient (if any),
/// `recently_cleared` is one whose RAII guard dropped within the
/// grace window.
fn is_deliberate_stop(
active: Option<TransientKind>,
recently_cleared: Option<TransientKind>,
) -> bool {
let is_op_kind = |kind: TransientKind| {
matches!(
kind,
TransientKind::Stopping
| TransientKind::Restarting
| TransientKind::Destroying
| TransientKind::Rebuilding
)
};
active.is_some_and(is_op_kind) || recently_cleared.is_some_and(is_op_kind)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deliberate_when_active_transient_is_operator_kind() {
for kind in [
TransientKind::Stopping,
TransientKind::Restarting,
TransientKind::Destroying,
TransientKind::Rebuilding,
] {
assert!(is_deliberate_stop(Some(kind), None), "{kind:?}");
}
}
#[test]
fn deliberate_when_recent_transient_is_operator_kind() {
// Race the #425 bug repros: lifecycle action completes + drops
// the guard between two polls. recent_transient catches it.
for kind in [
TransientKind::Stopping,
TransientKind::Restarting,
TransientKind::Destroying,
TransientKind::Rebuilding,
] {
assert!(is_deliberate_stop(None, Some(kind)), "{kind:?}");
}
}
#[test]
fn not_deliberate_with_no_transient_at_all() {
// The real-crash case — fires the ContainerCrash event.
assert!(!is_deliberate_stop(None, None));
}
#[test]
fn not_deliberate_when_only_spawning_starting() {
// Spawning/Starting are never paired with a "stopped" transition
// — they're starts. If we see one alongside a stop, it's
// unrelated (e.g. just-started container died), still a crash.
for kind in [TransientKind::Spawning, TransientKind::Starting] {
assert!(!is_deliberate_stop(Some(kind), None), "{kind:?} active");
assert!(!is_deliberate_stop(None, Some(kind)), "{kind:?} recent");
}
}
}
fn emit_login_transitions(
coord: &Coordinator,
prev: &HashSet<String>,
current: &HashSet<String>,
sub_agents: &[String],
prev_sub_agents: &HashSet<String>,
) {
for agent in current.difference(prev) {
tracing::info!(%agent, "agent logged in");
coord.notify_manager(&hive_sh4re::HelperEvent::LoggedIn {
agent: agent.clone(),
});
}
// Detect transitions into "needs login": an agent that was previously
// logged-in goes unsigned (credentials deleted), OR a brand-new agent
// appears without a session.
//
// prev_needs uses prev_sub_agents (the agent set from the last tick) so
// that a newly-spawned agent — which does not appear in prev_sub_agents —
// is absent from prev_needs even though it's not in prev_logged_in.
// Without this, new agents land in both prev_needs and current_needs and
// the set difference is empty, silently dropping the event.
let prev_needs: HashSet<&str> = prev_sub_agents
.iter()
.map(String::as_str)
.filter(|n| !prev.contains(*n))
.collect();
let current_needs: HashSet<&str> = sub_agents
.iter()
.map(String::as_str)
.filter(|n| !current.contains(*n))
.collect();
for agent in current_needs.difference(&prev_needs) {
tracing::info!(%agent, "agent needs login");
coord.notify_manager(&hive_sh4re::HelperEvent::NeedsLogin {
agent: (*agent).to_owned(),
});
}
}

2285
hive-c0re/src/dashboard.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,377 @@
//! Unified dashboard event channel.
//!
//! Anything the browser wants to react to in near-real-time flows through
//! `Coordinator.dashboard_events`. Each event is stamped with a monotonic
//! per-process `seq` so the client can dedupe its buffered live traffic
//! against snapshot/history responses (drop frames with
//! `seq <= snapshot.seq`).
//!
//! Why one channel instead of one-per-domain: browsers cap concurrent
//! SSE connections per origin (~6 in chrome) and dispatch-by-kind on the
//! client is a one-liner. Splits get reserved for high-volume sub-streams
//! that most consumers don't care about (none yet).
//!
//! Message-broker traffic (`Sent` / `Delivered`) lives on this channel
//! too. A background forwarder task in `main.rs` subscribes to the broker
//! and re-emits each `MessageEvent` as a `DashboardEvent::Sent` /
//! `DashboardEvent::Delivered` with a freshly-stamped seq. Keeping the
//! broker's intra-process channel separate avoids coupling the broker
//! (used by `recv_blocking_batch` inside the harness loop) to dashboard
//! presentation concerns.
//!
//! New mutation kinds (approval added/resolved, question added/answered,
//! transient changed, etc.) land here as additional variants. The client
//! dispatches by `kind` and updates the relevant section.
use serde::Serialize;
use crate::container_view::ContainerView;
use crate::dashboard::{MetaInputView, TombstoneView};
use crate::rebuild_queue::QueueEntry;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum DashboardEvent {
/// Broker `Sent` event mirrored onto the dashboard channel.
/// `file_refs` carries every path-shaped token in `body` that
/// hive-c0re verified is a regular file under the allow-listed
/// roots (per-agent `state/` + `shared/`). The forwarder
/// pre-validates so the dashboard doesn't need a probe
/// endpoint — the client renders anchors only for tokens that
/// appear in this list, everything else stays plain text.
Sent {
seq: u64,
/// Broker row id. Allows the dashboard to track reply threads.
id: i64,
from: String,
to: String,
body: String,
at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
in_reply_to: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
file_refs: Vec<String>,
},
/// Broker `Delivered` event mirrored onto the dashboard channel.
/// `file_refs` is the same shape as `Sent`.
Delivered {
seq: u64,
/// Broker row id. Allows the dashboard to track reply threads.
id: i64,
from: String,
to: String,
body: String,
at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
in_reply_to: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
file_refs: Vec<String>,
},
/// A new approval landed in the pending queue. Payload carries
/// enough to render the dashboard row without a `/api/state`
/// refetch (`diff` is the raw unified diff text, same shape the
/// snapshot ships).
///
/// The approval's own kind (`"apply_commit"` / `"spawn"`) lives on
/// `approval_kind` rather than `kind` because the latter is taken
/// by the serde tag identifying which `DashboardEvent` variant
/// this is.
ApprovalAdded {
seq: u64,
id: i64,
agent: String,
approval_kind: &'static str,
sha_short: Option<String>,
diff: Option<String>,
description: Option<String>,
},
/// A pending approval transitioned to a terminal state
/// (approved / denied / failed). Clients move the row out of the
/// pending list and into history.
ApprovalResolved {
seq: u64,
id: i64,
agent: String,
approval_kind: &'static str,
sha_short: Option<String>,
/// `"approved"` / `"denied"` / `"failed"`.
status: &'static str,
resolved_at: i64,
note: Option<String>,
description: Option<String>,
},
/// A question landed in the queue. `target = None` means
/// operator-targeted (`Ask { to: None | Some("operator") }`);
/// `target = Some(<agent>)` means a peer-to-peer question. Both
/// are surfaced on the dashboard so the operator can monitor /
/// override-answer stuck threads.
QuestionAdded {
seq: u64,
id: i64,
asker: String,
question: String,
options: Vec<String>,
multi: bool,
asked_at: i64,
deadline_at: Option<i64>,
target: Option<String>,
/// Verified file-path tokens that appear in `question`.
/// Same shape as broker `Sent`/`Delivered` events; the
/// client linkifies only what hive-c0re vouched for.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
question_refs: Vec<String>,
},
/// A question was answered (operator answer, peer answer,
/// operator override on a peer thread, or ttl watchdog
/// `[expired]`). Clients move the row from pending to history.
/// `cancelled = true` when the operator dismissed via the cancel
/// button.
QuestionResolved {
seq: u64,
id: i64,
answer: String,
answerer: String,
answered_at: i64,
cancelled: bool,
target: Option<String>,
/// Verified file-path tokens that appear in `answer`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
answer_refs: Vec<String>,
},
/// A lifecycle action started for an agent (spawn / start / stop
/// / restart / rebuild / destroy). Clients render a spinner next
/// to the row; the client computes "seconds in this state"
/// locally from `since_unix` so a slow rebuild's elapsed time
/// ticks without polling.
TransientSet {
seq: u64,
name: String,
/// Lifecycle kind: `"spawning"` / `"starting"` / `"stopping"` /
/// `"restarting"` / `"rebuilding"` / `"destroying"`.
transient_kind: &'static str,
since_unix: i64,
},
/// The matching lifecycle action resolved (success or failure).
/// Clients drop the spinner row.
TransientCleared { seq: u64, name: String },
/// One container row changed — new container appeared (post-spawn
/// finalise), an existing one flipped `running` / `needs_update` /
/// `sha`, etc. Clients upsert by `container.name`. Payload carries
/// the full row so cold-loaded clients and event-driven clients
/// converge on the same render.
///
/// Fired by `Coordinator::rescan_containers_and_emit`, which diffs
/// a fresh `nixos-container list`derived snapshot against the
/// last one cached on the coordinator. Mutation sites (lifecycle
/// endpoints, `actions::destroy` / approve, `crash_watch`'s poll loop)
/// call the rescan after their work lands.
ContainerStateChanged {
seq: u64,
container: ContainerView,
},
/// A container that was in the previous snapshot is gone. Clients
/// drop the row by name. Fired alongside any
/// `nixos-container destroy` (operator-driven or otherwise) on the
/// next rescan.
ContainerRemoved { seq: u64, name: String },
/// Full snapshot of the tombstones list. Emitted on every
/// mutation that could add / remove a tombstone: destroy
/// (with or without purge), purge-tombstone, spawn approval
/// (which can consume a tombstone of the same name). Snapshot
/// shape (not diff) because the list is tiny (single-digit
/// typical) and recomputing avoids the add/remove races a
/// per-row event would have.
TombstonesChanged {
seq: u64,
tombstones: Vec<TombstoneView>,
},
/// Full snapshot of `meta/flake.lock`'s root inputs. Emitted
/// after every operation that bumps a lock: `meta-update`,
/// `rebuild_agent` (lock bumps via two-phase staging),
/// `update-all`. Same snapshot-shape rationale as
/// `TombstonesChanged` — the list is small (one row per agent
/// plus their fetched inputs).
MetaInputsChanged {
seq: u64,
inputs: Vec<MetaInputView>,
},
/// A dashboard-triggered `meta-update` started (`running: true`) or
/// finished (`running: false`). `post_meta_update` returns 200
/// immediately and runs the `nix flake update` + agent-rebuild
/// ripple in a background task — this event lets the META INPUTS
/// panel show a disabled "updating…" state for that whole window
/// instead of looking idle (issue #259). Emitted by
/// `Coordinator::meta_update_guard` / `MetaUpdateGuard::drop` only
/// when the active-run count crosses 0, so concurrent updates flip
/// the flag exactly once.
MetaUpdateRunning { seq: u64, running: bool },
/// Full snapshot of the rebuild queue (`hive-c0re::rebuild_queue`)
/// — every entry, in enqueue order, including the few most-recent
/// terminal entries the queue retains for history. Same
/// snapshot-shape rationale as `TombstonesChanged` /
/// `MetaInputsChanged`: the list is small, snapshot semantics avoid
/// the add/remove races a per-row event would have, and the
/// dashboard's grouping (parent_id) is most naturally re-derived
/// from the full list.
RebuildQueueChanged {
seq: u64,
queue: Vec<QueueEntry>,
},
}
impl DashboardEvent {
/// Snake-case identifier matching this variant's serde `tag`
/// (e.g. `Sent` → `"sent"`, `ContainerStateChanged` →
/// `"container_state_changed"`). Lets `/dashboard/stream`'s
/// `?kinds=` filter (#408) decide whether to forward a frame
/// without paying the JSON-serialise cost first.
///
/// Keep in sync with `#[serde(rename_all = "snake_case", tag =
/// "kind")]` on `DashboardEvent` — if a new variant lands above,
/// add it here too. `cargo test` covers this via the
/// `kind_tag_matches_serde_kind_field` round-trip test.
#[must_use]
pub fn kind_tag(&self) -> &'static str {
match self {
DashboardEvent::Sent { .. } => "sent",
DashboardEvent::Delivered { .. } => "delivered",
DashboardEvent::ApprovalAdded { .. } => "approval_added",
DashboardEvent::ApprovalResolved { .. } => "approval_resolved",
DashboardEvent::QuestionAdded { .. } => "question_added",
DashboardEvent::QuestionResolved { .. } => "question_resolved",
DashboardEvent::TransientSet { .. } => "transient_set",
DashboardEvent::TransientCleared { .. } => "transient_cleared",
DashboardEvent::ContainerStateChanged { .. } => "container_state_changed",
DashboardEvent::ContainerRemoved { .. } => "container_removed",
DashboardEvent::TombstonesChanged { .. } => "tombstones_changed",
DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed",
DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running",
DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Round-trip representative variants through serde and confirm
/// the `kind` JSON field matches `kind_tag()`. The exhaustive
/// `match` in `kind_tag` already provides compile-time variant
/// coverage — this test is the value-side guard against
/// typos in the snake_case strings vs serde's `rename_all`
/// output. `ContainerStateChanged` is omitted from the sample
/// list only because `ContainerView` has no `Default` impl and
/// constructing one inline here is more boilerplate than the
/// test is worth; the variant is still covered by the
/// `kind_tag` match arm.
#[test]
fn kind_tag_matches_serde_kind_field() {
let samples: Vec<DashboardEvent> = vec![
DashboardEvent::Sent {
seq: 1,
id: 1,
from: "a".into(),
to: "b".into(),
body: String::new(),
at: 0,
in_reply_to: None,
file_refs: Vec::new(),
},
DashboardEvent::Delivered {
seq: 1,
id: 1,
from: "a".into(),
to: "b".into(),
body: String::new(),
at: 0,
in_reply_to: None,
file_refs: Vec::new(),
},
DashboardEvent::ApprovalAdded {
seq: 1,
id: 1,
agent: "x".into(),
approval_kind: "apply_commit",
sha_short: None,
diff: None,
description: None,
},
DashboardEvent::ApprovalResolved {
seq: 1,
id: 1,
agent: "x".into(),
approval_kind: "apply_commit",
sha_short: None,
status: "approved",
resolved_at: 0,
note: None,
description: None,
},
DashboardEvent::QuestionAdded {
seq: 1,
id: 1,
asker: "a".into(),
question: String::new(),
options: Vec::new(),
multi: false,
asked_at: 0,
deadline_at: None,
target: None,
question_refs: Vec::new(),
},
DashboardEvent::QuestionResolved {
seq: 1,
id: 1,
answer: String::new(),
answerer: "a".into(),
answered_at: 0,
cancelled: false,
target: None,
answer_refs: Vec::new(),
},
DashboardEvent::TransientSet {
seq: 1,
name: "x".into(),
transient_kind: "rebuilding",
since_unix: 0,
},
DashboardEvent::TransientCleared {
seq: 1,
name: "x".into(),
},
DashboardEvent::ContainerRemoved {
seq: 1,
name: "x".into(),
},
DashboardEvent::TombstonesChanged {
seq: 1,
tombstones: Vec::new(),
},
DashboardEvent::MetaInputsChanged {
seq: 1,
inputs: Vec::new(),
},
DashboardEvent::MetaUpdateRunning {
seq: 1,
running: false,
},
DashboardEvent::RebuildQueueChanged {
seq: 1,
queue: Vec::new(),
},
];
for ev in samples {
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");
let serde_kind = v
.get("kind")
.and_then(|k| k.as_str())
.expect("kind field present");
assert_eq!(
ev.kind_tag(),
serde_kind,
"kind_tag() drift on {ev:?}",
);
}
}
}

View file

@ -0,0 +1,66 @@
//! Host-side vacuum of every per-agent events.sqlite. The harness
//! writes to `/state/hyperhive-events.sqlite` (bind-mounted from
//! `/var/lib/hyperhive/agents/<name>/state/`); we open the same file
//! from the host every hour and delete rows older than `KEEP_SECS`.
//! Age-only — no row cap — so a chatty turn doesn't lose history
//! sooner than a quiet one; disk pressure on a sustained burst is
//! a cheaper problem than a missing event when the operator is
//! debugging a regression. Keeping retention on the host means
//! agents don't need any cleanup wiring of their own, and a
//! misbehaving harness can't disable its own vacuum.
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use rusqlite::{Connection, Result, params};
use crate::coordinator::Coordinator;
const VACUUM_INTERVAL: Duration = Duration::from_secs(3600);
const KEEP_SECS: i64 = 7 * 24 * 3600;
/// Background loop: sweep every existing agent state dir hourly, run
/// the vacuum SQL against its events.sqlite if present. Errors are
/// logged but don't tear the loop down.
pub fn spawn(coord: &Arc<Coordinator>) {
let mut shutdown = coord.shutdown_rx();
tokio::spawn(async move {
loop {
sweep_once();
tokio::select! {
() = tokio::time::sleep(VACUUM_INTERVAL) => {}
_ = shutdown.changed() => {
tracing::info!("events vacuum: shutdown signal received");
break;
}
}
}
});
}
fn sweep_once() {
for name in Coordinator::kept_state_names() {
let path = Coordinator::agent_notes_dir(&name).join("hyperhive-events.sqlite");
if !path.exists() {
continue;
}
match vacuum_file(&path) {
Ok(0) => {}
Ok(n) => tracing::info!(agent = %name, removed = n, "events vacuum"),
Err(e) => tracing::warn!(agent = %name, error = ?e, "events vacuum failed"),
}
}
}
fn vacuum_file(path: &Path) -> Result<u64> {
let conn = Connection::open(path)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
let cutoff = now - KEEP_SECS;
let removed = conn.execute("DELETE FROM events WHERE ts < ?1", params![cutoff])?;
Ok(u64::try_from(removed).unwrap_or(0))
}

View file

@ -0,0 +1,431 @@
//! Pre-apply validation for agent `flake.lock` files (closes part of #317).
//!
//! Every `request_apply_commit` lands a `proposal/<id>` tag in the
//! agent's applied repo before the operator sees the approval. We
//! parse `flake.lock` from that tag's tree and reject the request if
//! two or more nodes share an identical `original` field — that
//! signals a missing `inputs.<X>.inputs.nixpkgs.follows = "nixpkgs"`
//! directive in `flake.nix` and would inflate meta's lock with
//! duplicates after deploy.
//!
//! Per mara's scope note on #317 (comment 4189): the check runs on
//! the agent repo, not meta, and catches *new* violations only.
//! Existing agents whose lock already has duplicates are out of
//! scope here and get a coordinated config-change pass via the
//! manager instead.
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::Path;
use anyhow::{Context, Result};
use serde_json::Value;
use tokio::process::Command;
use crate::lifecycle::git_command;
/// One group of `flake.lock` nodes that all share the same canonical
/// `original` reference. Surfaced in the rejection message so the
/// operator (and the manager that submitted the apply) can see
/// exactly which input pair needs a `follows` directive.
#[derive(Debug, Clone)]
pub struct DuplicateGroup {
/// One of the original `Value`s from the lock — used for
/// pretty-printing in the error message. Canonical equivalence
/// is enforced by the `BTreeMap` key in `duplicate_groups`, so
/// we don't need to keep the canonicalised form on the struct.
pub original: Value,
/// Names of the flake.lock nodes that share this `original`,
/// sorted for stable error output.
pub keys: Vec<String>,
}
/// Read `flake.lock` from `<tag>:flake.lock` in `repo`. Returns
/// `Ok(None)` when the file isn't tracked in that tag (no inputs ⇒
/// nothing to dedup); `Err` only on real git plumbing failures.
async fn read_lock_at_tag(repo: &Path, tag: &str) -> Result<Option<String>> {
let spec = format!("{tag}:flake.lock");
let out = git_command()
.current_dir(repo)
.args(["show", &spec])
.output()
.await
.with_context(|| format!("git show {spec} in {}", repo.display()))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
// git uses two different messages for "path not in tree"
// depending on whether the path also collides with an on-disk
// file. Both translate to "no flake.lock in this commit" —
// a legitimate, dedup-clean state for an agent with empty
// `inputs = { }`. Any other git failure (permission denied,
// ref-not-found, etc.) propagates as a hard error rather than
// being silently swallowed.
if stderr.contains("does not exist")
|| stderr.contains("exists on disk, but not in")
{
return Ok(None);
}
anyhow::bail!("git show {spec} failed: {}", stderr.trim());
}
Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()))
}
/// Recursively serialise `v` with object keys sorted, so two
/// JSON values that differ only in key insertion order produce the
/// same string. `serde_json::Value` preserves `IndexMap` order by
/// default, which is fine for parsing but breaks our group-by-key
/// idea — hence this hand-rolled canonicaliser.
fn canonical_json(v: &Value) -> String {
match v {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let mut s = String::from("{");
for (i, k) in keys.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&serde_json::to_string(k).unwrap_or_default());
s.push(':');
s.push_str(&canonical_json(&map[*k]));
}
s.push('}');
s
}
Value::Array(arr) => {
let mut s = String::from("[");
for (i, x) in arr.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&canonical_json(x));
}
s.push(']');
s
}
_ => serde_json::to_string(v).unwrap_or_default(),
}
}
/// Parse `raw` (a `flake.lock` JSON document) and return every group
/// of nodes whose `original` field is identical. Nodes without an
/// `original` (the synthetic `root`, or anomalous entries) are
/// skipped. Groups with only one member are filtered out — only true
/// duplicates surface.
///
/// Pure function, no I/O — covered by the unit tests below.
pub fn duplicate_groups(raw: &str) -> Result<Vec<DuplicateGroup>> {
let json: Value = serde_json::from_str(raw).context("parse flake.lock")?;
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
return Ok(Vec::new());
};
let mut groups: BTreeMap<String, DuplicateGroup> = BTreeMap::new();
for (name, node) in nodes {
let Some(original) = node.get("original") else {
continue;
};
let key = canonical_json(original);
let entry = groups.entry(key).or_insert_with(|| DuplicateGroup {
original: original.clone(),
keys: Vec::new(),
});
entry.keys.push(name.clone());
}
let mut dups: Vec<DuplicateGroup> = groups
.into_values()
.filter(|g| g.keys.len() > 1)
.collect();
for g in &mut dups {
g.keys.sort();
}
Ok(dups)
}
/// Re-derive the agent's `flake.lock` from its `flake.nix` (in a
/// throw-away worktree at the proposal tag) and reject the apply when
/// the result differs from what's committed — that means the manager
/// edited `flake.nix` but didn't commit the regenerated lock, so the
/// shipped state lies about what nix will actually fetch.
///
/// Plain `nix flake lock` (no `--update-input` flags) only fills in
/// MISSING entries; it never refreshes existing ones. So a lock that
/// matches its `flake.nix` round-trips to a no-op, and any diff is a
/// real "stale lock" signal.
///
/// Materialises the proposal tag into a temp worktree under
/// `std::env::temp_dir()` to avoid touching `applied/<n>/main` while
/// the check runs. Cleanup is unconditional via the inner-fn pattern.
///
/// Returns `Ok(())` when in sync (or there's no `flake.nix` at all);
/// `Err` with a human-readable message on stale lock or nix tooling
/// failure.
pub async fn check_lock_in_sync(repo: &Path, tag: &str, approval_id: i64) -> Result<()> {
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp_dir = std::env::temp_dir().join(format!("hive-flake-check-{approval_id}-{suffix}"));
// Detached worktree at the proposal tag — gives us a clean, mutable
// copy of the agent's tree without disturbing whatever's currently
// checked out on `applied/<n>/main`.
let out = git_command()
.current_dir(repo)
.args([
"worktree",
"add",
"--detach",
&tmp_dir.to_string_lossy(),
tag,
])
.output()
.await
.with_context(|| format!("git worktree add {} {tag}", tmp_dir.display()))?;
if !out.status.success() {
anyhow::bail!(
"git worktree add failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let result = lock_in_sync_inner(&tmp_dir).await;
// Best-effort cleanup. `git worktree remove --force` handles the
// common case; `remove_dir_all` mops up if git decided the worktree
// is half-gone (or if the inner work bailed before nix touched the
// tree). Failures here are logged, not propagated — the check's
// result is what matters.
if let Err(e) = remove_worktree(repo, &tmp_dir).await {
tracing::warn!(
worktree = %tmp_dir.display(),
error = %format!("{e:#}"),
"flake_check: temp worktree cleanup failed"
);
}
result
}
async fn lock_in_sync_inner(worktree: &Path) -> Result<()> {
// No `flake.nix` means there's nothing for nix to lock — skip the
// check cleanly (the dedup check will likewise no-op).
if !worktree.join("flake.nix").exists() {
return Ok(());
}
let committed = tokio::fs::read_to_string(worktree.join("flake.lock"))
.await
.ok();
// `--extra-experimental-features` mirrors `meta::nix` for hosts
// that haven't already enabled flakes in `nix.conf`. Plain
// `nix flake lock` (no `--update-input`) fills missing entries but
// never refreshes existing ones — exactly the semantics we want.
let out = Command::new("nix")
.current_dir(worktree)
.args([
"--extra-experimental-features",
"nix-command flakes",
"flake",
"lock",
])
.output()
.await
.with_context(|| format!("nix flake lock in {}", worktree.display()))?;
if !out.status.success() {
anyhow::bail!(
"nix flake lock failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let regenerated = tokio::fs::read_to_string(worktree.join("flake.lock"))
.await
.ok();
// An agent that declares inputs in flake.nix but ships no
// flake.lock at all hits this branch (committed = None,
// regenerated = Some(...)). That's a deliberate reject: every
// agent with inputs MUST commit its lock, otherwise meta's
// dedup pass has nothing to introspect and the broken state
// leaks downstream. Treated identically to a stale lock.
if committed.as_deref() != regenerated.as_deref() {
anyhow::bail!(
"flake.lock is out of sync with flake.nix — `nix flake lock` produces a different lock. \
Run `nix flake lock` in your agent config, commit the result, and re-submit request_apply_commit."
);
}
Ok(())
}
async fn remove_worktree(repo: &Path, worktree: &Path) -> Result<()> {
let out = git_command()
.current_dir(repo)
.args([
"worktree",
"remove",
"--force",
&worktree.to_string_lossy(),
])
.output()
.await
.with_context(|| format!("git worktree remove {}", worktree.display()))?;
if !out.status.success() {
// `git worktree remove` already errored — still try the raw
// rmdir so we don't leak the dir on disk. Surface the original
// git stderr for context.
let _ = tokio::fs::remove_dir_all(worktree).await;
anyhow::bail!(
"git worktree remove failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
// git removed the worktree's metadata but the dir itself may
// linger on stripped-down git versions — best-effort clean.
let _ = tokio::fs::remove_dir_all(worktree).await;
Ok(())
}
/// Run the dedup check against the agent's freshly-applied tree.
///
/// `Ok(())` means either the commit doesn't carry a `flake.lock` (no
/// inputs declared) or every node has a unique `original`. `Err`
/// carries a multi-line message listing every duplicate group with
/// the offending node names, suitable for surfacing on the failed
/// approval row.
pub async fn check_no_duplicate_inputs(repo: &Path, tag: &str) -> Result<()> {
let Some(raw) = read_lock_at_tag(repo, tag).await? else {
return Ok(());
};
let dups = duplicate_groups(&raw)?;
if dups.is_empty() {
return Ok(());
}
let mut msg = String::from(
"flake.lock has duplicate flake inputs — add a `follows` directive in flake.nix to collapse them:\n",
);
for g in &dups {
let original = serde_json::to_string(&g.original).unwrap_or_else(|_| "?".into());
let _ = writeln!(msg, " - {original} → nodes [{}]", g.keys.join(", "));
}
anyhow::bail!("{}", msg.trim_end());
}
#[cfg(test)]
mod tests {
use super::*;
const CLEAN_LOCK: &str = r#"{
"nodes": {
"nixpkgs": {
"locked": {"rev": "aaa"},
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-25.11", "type": "github"}
},
"nixpkgs-unstable": {
"locked": {"rev": "bbb"},
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixpkgs-unstable", "type": "github"}
},
"root": {"inputs": {"nixpkgs": "nixpkgs"}}
},
"root": "root",
"version": 7
}"#;
const DUPLICATE_LOCK: &str = r#"{
"nodes": {
"nixpkgs": {
"locked": {"rev": "aaa"},
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-25.11", "type": "github"}
},
"nixpkgs_2": {
"locked": {"rev": "ccc"},
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-25.11", "type": "github"}
},
"nixpkgs_3": {
"locked": {"rev": "ddd"},
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-25.11", "type": "github"}
},
"treefmt-nix": {
"locked": {"rev": "eee"},
"original": {"owner": "numtide", "repo": "treefmt-nix", "type": "github"}
},
"treefmt-nix_2": {
"locked": {"rev": "fff"},
"original": {"type": "github", "owner": "numtide", "repo": "treefmt-nix"}
},
"root": {"inputs": {"nixpkgs": "nixpkgs"}}
},
"root": "root",
"version": 7
}"#;
#[test]
fn clean_lock_has_no_duplicates() {
let dups = duplicate_groups(CLEAN_LOCK).expect("parse");
assert!(dups.is_empty(), "expected no dups, got {dups:#?}");
}
#[test]
fn duplicate_lock_reports_groups() {
let dups = duplicate_groups(DUPLICATE_LOCK).expect("parse");
assert_eq!(dups.len(), 2, "expected nixpkgs + treefmt-nix groups");
// Order is BTreeMap-stable: sorted by canonical_json key. The
// numtide treefmt-nix key sorts before the NixOS nixpkgs one
// because `numtide` < `NixOS` lexicographically (case-sensitive,
// capitals come first... wait — capital N is 0x4e, lowercase n
// is 0x6e, so capitals come first). So nixpkgs group sorts
// first. Verify by content instead of position to avoid coupling
// to that subtlety.
let nixpkgs_group = dups
.iter()
.find(|g| g.original.get("ref").is_some())
.expect("nixpkgs group present");
assert_eq!(
nixpkgs_group.keys,
vec!["nixpkgs", "nixpkgs_2", "nixpkgs_3"]
);
let treefmt_group = dups
.iter()
.find(|g| g.original.get("ref").is_none())
.expect("treefmt-nix group present");
assert_eq!(treefmt_group.keys, vec!["treefmt-nix", "treefmt-nix_2"]);
}
#[test]
fn key_order_in_original_does_not_matter() {
// The treefmt-nix and treefmt-nix_2 entries above use different
// key orderings for `original` ({owner,repo,type} vs
// {type,owner,repo}); duplicate_groups should still merge them.
let dups = duplicate_groups(DUPLICATE_LOCK).expect("parse");
let treefmt = dups
.iter()
.find(|g| g.keys.iter().any(|k| k == "treefmt-nix"))
.expect("treefmt-nix group");
assert!(treefmt.keys.contains(&"treefmt-nix_2".to_owned()));
}
#[test]
fn nodes_without_original_are_ignored() {
// The synthetic `root` node has no `original` and must not be
// grouped against anything.
let dups = duplicate_groups(CLEAN_LOCK).expect("parse");
assert!(dups.iter().all(|g| !g.keys.iter().any(|k| k == "root")));
}
#[test]
fn missing_nodes_object_is_not_an_error() {
// A lock file that's syntactically JSON but lacks `nodes` (e.g.
// a partial test fixture) should fail open — no dups reported.
let raw = r#"{"root": "root", "version": 7}"#;
let dups = duplicate_groups(raw).expect("parse");
assert!(dups.is_empty());
}
#[test]
fn invalid_json_is_a_hard_error() {
let err = duplicate_groups("not-json-at-all").unwrap_err();
assert!(format!("{err:#}").contains("parse flake.lock"));
}
}

691
hive-c0re/src/forge.rs Normal file
View file

@ -0,0 +1,691 @@
//! Optional Forgejo wiring. When the `hive-forge` nixos-container is
//! present and running, hive-c0re ensures every agent (and the
//! manager) has a corresponding forgejo user with an API token
//! written to `<agent-state>/forge-token` — visible inside the
//! container as `/state/forge-token`. Idempotent: skips creation
//! when the user already exists, skips token issuance when the file
//! is already there.
//!
//! It also mirrors each agent's hive-c0re-owned *applied* config repo
//! into the private `agent-configs` org (`push_config`), so every
//! deploy / approval tag core plants is visible on the forge. Each
//! agent is a read-only collaborator on `core/meta` (the meta flake)
//! so they can fetch their deployment context; the `agent-configs`
//! repos remain core-only.
//!
//! No-op when `hive-forge` isn't enabled (detected via
//! `nixos-container list`), so operators who don't run the bundled
//! forge pay nothing.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use base64::Engine;
use reqwest::StatusCode;
use tokio::process::Command;
use crate::coordinator::Coordinator;
const FORGE_CONTAINER: &str = "hive-forge";
const FORGE_HTTP: &str = "http://localhost:3000";
const TOKEN_NAME_PREFIX: &str = "hyperhive";
/// Where the host-side `core` admin token lives. Used by hive-c0re
/// itself to push the meta repo + drive admin API calls (org
/// creation, future webhook setup, etc.). Root-only.
const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token";
/// Marker that records whether `ensure_core_avatar` has successfully
/// uploaded the hyperhive logo as `core`'s avatar (issue #320). One-shot:
/// the upload runs once, the marker is written, subsequent startups skip
/// the call. Delete to force re-upload.
const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set";
/// Sibling marker for the `agent-configs` org avatar (#424). Same one-
/// shot semantics — delete to force the upload to re-run.
const CONFIG_ORG_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-agent-configs-avatar-set";
/// Hyperhive logo bytes, baked into the daemon. Uploaded once via the
/// admin avatar API so the `core` Forgejo user shows the project mark
/// next to commits in `agent-configs/*`, `core/meta`, etc. instead of
/// the default hash identicon.
const CORE_AVATAR_PNG: &[u8] = include_bytes!("../../branding/hyperhive.png");
/// `agent-configs` org logo bytes (#424). Sibling visual to the main
/// hyperhive mark — same dark base + outer ring + corner brackets,
/// with a stacked-config-files glyph in the centre so the operator
/// can distinguish the agent-configs namespace from the main
/// `hyperhive` org at a glance. Source-of-truth is
/// `branding/agent-configs.svg`; `hive-c0re/build.rs` renders it
/// into `$OUT_DIR/agent-configs.png` at compile time via
/// `rsvg-convert` so the raster never gets checked into git.
const CONFIG_ORG_AVATAR_PNG: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/agent-configs.png"));
/// Forgejo org grouping every agent's applied config repo. Core is a
/// site admin and reads + writes every repo here; agents are NOT
/// members and the repos are private, so no agent — not even the one
/// a repo describes — can reach a config repo through the forge. The
/// applied repos stay hive-c0re-owned on disk; this org is just a
/// mirror target core pushes to.
const CONFIG_ORG: &str = "agent-configs";
/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives at
/// `core/meta` (the `core` user's own namespace — no org needed).
const SEEDED_ORGS: &[&str] = &[CONFIG_ORG];
/// Forgejo scopes the agent's token gets. Broad-but-not-admin: every
/// repo / PR / issue thing an agent needs day-to-day, no admin
/// surface.
/// - `write:repository` — create, clone, push, delete repos in the
/// user's own namespace; merge PRs.
/// - `write:issue` — open / comment / review issues *and* pull
/// requests (forgejo namespaces PR conversation under issues).
/// - `write:user` — edit own profile, create repos under own user.
/// - `write:organization` — create + manage orgs (lets agents share
/// a forge namespace).
/// - `read:user` — token-owner endpoint clients call to introspect.
/// - `write:misc` — hooks, attachments, the rest of the long tail.
/// - `read:notification` — required by `forge_notify` to poll
/// `GET /notifications` for unread PR/review events.
/// - `write:notification` — required by `forge_notify` to mark
/// notifications as read via `PATCH /notifications/threads/{id}`.
const TOKEN_SCOPES: &str =
"read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
/// Token file inside the agent's bind-mounted state dir (visible as
/// `/state/forge-token` from inside the container).
fn token_path(name: &str) -> PathBuf {
Coordinator::agent_notes_dir(name).join("forge-token")
}
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
/// `nixos-container list` is just a directory scan in /etc.
pub async fn is_present() -> bool {
let Ok(out) = Command::new("nixos-container").arg("list").output().await else {
return false;
};
if !out.status.success() {
return false;
}
String::from_utf8_lossy(&out.stdout)
.lines()
.any(|l| l.trim() == FORGE_CONTAINER)
}
/// Run `forgejo admin <args>` inside the hive-forge container as the
/// forgejo user (the only uid with write access to the state dir).
/// Returns stdout on success; bails with stderr context on failure.
async fn forge_admin(args: &[&str]) -> Result<String> {
let mut cmd = Command::new("nixos-container");
// `runuser` (util-linux, always present in a NixOS container)
// beats `sudo` here — sudo isn't installed unless `security.sudo`
// is enabled, and we don't want to depend on that.
//
// `--work-path` is mandatory: without it, the admin CLI defaults
// WorkPath to `dirname(executable)` (a RO nix-store path), then
// looks for `<WorkPath>/custom/conf/app.ini` which doesn't
// exist, falls back to defaults, and F3 init tries to mkdir
// under the nix store and fatals. The systemd unit sets
// WORK_PATH for the daemon; we mirror it here for the CLI.
cmd.args([
"run",
FORGE_CONTAINER,
"--",
"runuser",
"-u",
"forgejo",
"--",
"forgejo",
"--work-path",
"/var/lib/forgejo",
"admin",
]);
cmd.args(args);
let out = cmd
.output()
.await
.context("invoke nixos-container run hive-forge -- forgejo admin")?;
if !out.status.success() {
anyhow::bail!(
"forgejo admin {} failed ({}): {}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stderr).trim(),
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Pull the access token out of forgejo's success message. Format
/// has shifted across versions (table form vs. "Access token was
/// successfully created: <hex>"), so just hunt the output for the
/// first long hex-looking word.
fn extract_token(output: &str) -> Option<String> {
output
.split(|c: char| c.is_whitespace() || c == ',' || c == ':')
.find(|w| w.len() >= 32 && w.chars().all(|c| c.is_ascii_hexdigit()))
.map(str::to_owned)
}
/// Canonical email address for a hive agent's Forgejo account.
/// Must match the `user.email` set by `meta::render_flake` so commits
/// by the agent link back to their Forgejo profile page.
fn agent_email(name: &str) -> String {
format!("{name}@hyperhive")
}
/// Thin Forgejo REST helper. Sends `method` to `url` with a JSON body
/// and `Authorization: token <token>`, returns the HTTP status code.
/// All Forgejo API calls that don't shell out to `forgejo admin` go
/// through here — one place for auth header, content-type, error
/// propagation, and the shared reqwest Client.
async fn forge_http(
method: reqwest::Method,
url: &str,
token: &str,
body: &str,
) -> Result<StatusCode> {
let client = reqwest::Client::new();
let resp = client
.request(method, url)
.header("Authorization", format!("token {token}"))
.header("Content-Type", "application/json")
.body(body.to_owned())
.send()
.await
.with_context(|| format!("forge HTTP request to {url}"))?;
Ok(resp.status())
}
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
/// returns a "user already exists" error which we treat as success.
/// `admin` adds `--admin` (site admin) — used for the bootstrap
/// `core` user that drives the API.
async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> {
let mut args = vec![
"user",
"create",
"--username",
name,
"--email",
];
let email = agent_email(name);
args.push(&email);
args.extend(["--random-password", "--must-change-password=false"]);
if admin {
args.push("--admin");
}
let result = forge_admin(&args).await;
match result {
Ok(_) => {
tracing::info!(%name, "forge: created user");
Ok(())
}
Err(e) => {
// Forgejo's "already exists" error wording varies; just
// try the next step and let token issuance surface a
// real failure if the user truly isn't there.
let msg = format!("{e:#}");
if msg.contains("already exists") || msg.contains("user already") {
tracing::debug!(%name, "forge: user already exists");
Ok(())
} else {
tracing::warn!(%name, error = %msg, "forge: user create unclear; trying token anyway");
Ok(())
}
}
}
}
/// Idempotently align the Forgejo account email to `agent_email(name)`.
/// Existing agents were created with `{name}@hive.local`; this corrects
/// that so git commits (which use `{name}@hyperhive`) link to profiles.
/// Best-effort: failures are warned, not propagated.
async fn ensure_user_email(name: &str) {
let email = agent_email(name);
match forge_admin(&["user", "edit", "--username", name, "--email", &email]).await {
Ok(_) => tracing::debug!(%name, %email, "forge: user email aligned"),
Err(e) => tracing::warn!(%name, error = %e, "forge: could not align user email"),
}
}
/// Mint a fresh access token for `name` and persist it to
/// `<state>/forge-token` (0600). Token name is suffixed with a
/// monotonic clock so re-issuing doesn't collide with an existing
/// token of the same name in the DB.
async fn mint_and_persist_token(name: &str, path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let token_name = format!(
"{TOKEN_NAME_PREFIX}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
);
let stdout = forge_admin(&[
"user",
"generate-access-token",
"--username",
name,
"--token-name",
&token_name,
"--scopes",
TOKEN_SCOPES,
])
.await?;
let token = extract_token(&stdout)
.with_context(|| format!("parse token from forgejo output: {stdout:?}"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(path, format!("{token}\n"))
.with_context(|| format!("write token to {}", path.display()))?;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
tracing::info!(%name, path = %path.display(), %token_name, "forge: persisted access token");
Ok(())
}
/// Ensure `name` has a forgejo user + token file. Always re-mints the
/// token so the on-disk file always reflects the current `TOKEN_SCOPES`.
/// Safe to call on every spawn and on every hive-c0re startup.
pub async fn ensure_user_for(name: &str) -> Result<()> {
if !is_present().await {
return Ok(());
}
ensure_user_exists(name, false).await?;
ensure_user_email(name).await;
mint_and_persist_token(name, &token_path(name)).await
}
/// Set `core`'s Forgejo avatar to the hyperhive logo once, then
/// remember it so subsequent startups don't re-upload (issue #320).
/// Best-effort — any non-2xx is logged at the caller; the project
/// runs fine with the default hash identicon.
async fn ensure_core_avatar(token: &str) -> Result<()> {
let marker = std::path::Path::new(CORE_AVATAR_MARKER);
if marker.exists() {
return Ok(());
}
let body = format!(
r#"{{"image":"{}"}}"#,
base64::engine::general_purpose::STANDARD.encode(CORE_AVATAR_PNG),
);
let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar");
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;
if !status.is_success() {
anyhow::bail!("set core avatar: HTTP {status}");
}
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(marker, "").ok();
tracing::info!("forge: set core user avatar to hyperhive logo");
Ok(())
}
/// Set the `agent-configs` org's Forgejo avatar to the
/// configs-stack glyph once (#424). Sibling to `ensure_core_avatar`:
/// one-shot, marker-guarded, best-effort. Forgejo's per-org avatar
/// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG
/// JSON body — same shape as the admin user endpoint above.
async fn ensure_config_org_avatar(token: &str) -> Result<()> {
let marker = std::path::Path::new(CONFIG_ORG_AVATAR_MARKER);
if marker.exists() {
return Ok(());
}
let body = format!(
r#"{{"image":"{}"}}"#,
base64::engine::general_purpose::STANDARD.encode(CONFIG_ORG_AVATAR_PNG),
);
let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar");
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;
if !status.is_success() {
anyhow::bail!("set {CONFIG_ORG} avatar: HTTP {status}");
}
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(marker, "").ok();
tracing::info!(org = CONFIG_ORG, "forge: set org avatar to configs-stack logo");
Ok(())
}
/// Ensure the bootstrap `core` admin user + a token at
/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo
/// API calls (org creation now, meta-repo push later). Returns the
/// token. Idempotent: skips creation when user exists, skips token
/// when the file is present.
async fn ensure_core_user_and_token() -> Result<String> {
let path = std::path::Path::new(CORE_TOKEN_PATH);
if let Ok(existing) = std::fs::read_to_string(path) {
let trimmed = existing.trim().to_owned();
if !trimmed.is_empty() {
return Ok(trimmed);
}
}
ensure_user_exists("core", true).await?;
mint_and_persist_token("core", path).await?;
let raw = std::fs::read_to_string(path)
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;
Ok(raw.trim().to_owned())
}
/// JSON body for a private, empty repo defaulting to `main`.
fn repo_body(name: &str) -> String {
format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#)
}
/// POST a repo-creation request to `url` and fold "already exists"
/// (HTTP 409 / 422) into success. `label` is `<owner>/<name>` — purely
/// for log + error context.
async fn create_repo(url: &str, body: &str, token: &str, label: &str) -> Result<()> {
let status = forge_http(reqwest::Method::POST, url, token, body).await?;
match status.as_u16() {
201 => {
tracing::info!(%label, "forge: created repo");
Ok(())
}
409 | 422 => {
tracing::debug!(%label, "forge: repo already exists");
Ok(())
}
other => anyhow::bail!("POST {url} ({label}) returned HTTP {other}"),
}
}
/// Create a repo in the token-owner's own namespace. `token` belongs
/// to the user we want the repo owned by (we use `core`'s token for
/// `core/meta`). Idempotent.
pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
create_repo(
&format!("{FORGE_HTTP}/api/v1/user/repos"),
&repo_body(name),
token,
&format!("core/{name}"),
)
.await
}
/// Create `name` inside org `org` (used for `agent-configs/<agent>`).
/// Idempotent.
async fn ensure_org_repo(org: &str, name: &str, token: &str) -> Result<()> {
create_repo(
&format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"),
&repo_body(name),
token,
&format!("{org}/{name}"),
)
.await
}
/// Read the persisted core token, or None when the forge isn't
/// seeded yet. Cheap — just a file read.
pub fn core_token() -> Option<String> {
std::fs::read_to_string(CORE_TOKEN_PATH)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
}
/// Push `dir` (the meta repo) to `core/meta` on the local forge.
/// Best-effort: returns Err which callers log + ignore. No-op when
/// the core token isn't present (forge not enabled).
pub async fn push_meta(dir: &Path) -> Result<()> {
let Some(token) = core_token() else {
return Ok(());
};
// Token-in-URL push. Forgejo accepts `oauth2:<token>` or just
// any-username:<token>; using `core` matches the owner so the
// remote name is self-describing.
let url = format!("http://core:{token}@localhost:3000/core/meta.git");
let out = Command::new("git")
.current_dir(dir)
.args(["push", "--force", &url, "HEAD:main"])
.output()
.await
.context("invoke git push core/meta")?;
if !out.status.success() {
anyhow::bail!(
"git push core/meta failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
tracing::info!("forge: pushed meta to core/meta");
Ok(())
}
/// Ensure the `agent-configs/<name>` repo exists so the first
/// `push_config` doesn't 404. No-op when the forge isn't running or
/// the core token isn't minted yet. Safe to call on every spawn and
/// on every startup.
pub async fn ensure_config_repo(name: &str) -> Result<()> {
if !is_present().await {
return Ok(());
}
let Some(token) = core_token() else {
return Ok(());
};
ensure_org_repo(CONFIG_ORG, name, &token).await
}
/// Grant agent `name` read-only collaborator access to `core/meta` on
/// the forge so the agent can clone/fetch the meta flake. Idempotent:
/// HTTP 204 (already a collaborator) is treated as success.
pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> {
let url = format!("{FORGE_HTTP}/api/v1/repos/core/meta/collaborators/{name}");
let body = r#"{"permission":"read"}"#;
let out = Command::new("curl")
.args([
"-sS",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"-X",
"PUT",
"-H",
"Content-Type: application/json",
"-H",
&format!("Authorization: token {core_token}"),
"-d",
body,
&url,
])
.output()
.await
.context("invoke curl PUT core/meta/collaborators")?;
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
match code.as_str() {
"204" => {
tracing::info!(%name, "forge: granted meta read access");
Ok(())
}
other => anyhow::bail!(
"PUT core/meta/collaborators/{name} returned HTTP {other}"
),
}
}
/// Add `http://localhost:3000/core/meta.git` as the `meta` remote in
/// the agent's proposed config repo so the agent (and the manager) can
/// fetch the meta flake from the forge. Idempotent: no-op when the
/// remote already points at the right URL, or when the proposed repo
/// does not exist yet. No-op when the forge is not running.
pub async fn ensure_meta_remote(name: &str) -> Result<()> {
if !is_present().await {
return Ok(());
}
let proposed_dir = Coordinator::agent_proposed_dir(name);
if !proposed_dir.join(".git").exists() {
return Ok(());
}
let want = format!("{FORGE_HTTP}/core/meta.git");
let existing = crate::lifecycle::git_command()
.current_dir(&proposed_dir)
.args(["remote", "get-url", "meta"])
.output()
.await
.context("git remote get-url meta")?;
if existing.status.success() {
let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned();
if current == want {
return Ok(());
}
crate::lifecycle::git(&proposed_dir, &["remote", "set-url", "meta", &want]).await
} else {
crate::lifecycle::git(&proposed_dir, &["remote", "add", "meta", &want]).await
}
}
/// Mirror agent `name`'s applied config repo — `main` plus every tag
/// (`proposal` / `approved` / `building` / `deployed` / `failed` /
/// `denied`) — to `agent-configs/<name>` on the local forge.
/// Best-effort: returns Err which callers log + ignore. No-op when the
/// forge isn't seeded or the applied repo doesn't exist yet.
///
/// Call this after every hive-c0re mutation of an applied repo's refs
/// so the forge copy always reflects what core actually did. `--force`
/// because a failed build rolls `main` backwards to the last-good sha.
///
/// The tokenised URL is passed straight to `git push` and deliberately
/// never stored as a named remote: the applied repo is bind-mounted
/// READ-ONLY into the manager container (`/applied`), so a token in
/// `.git/config` would leak core's admin credential to an agent.
pub async fn push_config(name: &str) -> Result<()> {
let Some(token) = core_token() else {
return Ok(());
};
let dir = Coordinator::agent_applied_dir(name);
if !dir.join(".git").exists() {
return Ok(());
}
let url = format!("http://core:{token}@localhost:3000/{CONFIG_ORG}/{name}.git");
let out = crate::lifecycle::git_command()
.current_dir(&dir)
.args([
"push",
"--force",
&url,
"refs/heads/main:refs/heads/main",
"refs/tags/*:refs/tags/*",
])
.output()
.await
.context("invoke git push agent-configs")?;
if !out.status.success() {
anyhow::bail!(
"git push {CONFIG_ORG}/{name} failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
tracing::info!(%name, "forge: mirrored applied config to agent-configs");
Ok(())
}
/// POST `/api/v1/orgs` to create an org named `name`. Idempotent:
/// HTTP 422 ("user already exists") is treated as success.
async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {
let body = format!(r#"{{"username":"{name}"}}"#);
let url = format!("{FORGE_HTTP}/api/v1/orgs");
let status = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?;
match status.as_u16() {
201 => {
tracing::info!(%name, "forge: created org");
Ok(())
}
422 | 409 => {
tracing::debug!(%name, "forge: org already exists");
Ok(())
}
other => anyhow::bail!("POST /api/v1/orgs name={name} returned HTTP {other}"),
}
}
/// Per-agent forge sync: ensure the agent has a forgejo user + token,
/// a mirrored config repo, read access to `core/meta`, and the `meta`
/// remote in its proposed repo. All operations are idempotent; failures
/// are logged as warnings but don't abort the caller.
///
/// `core_token` is `core_token()` — passed in so callers that already
/// fetched it don't re-read the file. Pass `None` to skip the
/// `meta_read_access` step (safe: the access grant is best-effort).
///
/// Called by both `ensure_all()` (startup sweep) and `rebuild_agent`
/// (per-rebuild) so the two paths stay equivalent.
pub async fn sync_agent(name: &str, core_token: Option<&str>) {
if let Err(e) = ensure_user_for(name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
}
// Align email to match the git user.email set by meta::render_flake
// so commits link to the agent's Forgejo profile. Best-effort;
// also patches up agents created before this fix (old @hive.local).
ensure_user_email(name).await;
// Mirror the agent's applied config repo into agent-configs.
// ensure_config_repo is idempotent; push_config catches any
// drift since the last run — e.g. the startup migration just
// relocated `deployed/0`, or a deploy landed while the forge
// was down.
if let Err(e) = ensure_config_repo(name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed");
}
if let Err(e) = push_config(name).await {
tracing::warn!(%name, error = ?e, "forge: push_config failed");
}
// Grant read-only access to core/meta and wire the `meta` remote
// into the proposed repo so agents can fetch their deployment context.
if let Some(token) = core_token
&& let Err(e) = meta_read_access(name, token).await {
tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed");
}
if let Err(e) = ensure_meta_remote(name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed");
}
}
/// Sweep every existing container (manager + sub-agents) and ensure
/// each has a forgejo user + token, plus an `agent-configs/<name>`
/// repo mirroring its applied config. Also seeds the `core` admin
/// user (hive-c0re's own identity for pushing the meta repo + driving
/// the API), the `agent-configs` org, and the `core/meta` repo.
/// Called once at hive-c0re startup. Per-step failures are logged
/// but don't abort the sweep.
pub async fn ensure_all() {
if !is_present().await {
tracing::debug!("forge: hive-forge container absent, skipping user sweep");
return;
}
let core_token = match ensure_core_user_and_token().await {
Ok(t) => Some(t),
Err(e) => {
tracing::warn!(error = ?e, "forge: ensure_core_user_and_token failed");
None
}
};
if let Some(token) = core_token.as_deref() {
for org in SEEDED_ORGS {
if let Err(e) = ensure_org(org, token).await {
tracing::warn!(%org, error = ?e, "forge: ensure_org failed");
}
}
// Meta repo lives at core/meta — pushed from git_commit in
// meta.rs on every deploy/lock-update. Make sure it exists
// before the first push hits a 404.
if let Err(e) = ensure_repo("meta", token).await {
tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed");
}
if let Err(e) = ensure_core_avatar(token).await {
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
}
if let Err(e) = ensure_config_org_avatar(token).await {
tracing::warn!(error = ?e, "forge: ensure_config_org_avatar failed");
}
}
let Ok(containers) = crate::lifecycle::list().await else {
tracing::warn!("forge: nixos-container list failed; skipping user sweep");
return;
};
for c in containers {
let name = if c == crate::lifecycle::MANAGER_NAME {
c
} else if let Some(n) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) {
n.to_owned()
} else {
continue;
};
sync_agent(&name, core_token.as_deref()).await;
}
}

1144
hive-c0re/src/lifecycle.rs Normal file

File diff suppressed because it is too large Load diff

66
hive-c0re/src/limits.rs Normal file
View file

@ -0,0 +1,66 @@
//! Wire-protocol size limits shared across the agent + manager
//! sockets. Caps on inline message bodies stop a single chatty agent
//! (or a misbehaving extra-MCP server) from flooding the broker
//! sqlite with megabyte-sized rows that then bloat every recipient's
//! wake-prompt context. Anything genuinely larger should be written
//! to a state file and the path sent as the body.
//!
//! Reminders get a separate auto-file escape hatch (see
//! `agent_server::handle_remind`) so callers don't have to think
//! about it — oversized reminder bodies get persisted to disk
//! transparently and the inbox sees a pointer.
/// Per-message body cap. Applies to `send`, `ask` question text,
/// `answer` body, and the stored inline form of a reminder. 4 KiB
/// catches the bulk of conversational overflow (status reports,
/// bullet-list summaries, short proposals) while staying small
/// enough that a backed-up inbox of ~10 unread messages only adds
/// ~40 KiB to the recipient's wake-prompt context. Genuinely
/// long-form artifacts (audit reports, full diffs, transcripts)
/// still belong in a state file — the error message on overflow
/// points callers at that escape hatch.
pub const MESSAGE_MAX_BYTES: usize = 4096;
/// Validate that `body` fits under [`MESSAGE_MAX_BYTES`]. Returns a
/// caller-ready error string (caller wraps in
/// `AgentResponse::Err`/`ManagerResponse::Err`) on failure.
///
/// `label` shows up in the error message verbatim — pass a short
/// noun like `"send"`, `"question"`, `"broadcast"` so the model can
/// tell which call got rejected.
pub fn check_size(label: &str, body: &str) -> Result<(), String> {
if body.len() > MESSAGE_MAX_BYTES {
Err(format!(
"{label} body too long ({} bytes, max {MESSAGE_MAX_BYTES}); write the \
payload to a file under your `/agents/<you>/state/` dir and send the \
path as the body instead",
body.len()
))
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_short_body() {
assert!(check_size("send", "hello").is_ok());
assert!(check_size("send", &"x".repeat(MESSAGE_MAX_BYTES)).is_ok());
}
#[test]
fn rejects_oversize_body() {
let err = check_size("send", &"x".repeat(MESSAGE_MAX_BYTES + 1)).unwrap_err();
assert!(err.contains("send body too long"));
assert!(err.contains(&format!("max {MESSAGE_MAX_BYTES}")));
}
#[test]
fn label_threads_through() {
let err = check_size("question", &"x".repeat(MESSAGE_MAX_BYTES + 1)).unwrap_err();
assert!(err.starts_with("question body too long"));
}
}

153
hive-c0re/src/loose_ends.rs Normal file
View file

@ -0,0 +1,153 @@
//! Loose-ends aggregator. Walks the `approvals` + `operator_questions`
//! tables once per call and assembles a `Vec<LooseEnd>` for either
//! a single agent (`for_agent`) or the whole hive (`hive_wide`). Both
//! `AgentRequest::GetLooseEnds` and `ManagerRequest::GetLooseEnds`
//! land here so the routing logic + age-seconds derivation stay in
//! one place.
//!
//! Call frequency is low (an agent doing self-introspection between
//! turns), so the sweep happens fresh every time — no caching, no
//! mutation events. If the sweep ever shows up in a profile, the
//! sqlite queries already filter on the same indexes
//! (`idx_approvals_pending` + `idx_operator_questions_pending`) that
//! the dashboard uses, so the bottleneck would be json
//! (de)serialisation, not the read.
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use hive_sh4re::{MANAGER_AGENT, LooseEnd};
use crate::coordinator::Coordinator;
/// Open threads pending against `agent`:
/// - pending approvals where this agent is the submitter (only ever
/// true for the manager — sub-agents don't submit approvals — but
/// we keep the rule per-agent so the manager's MCP surface gets
/// the same shape via a different code path);
/// - unanswered questions where `agent` is the asker (waiting on
/// someone) OR the target (owes a reply);
/// - pending reminders this agent scheduled (`owner == self`).
///
/// Ordered approvals → questions → reminders within the returned
/// vector. Within each kind, source-of-truth ordering (sqlite's
/// `pending()` queries return newest-first within their indexes).
pub fn for_agent(coord: &Coordinator, agent: &str) -> Result<Vec<LooseEnd>> {
let now = now_unix();
let mut out = Vec::new();
// Approvals are only submitted by the manager today. When that
// expands (e.g. sub-agents propose changes to their own configs),
// teach the approvals table to track the submitter and filter
// here on that column — for now MANAGER_AGENT == sole submitter.
if agent == MANAGER_AGENT {
for a in coord.approvals.pending()? {
out.push(LooseEnd::Approval {
id: a.id,
agent: a.agent,
commit_ref: a.commit_ref,
description: a.description,
age_seconds: saturating_age(now, a.requested_at),
});
}
}
for q in coord.questions.pending_all()? {
let role_match = q.asker == agent || q.target.as_deref() == Some(agent);
if !role_match {
continue;
}
out.push(LooseEnd::Question {
id: q.id,
asker: q.asker,
target: q.target,
question: q.question,
age_seconds: saturating_age(now, q.asked_at),
});
}
for r in coord.broker.list_pending_reminders()? {
if r.agent != agent {
continue;
}
out.push(LooseEnd::Reminder {
id: r.id,
owner: r.agent,
message: r.message,
due_at: r.due_at,
age_seconds: saturating_age(now, r.created_at),
});
}
Ok(out)
}
/// Hive-wide loose-ends view: EVERY pending approval + EVERY
/// unanswered question + EVERY pending reminder. Manager surface
/// only; sub-agents can't see each other's threads via the agent
/// surface (`for_agent` filters by name).
pub fn hive_wide(coord: &Coordinator) -> Result<Vec<LooseEnd>> {
let now = now_unix();
let mut out = Vec::new();
for a in coord.approvals.pending()? {
out.push(LooseEnd::Approval {
id: a.id,
agent: a.agent,
commit_ref: a.commit_ref,
description: a.description,
age_seconds: saturating_age(now, a.requested_at),
});
}
for q in coord.questions.pending_all()? {
out.push(LooseEnd::Question {
id: q.id,
asker: q.asker,
target: q.target,
question: q.question,
age_seconds: saturating_age(now, q.asked_at),
});
}
for r in coord.broker.list_pending_reminders()? {
out.push(LooseEnd::Reminder {
id: r.id,
owner: r.agent,
message: r.message,
due_at: r.due_at,
age_seconds: saturating_age(now, r.created_at),
});
}
Ok(out)
}
fn saturating_age(now: i64, then: i64) -> u64 {
let delta = now.saturating_sub(then);
u64::try_from(delta).unwrap_or(0)
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saturating_age_handles_clock_back_step() {
// `now` < `then`: caller's clock went backwards between rows.
// We saturate to 0 rather than returning a negative or
// wrapping around to ~u64::MAX (which would render as "27
// billion years ago" in the wake prompt).
assert_eq!(saturating_age(100, 200), 0);
}
#[test]
fn saturating_age_normal_case() {
assert_eq!(saturating_age(1_000_000, 999_990), 10);
}
#[test]
fn saturating_age_zero_when_equal() {
assert_eq!(saturating_age(42, 42), 0);
}
}

376
hive-c0re/src/main.rs Normal file
View file

@ -0,0 +1,376 @@
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context as _, Result, bail};
use clap::{Parser, Subcommand};
use hive_sh4re::{HostRequest, HostResponse};
mod actions;
mod agent_server;
mod approvals;
mod auto_update;
mod broker;
mod client;
mod container_view;
mod coordinator;
mod crash_watch;
mod dashboard;
mod dashboard_events;
mod events_vacuum;
mod stats_vacuum;
mod flake_check;
mod forge;
mod lifecycle;
mod scheduled_prompts;
mod scheduled_prompts_worker;
mod limits;
mod loose_ends;
mod manager_server;
mod meta;
mod migrate;
mod operator_questions;
mod questions;
mod rebuild_queue;
mod topology;
mod reminder_scheduler;
mod server;
use coordinator::Coordinator;
#[derive(Parser)]
#[command(name = "hive-c0re", about = "hyperhive coordinator daemon and CLI")]
struct Cli {
/// Path to the host admin socket.
#[arg(long, global = true, default_value = "/run/hyperhive/host.sock")]
socket: PathBuf,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Run the coordinator daemon.
Serve {
/// URL of the hyperhive flake. Inlined into each per-agent
/// `flake.nix` as the `hyperhive` input.
#[arg(long, default_value = "/etc/hyperhive")]
hyperhive_flake: String,
/// Path to the sqlite message store.
#[arg(long, default_value = "/var/lib/hyperhive/broker.sqlite")]
db: PathBuf,
/// Dashboard HTTP port.
#[arg(long, default_value_t = 7000)]
dashboard_port: u16,
/// Operator pronouns (free text). Threaded into each
/// container's harness via `HIVE_OPERATOR_PRONOUNS` so the
/// system prompt can mention them. Default: `she/her`.
#[arg(long, default_value = "she/her")]
operator_pronouns: String,
/// Per-model context-window sizes, as JSON object mapping model-family
/// short name to token count. Threaded into each container as
/// `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars. Set via the
/// `services.hive-c0re.contextWindowTokens` NixOS option.
#[arg(long, default_value = r#"{"haiku":200000,"sonnet":1000000,"opus":1000000}"#)]
context_window_tokens: String,
},
/// Spawn a new agent container directly (`hive-agent-<name>`). Bypasses
/// the approval queue — use only as an operator on the host. For
/// approval-gated spawns, use `request-spawn` instead.
Spawn { name: String },
/// Queue a spawn request as an approval. The container is created on
/// `approve <id>` (CLI) or the dashboard's APPR0VE button.
RequestSpawn { name: String },
/// Stop a managed container (graceful).
Kill { name: String },
/// Tear down a sub-agent container. Container is removed; persistent
/// state (config repos + Claude credentials) is kept by default. Pass
/// `--purge` to also wipe the agent's state dirs (config + creds +
/// notes). No undo.
Destroy {
name: String,
#[arg(long)]
purge: bool,
},
/// Apply pending config to a managed container.
Rebuild { name: String },
/// List managed containers.
List,
/// List pending approval requests submitted by the manager.
Pending,
/// Approve a pending request by id; the action runs immediately.
Approve { id: i64 },
/// Deny a pending request by id.
Deny { id: i64 },
/// Move an agent in the topology tree (#486). Set `--parent` to
/// a new parent agent name; pass `--root` to promote the agent
/// to root (no parent). Refuses cycles, unknown agents, and
/// any attempt to reparent the manager.
SetParent {
child: String,
/// New parent agent name. Mutually exclusive with `--root`.
/// Exactly one of `--parent` / `--root` is required — clap
/// rejects both-absent calls so a fat-fingered
/// `hive-c0re set-parent alice` doesn't silently promote
/// alice to root (argus flag on PR #492).
#[arg(long, conflicts_with = "root", required_unless_present = "root")]
parent: Option<String>,
/// Promote `child` to root (no parent).
#[arg(long)]
root: bool,
},
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
match cli.cmd {
Cmd::Serve {
hyperhive_flake,
db,
dashboard_port,
operator_pronouns,
context_window_tokens,
} => cmd_serve(hyperhive_flake, db, dashboard_port, operator_pronouns, context_window_tokens, &cli.socket).await,
Cmd::Spawn { name } => {
render(client::request(&cli.socket, HostRequest::Spawn { name }).await?)
}
Cmd::RequestSpawn { name } => {
render(client::request(&cli.socket, HostRequest::RequestSpawn { name }).await?)
}
Cmd::Kill { name } => {
render(client::request(&cli.socket, HostRequest::Kill { name }).await?)
}
Cmd::Destroy { name, purge } => {
render(client::request(&cli.socket, HostRequest::Destroy { name, purge }).await?)
}
Cmd::Rebuild { name } => {
render(client::request(&cli.socket, HostRequest::Rebuild { name }).await?)
}
Cmd::List => render(client::request(&cli.socket, HostRequest::List).await?),
Cmd::Pending => render(client::request(&cli.socket, HostRequest::Pending).await?),
Cmd::Approve { id } => {
render(client::request(&cli.socket, HostRequest::Approve { id }).await?)
}
Cmd::Deny { id } => render(client::request(&cli.socket, HostRequest::Deny { id }).await?),
Cmd::SetParent {
child,
parent,
root,
} => {
let new_parent = if root { None } else { parent };
render(
client::request(
&cli.socket,
HostRequest::SetParent { child, new_parent },
)
.await?,
)
}
}
}
/// Start the coordinator daemon: open the broker, run migrations, spawn
/// background tasks (auto-update, vacuums, crash-watcher, reminder-scheduler,
/// dashboard), then serve the admin socket until a signal arrives.
async fn cmd_serve(
hyperhive_flake: String,
db: std::path::PathBuf,
dashboard_port: u16,
operator_pronouns: String,
context_window_tokens: String,
socket: &std::path::Path,
) -> Result<()> {
let cwt: std::collections::HashMap<String, u64> =
serde_json::from_str(&context_window_tokens)
.context("--context-window-tokens: invalid JSON")?;
let coord = Arc::new(Coordinator::open(
&db,
hyperhive_flake,
dashboard_port,
operator_pronouns,
cwt,
)?);
manager_server::start(coord.clone())?;
// Idempotent pre-flight: rewrite pre-meta-layout applied
// repos, ensure proposed repos carry the `applied`
// remote, bootstrap the meta repo, repoint containers at
// `meta#<name>` (one-shot, guarded by a marker file).
// Runs before manager auto-spawn so the new manager is
// built against meta from the first attempt.
if let Err(e) = migrate::run(&coord).await {
tracing::warn!(error = ?e, "startup migration failed");
}
// Auto-create the manager container if it isn't there yet. Block
// on this — without hm1nd the system has no manager harness.
// Failures are logged but allowed: a broken auto-spawn shouldn't
// make the dashboard unreachable for debugging.
if let Err(e) = auto_update::ensure_manager(&coord).await {
tracing::warn!(error = ?e, "auto-spawn manager failed");
}
// Auto-update in the background — don't block service start.
// Sub-agent rebuilds can take tens of seconds; we want the admin
// socket up immediately.
let update_coord = coord.clone();
tokio::spawn(async move {
if let Err(e) = auto_update::run(update_coord).await {
tracing::warn!(error = ?e, "auto-update task failed");
}
});
// Forge user sweep: ensure every existing container has a
// forgejo user + access token. No-op when the hive-forge
// container isn't running. Backgrounded — touches the
// forge state dir via `nixos-container run` which is slow.
tokio::spawn(async move {
forge::ensure_all().await;
});
// Periodic broker vacuum: drop fully-acked messages older
// than 30 days. Delivered-but-unacked rows (recoverable via
// requeue_inflight) and undelivered rows are always kept.
// Runs hourly; first sweep happens immediately.
let vacuum_coord = coord.clone();
let mut vacuum_shutdown = coord.shutdown_rx();
tokio::spawn(async move {
let interval = std::time::Duration::from_secs(3600);
let keep_secs: i64 = 30 * 24 * 3600;
loop {
match vacuum_coord.broker.vacuum_delivered(keep_secs) {
Ok(0) => {}
Ok(n) => tracing::info!(removed = n, "broker vacuum"),
Err(e) => tracing::warn!(error = ?e, "broker vacuum failed"),
}
tokio::select! {
() = tokio::time::sleep(interval) => {}
_ = vacuum_shutdown.changed() => {
tracing::info!("broker vacuum: shutdown signal received");
break;
}
}
}
});
// Per-agent events.sqlite vacuum: host-side so the harness
// doesn't need any retention wiring of its own.
events_vacuum::spawn(&coord);
// Per-agent turn-stats.sqlite vacuum: same pattern, 90-day
// retention so trend analysis has enough history.
stats_vacuum::spawn(&coord);
// Container crash watcher: emits HelperEvent::ContainerCrash
// when a previously-running container goes away without an
// operator-initiated transient state.
crash_watch::spawn(coord.clone());
// Reminder scheduler: drains due reminders + handles
// file_path payload persistence. See reminder_scheduler.rs.
reminder_scheduler::spawn(coord.clone());
// Scheduled-prompts worker: drains due scheduled_prompts rows
// and fans the body out to each active target's inbox. See
// scheduled_prompts_worker.rs (#444).
scheduled_prompts_worker::spawn(coord.clone());
// Rebuild-queue worker: drains the global rebuild/meta-update/
// spawn queue FIFO so hive-c0re never runs two heavyweight
// container ops concurrently. Existing rebuild call sites
// (auto_update, dashboard, manager, approval handler) enqueue
// here instead of awaiting `rebuild_agent` inline. See
// `rebuild_queue.rs`.
{
let q_coord = coord.clone();
tokio::spawn(async move {
rebuild_queue::run_worker(q_coord).await;
});
}
// Forward every broker event onto the unified dashboard
// channel with a freshly-stamped seq, so the dashboard SSE
// sees broker messages + future mutation events on one
// stream with one monotonic seq. The broker's intra-process
// channel (used by `recv_blocking_batch`) stays untouched.
spawn_broker_to_dashboard_forwarder(coord.clone());
let dash_coord = coord.clone();
tokio::spawn(async move {
if let Err(e) = dashboard::serve(dashboard_port, dash_coord).await {
tracing::error!(error = ?e, "dashboard failed");
}
});
// Run the admin socket until a signal arrives; then signal
// all background tasks so they exit cleanly before the
// process terminates.
let coord_sig = coord.clone();
tokio::select! {
res = server::serve(socket, coord) => { res? }
_ = tokio::signal::ctrl_c() => {
tracing::info!("SIGINT received — requesting shutdown");
coord_sig.request_shutdown();
}
() = async {
let mut sig = tokio::signal::unix::signal(
tokio::signal::unix::SignalKind::terminate()
).expect("failed to install SIGTERM handler");
sig.recv().await;
} => {
tracing::info!("SIGTERM received — requesting shutdown");
coord_sig.request_shutdown();
}
}
Ok(())
}
/// Re-emit every broker `MessageEvent` onto the dashboard channel as
/// a `DashboardEvent::Sent` / `Delivered` with a freshly-stamped seq.
/// Background task; runs for the life of the process. On a lagged
/// broker subscription we just keep going — the dashboard channel is
/// best-effort presentation plumbing, the broker keeps its own sqlite
/// log for replay.
fn spawn_broker_to_dashboard_forwarder(coord: Arc<Coordinator>) {
use broker::MessageEvent;
use dashboard_events::DashboardEvent;
let mut rx = coord.broker.subscribe();
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(MessageEvent::Sent { id, from, to, body, at, in_reply_to }) => {
let file_refs = dashboard::scan_validated_paths(&body);
coord.emit_dashboard_event(DashboardEvent::Sent {
seq: coord.next_seq(),
id,
from,
to,
body,
at,
in_reply_to,
file_refs,
});
}
Ok(MessageEvent::Delivered { id, from, to, body, at, in_reply_to }) => {
let file_refs = dashboard::scan_validated_paths(&body);
coord.emit_dashboard_event(DashboardEvent::Delivered {
seq: coord.next_seq(),
id,
from,
to,
body,
at,
in_reply_to,
file_refs,
});
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(skipped = n, "broker-to-dashboard forwarder lagged");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
}
fn render(resp: HostResponse) -> Result<()> {
println!("{}", serde_json::to_string_pretty(&resp)?);
if !resp.ok {
bail!(resp.error.unwrap_or_else(|| "request failed".to_owned()));
}
Ok(())
}

File diff suppressed because it is too large Load diff

635
hive-c0re/src/meta.rs Normal file
View file

@ -0,0 +1,635 @@
//! Single hive-c0re-owned flake at `/var/lib/hyperhive/meta/` that
//! consumes every agent's applied repo as a flake input and exports one
//! `nixosConfiguration` per agent. Containers run against
//! `--flake /var/lib/hyperhive/meta#<name>`; lifecycle ops here drive the
//! lock file so meta's git log is the system-wide deploy audit trail.
//!
//! Flow:
//! - `sync_agents` (idempotent) — render `flake.nix` for the current
//! agent set, init the repo on first call, relock if the rendered
//! contents changed, commit. Used by spawn / destroy / startup
//! migration.
//! - `prepare_deploy` + `finalize_deploy` / `abort_deploy` — two-phase
//! for the `request_apply_commit` path so a failed
//! `nixos-container update` leaves no orphan commit in meta. Prepare
//! writes the new lock without committing; finalize commits with the
//! deploy message; abort `git restore`s the lock back.
//! - `lock_update_hyperhive` — one-shot for the auto-update path.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use tokio::process::Command;
use tokio::sync::Mutex;
use crate::lifecycle;
const META_ROOT: &str = "/var/lib/hyperhive/meta";
const APPLIED_ROOT: &str = "/var/lib/hyperhive/applied";
const GIT_NAME: &str = "c0re";
const GIT_EMAIL: &str = "c0re@hyperhive";
/// Single-writer lock around every meta-repo operation. Git isn't
/// safe to drive from concurrent processes against the same `.git/`
/// — two simultaneous `git add` / `commit` invocations race on
/// `.git/index.lock`; if either dies before releasing, the lock
/// sticks and the next operation hits "another git process seems to
/// be running" until somebody `rm`s it manually. Holding this mutex
/// across each public function's git+nix calls makes parallel
/// rebuilds (`auto_update` + dashboard-triggered + apply-commit)
/// take turns instead of colliding.
static META_LOCK: Mutex<()> = Mutex::const_new(());
/// Where the manager sees this directory inside its container (RO bind).
#[allow(dead_code)] // wired up by set_nspawn_flags in a follow-up commit
pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta";
#[derive(Debug, Clone)]
pub struct AgentSpec {
pub name: String,
pub is_manager: bool,
pub port: u16,
}
#[must_use]
pub fn meta_dir() -> PathBuf {
PathBuf::from(META_ROOT)
}
/// Idempotently reconcile the meta repo with the current agent set.
/// First call inits the git repo, runs `nix flake lock`, and lands a
/// seed commit. Subsequent calls only touch `flake.nix` when the
/// rendered contents differ from disk; an unchanged `flake.nix` is a
/// no-op.
#[allow(dead_code)] // first caller lands in a later commit
pub async fn sync_agents(
hyperhive_flake: &str,
dashboard_port: u16,
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec],
) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let new_flake = render_flake(hyperhive_flake, dashboard_port, operator_pronouns, context_window_tokens, agents);
let flake_path = dir.join("flake.nix");
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
let initial = !dir.join(".git").exists();
if !initial && on_disk == new_flake {
return Ok(());
}
std::fs::write(&flake_path, &new_flake)
.with_context(|| format!("write {}", flake_path.display()))?;
// Reconcile topology.json against the live agent set — adds
// entries for newly-spawned agents (default: manager as parent,
// manager itself as root) and drops removed agents. Operator
// overrides via the write API (#361 follow-up) are preserved
// because reconcile only fills in missing entries. Idempotent;
// when nothing changed the file isn't touched.
let agent_names: Vec<String> = agents.iter().map(|a| a.name.clone()).collect();
let topology_changed = crate::topology::reconcile(&agent_names)
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
if initial {
git(&dir, &["init", "--initial-branch=main"]).await?;
}
// Stage flake.nix *before* running nix flake lock. When meta is
// a git repo, nix treats it as a `git+file://` self-reference;
// its dirty-tree fetcher includes index entries (tracked +
// staged) but skips untracked files, so without the stage step
// an untracked flake.nix surfaces as "source tree does not
// contain '/flake.nix'". Lock then commit once with both
// flake.nix and flake.lock — single commit per change.
git(&dir, &["add", "flake.nix"]).await?;
// Stage topology.json on every sync (regenerated by reconcile
// above when the agent set changed). git add is a no-op when the
// file content is unchanged.
if crate::topology::topology_path().exists() {
git(&dir, &["add", "topology.json"]).await?;
}
nix(&dir, &["flake", "lock"]).await?;
if std::path::Path::new(&dir).join("flake.lock").exists() {
git(&dir, &["add", "flake.lock"]).await?;
}
let msg = if initial {
format!("seed meta from {} agent(s)", agents.len())
} else if topology_changed {
"regenerate meta flake + topology".to_owned()
} else {
"regenerate meta flake".to_owned()
};
git_commit(&dir, &msg).await?;
Ok(())
}
/// Phase 1 of an apply-commit deploy. Updates the locked rev of
/// `agent-<name>` to whatever `applied/<name>/main` currently points
/// at and **stages** the lock so `nixos-container update --flake
/// meta#<n>` (which reads via `git+file://`) sees the new rev via
/// the index. Doesn't commit — `finalize_deploy` commits on build
/// success, `abort_deploy` drops the staged change on failure so
/// meta history only carries successful deploys.
#[allow(dead_code)] // wired up by actions::run_apply_commit in a later commit
pub async fn prepare_deploy(name: &str) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
let input = format!("agent-{name}");
nix(&dir, &["flake", "update", &input]).await?;
// Stage the new lock — git+file://'s dirty-tree fetcher reads
// index entries, so the upcoming nixos-container update sees the
// bumped rev without a commit yet.
git(&dir, &["add", "flake.lock"]).await
}
/// Phase 2-success. Commit the staged lock with the deployed tag +
/// sha as the message. No-op when the rev was already at the right
/// place (nothing staged → nothing to commit).
#[allow(dead_code)]
pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
if !has_staged_changes(&dir).await? {
return Ok(());
}
let short = &sha[..sha.len().min(12)];
git_commit(&dir, &format!("deploy {name} {tag} {short}")).await
}
/// Phase 2-failure. Unstage + restore the lock so meta returns to
/// the previously-committed shas. The failed proposal is still
/// captured in `applied/<n>`'s annotated `failed/<id>` tag.
#[allow(dead_code)]
pub async fn abort_deploy() -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
git(&dir, &["restore", "--staged", "flake.lock"]).await?;
git(&dir, &["restore", "flake.lock"]).await
}
async fn has_staged_changes(dir: &Path) -> Result<bool> {
let st = lifecycle::git_command()
.current_dir(dir)
.args(["diff", "--cached", "--quiet"])
.status()
.await
.with_context(|| format!("git diff --cached in {}", dir.display()))?;
// exit 1 = differences present, 0 = no diff, other = error
match st.code() {
Some(0) => Ok(false),
Some(1) => Ok(true),
_ => bail!("git diff --cached exited unexpectedly"),
}
}
/// One-shot used by the manual-rebuild path: relock just one
/// agent's input and commit the lock change if any. Single-phase
/// (no separate finalize) because rebuild has no failure-revert
/// semantics — it always wants the latest main.
#[allow(dead_code)] // wired up by lifecycle::rebuild in this commit
pub async fn lock_update_for_rebuild(name: &str) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
let input = format!("agent-{name}");
nix(&dir, &["flake", "update", &input]).await?;
if git_is_clean(&dir).await? {
return Ok(());
}
git(&dir, &["add", "flake.lock"]).await?;
git_commit(&dir, &format!("rebuild {name}: lock update")).await
}
/// Update one or more named inputs in the meta flake and commit
/// the resulting lock change with a single combined message.
/// Used by the dashboard's "update meta inputs" form so the
/// operator can bulk-bump `hyperhive` + selected agents in one
/// shot. Each input name is passed verbatim to
/// Run `nix flake update [inputs...]` on the meta flake and commit the
/// resulting lock changes. When `inputs` is empty, updates ALL inputs
/// (bare `nix flake update`). The caller is responsible for picking
/// real input keys (e.g. via `inputs_view()` snapshotted from the lock
/// file) when targeting specific inputs.
pub async fn lock_update(inputs: &[String]) -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
let mut args: Vec<&str> = vec!["flake", "update"];
for i in inputs {
args.push(i.as_str());
}
nix(&dir, &args).await?;
if git_is_clean(&dir).await? {
return Ok(());
}
git(&dir, &["add", "flake.lock"]).await?;
let msg = if inputs.is_empty() {
"lock update: all inputs".to_string()
} else if inputs.len() == 1 {
format!("lock update: {}", inputs[0])
} else {
format!("lock update: {}", inputs.join(", "))
};
git_commit(&dir, &msg).await
}
/// One-shot used by the auto-update path: pin the latest hyperhive
/// rev, commit if the lock changed. Cheaper than `sync_agents`
/// because the per-agent inputs aren't touched.
#[allow(dead_code)]
pub async fn lock_update_hyperhive() -> Result<()> {
let _guard = META_LOCK.lock().await;
let dir = meta_dir();
nix(&dir, &["flake", "update", "hyperhive"]).await?;
if git_is_clean(&dir).await? {
return Ok(());
}
git(&dir, &["add", "flake.lock"]).await?;
git_commit(&dir, "bump hyperhive").await
}
fn render_flake(
hyperhive_flake: &str,
dashboard_port: u16,
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec],
) -> String {
render_flake_with_lookup(
hyperhive_flake,
dashboard_port,
operator_pronouns,
context_window_tokens,
agents,
agent_canonical_inputs,
)
}
/// Canonical inputs meta knows how to dedup. An agent that declares one
/// of these as a top-level input in its own `flake.nix` will get a
/// `follows = "<name>"` line emitted in meta — collapsing the
/// otherwise-separate-but-identical `nixpkgs_N` nodes into a single
/// meta-level reference (#355).
const CANONICAL_INPUTS: &[&str] = &["nixpkgs", "nixpkgs-unstable"];
/// Read an agent's applied `flake.lock` and return the subset of
/// `CANONICAL_INPUTS` it declares as direct (root-level) inputs.
/// Returns an empty vec when the lock is missing or unparsable —
/// safe degradation, the worst case is no dedup for that agent.
fn agent_canonical_inputs(name: &str) -> Vec<&'static str> {
let path = std::path::PathBuf::from(format!("{APPLIED_ROOT}/{name}/flake.lock"));
let Ok(raw) = std::fs::read_to_string(&path) else {
return Vec::new();
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
return Vec::new();
};
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
return Vec::new();
};
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
return Vec::new();
};
let Some(root_inputs) = nodes
.get(root_name)
.and_then(|n| n.get("inputs"))
.and_then(|v| v.as_object())
else {
return Vec::new();
};
CANONICAL_INPUTS
.iter()
.copied()
.filter(|canon| root_inputs.contains_key(*canon))
.collect()
}
/// Inner render helper accepting a lookup fn so tests can stub the
/// agent flake-lock introspection.
fn render_flake_with_lookup<F>(
hyperhive_flake: &str,
dashboard_port: u16,
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
agents: &[AgentSpec],
lookup: F,
) -> String
where
F: Fn(&str) -> Vec<&'static str>,
{
use std::fmt::Write as _;
let mut out = String::new();
out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n");
// Pin canonical nixpkgs revisions at the meta level so every input
// that pulls a nixpkgs sub-input can `follows = "nixpkgs"` and
// collapse to one shared node (closes #317). hyperhive's own
// flake.nix picks `nixos-25.11`; we mirror that here so meta and
// hyperhive don't diverge into two stable channels by default.
// Operator can override these at the meta layer to slide every
// dependent agent onto a different channel in one move.
out.push_str(" nixpkgs.url = \"github:NixOS/nixpkgs/nixos-25.11\";\n");
out.push_str(" nixpkgs-unstable.url = \"github:NixOS/nixpkgs/nixpkgs-unstable\";\n");
let _ = writeln!(out, " hyperhive.url = \"{hyperhive_flake}\";");
// Collapse hyperhive's own `nixpkgs` + `nixpkgs-unstable` inputs
// into meta's. Without this, hyperhive's flake.nix declarations
// become independent `nixpkgs_N` nodes in meta/flake.lock.
out.push_str(" hyperhive.inputs.nixpkgs.follows = \"nixpkgs\";\n");
out.push_str(" hyperhive.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\";\n");
for spec in agents {
let _ = writeln!(
out,
" agent-{}.url = \"git+file://{APPLIED_ROOT}/{}\";",
spec.name, spec.name,
);
// For each canonical input the agent declares in its own
// `flake.nix` (detected by reading its applied `flake.lock`),
// emit `inputs.agent-<name>.inputs.<canon>.follows = "<canon>"`.
// Collapses three otherwise-separate-but-identical nixpkgs
// nodes (root + agent-bitburner's + agent-dmatrix's) into one
// (closes #355). Skipped silently for agents that don't
// declare the input — emitting follows on a non-existent
// input would error at `nix flake lock` time.
for canon in lookup(&spec.name) {
let _ = writeln!(
out,
" agent-{}.inputs.{canon}.follows = \"{canon}\";",
spec.name,
);
}
}
out.push_str(" };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n");
// Free-text operator string — escape backslash + double-quote so a
// pronouns value like `he/him \ "rare"` round-trips into a valid
// nix string literal without breaking the flake.
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(
out,
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null }}:"
);
out.push_str(
r#" let
base = if isManager
then hyperhive.nixosConfigurations.manager
else hyperhive.nixosConfigurations.agent-base;
input = inputs."agent-${name}";
service = if isManager then "hive-m1nd" else "hive-ag3nt";
parentEnv = if parent == null then {} else { HIVE_PARENT = parent; };
in
base.extendModules {
modules = [
input.nixosModules.default
{
programs.git.config.user = {
name = name;
email = "${name}@hyperhive";
};
# Container-wide env: every service + co-process daemon can
# resolve the agent's durable state dir without hard-coding it.
environment.variables = {
HIVE_LABEL = name;
HYPERHIVE_STATE_DIR = "/agents/${name}/state";
};
systemd.services.${service}.environment = parentEnv // {
HIVE_PORT = toString port;
HIVE_LABEL = name;
HIVE_DASHBOARD_PORT = toString dashboardPort;
HIVE_OPERATOR_PRONOUNS = operatorPronouns;"#,
);
// Per-model context-window env vars declared in the host-level
// `services.hive-c0re.contextWindowTokens` option. Use a sorted
// iterator for deterministic flake output (no spurious git diffs).
let mut sorted_tokens: Vec<(&String, &u64)> = context_window_tokens.iter().collect();
sorted_tokens.sort_by_key(|(k, _)| k.as_str());
for (key, val) in &sorted_tokens {
let upper_key = key.to_ascii_uppercase();
let _ = writeln!(out, " HIVE_CONTEXT_WINDOW_TOKENS_{upper_key} = \"{val}\";");
}
// Forge URL — injected when hive-c0re itself has HIVE_FORGE_URL set
// (the NixOS module derives it from hyperhive.forge.{domain,httpPort}).
// Agents use it in forge_notify to poll Forgejo for PR/review events.
if let Ok(forge_url) = std::env::var("HIVE_FORGE_URL")
&& !forge_url.is_empty() {
let escaped = forge_url.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " HIVE_FORGE_URL = \"{escaped}\";");
}
out.push_str(
r#" HYPERHIVE_STATE_DIR = "/agents/${name}/state";
};
}
];
};
in
{
nixosConfigurations = {
"#,
);
// Pull the topology map once and look up each agent's parent. An
// empty / absent topology.json yields `parent = null` for everyone
// — equivalent to the pre-#361 status quo (every container at root).
// `meta::sync_agents` seeds the file on first run with manager as
// root + everyone else under manager.
let topology = crate::topology::read();
for spec in agents {
let parent_attr = topology
.get(&spec.name)
.and_then(|p| p.as_ref())
.map_or_else(|| "null".to_owned(), |p| format!("\"{p}\""));
let _ = writeln!(
out,
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; }};",
spec.name,
spec.name,
if spec.is_manager { "true" } else { "false" },
spec.port,
parent_attr,
);
}
out.push_str(" };\n };\n}\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_spec(name: &str, is_manager: bool, port: u16) -> AgentSpec {
AgentSpec {
name: name.to_owned(),
is_manager,
port,
}
}
#[test]
fn render_flake_declares_canonical_nixpkgs() {
let out = render_flake(
"github:example/hyperhive",
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
);
// Top-level nixpkgs inputs pinned by meta — every nested
// nixpkgs input can follow these instead of resolving its own
// (closes #317).
assert!(out.contains("nixpkgs.url = \"github:NixOS/nixpkgs/nixos-25.11\""));
assert!(out.contains("nixpkgs-unstable.url = \"github:NixOS/nixpkgs/nixpkgs-unstable\""));
}
#[test]
fn render_flake_collapses_hyperhive_nixpkgs_via_follows() {
let out = render_flake(
"github:example/hyperhive",
8000,
"she/her",
&std::collections::HashMap::new(),
&[],
);
// hyperhive's own `nixpkgs` + `nixpkgs-unstable` declarations
// get redirected at meta's. Without these, meta/flake.lock
// ends up with separate `nixpkgs_N` nodes for hyperhive's
// copy (the pre-#317 status quo).
assert!(out.contains("hyperhive.inputs.nixpkgs.follows = \"nixpkgs\""));
assert!(out.contains("hyperhive.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\""));
}
#[test]
fn render_flake_emits_follows_for_agents_declaring_nixpkgs() {
// Stub lookup: pretend `bitburner` declares `nixpkgs` at its
// root, while `argus` has no canonical inputs at all.
let lookup = |name: &str| -> Vec<&'static str> {
match name {
"bitburner" => vec!["nixpkgs"],
"dmatrix" => vec!["nixpkgs", "nixpkgs-unstable"],
_ => vec![],
}
};
let out = render_flake_with_lookup(
"github:example/hyperhive",
8000,
"she/her",
&std::collections::HashMap::new(),
&[
sample_spec("argus", false, 9001),
sample_spec("bitburner", false, 9002),
sample_spec("dmatrix", false, 9003),
],
lookup,
);
// bitburner declares nixpkgs → follows emitted.
assert!(
out.contains("agent-bitburner.inputs.nixpkgs.follows = \"nixpkgs\""),
"missing bitburner nixpkgs follows:\n{out}"
);
// dmatrix declares both → both follows emitted.
assert!(out.contains("agent-dmatrix.inputs.nixpkgs.follows = \"nixpkgs\""));
assert!(
out.contains("agent-dmatrix.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\"")
);
// argus declares neither → no follows emitted for it. Asserting
// ABSENCE is the important bit: emitting a follows on a
// non-existent input errors at `nix flake lock` time.
assert!(
!out.contains("agent-argus.inputs.nixpkgs"),
"argus shouldn't have nixpkgs follows:\n{out}"
);
}
#[test]
fn render_flake_skips_canonical_follows_when_lookup_returns_empty() {
let out = render_flake_with_lookup(
"github:example/hyperhive",
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
|_| Vec::new(),
);
// No agent-side follows when the lookup reports nothing
// declared — protects agents whose flake.lock can't be read
// (missing / unparsable) from being broken by a follows on a
// non-existent input.
assert!(
!out.contains("agent-alice.inputs."),
"alice shouldn't have any inputs follows:\n{out}"
);
}
}
async fn git_is_clean(dir: &Path) -> Result<bool> {
let out = lifecycle::git_command()
.current_dir(dir)
.args(["status", "--porcelain"])
.output()
.await
.with_context(|| format!("git status in {}", dir.display()))?;
Ok(out.stdout.iter().all(u8::is_ascii_whitespace))
}
async fn git(dir: &Path, args: &[&str]) -> Result<()> {
let out = lifecycle::git_command()
.current_dir(dir)
.args(args)
.output()
.await
.with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?;
if !out.status.success() {
bail!(
"git {} failed ({}): {}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
async fn git_commit(dir: &Path, message: &str) -> Result<()> {
git(
dir,
&[
"-c",
&format!("user.name={GIT_NAME}"),
"-c",
&format!("user.email={GIT_EMAIL}"),
"commit",
"-m",
message,
],
)
.await?;
// Best-effort mirror to the bundled forge. No-op when the forge
// isn't seeded (no core token on disk); push failures log a warn
// but don't bubble up — a missing mirror shouldn't fail an
// otherwise successful deploy.
if let Err(e) = crate::forge::push_meta(dir).await {
tracing::warn!(error = ?e, "forge: meta push after commit failed (non-fatal)");
}
Ok(())
}
async fn nix(dir: &Path, args: &[&str]) -> Result<()> {
// `--extra-experimental-features` belt-and-suspenders for hosts
// that haven't set this in nix.conf. The hyperhive module's
// deploy guide assumes flakes are already enabled, but the cost
// of being defensive is one extra argv each call.
let mut all = vec!["--extra-experimental-features", "nix-command flakes"];
all.extend(args);
let out = Command::new("nix")
.current_dir(dir)
.args(&all)
.output()
.await
.with_context(|| format!("nix {} in {}", args.join(" "), dir.display()))?;
if !out.status.success() {
bail!(
"nix {} failed ({}): {}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}

215
hive-c0re/src/migrate.rs Normal file
View file

@ -0,0 +1,215 @@
//! Startup auto-migration from the pre-meta layout. Runs before
//! `auto_update::run` and consists of four phases, each idempotent:
//!
//! 1. Per-agent applied repo: rewrite `flake.nix` to the module-only
//! boilerplate if it isn't already, commit, relocate `deployed/0`
//! to HEAD so `setup_applied`'s existence check passes.
//! 2. Per-agent proposed repo: ensure the `applied` git remote
//! points at `/applied/<n>/.git` (re-runs `setup_proposed`'s
//! `ensure_applied_remote` indirectly via a host-side git call).
//! 3. Meta repo: `meta::sync_agents` over the current agent list —
//! init the repo on first call, rerender + relock if anything
//! drifted.
//! 4. Container repoint: for every existing container, run
//! `nixos-container update <c> --flake meta#<name>` so it
//! activates against the meta flake. Guarded by a marker file
//! so the (expensive) phase 4 only runs once across hive-c0re
//! restarts.
//!
//! Env kill-switch: `HIVE_SKIP_META_MIGRATION=1` skips the whole
//! migration. Use when smoke-testing one agent at a time by hand.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::process::Command;
use crate::coordinator::Coordinator;
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
use crate::meta;
const KILL_SWITCH: &str = "HIVE_SKIP_META_MIGRATION";
/// Marker for phase 4. Once present, container repoint is skipped on
/// future restarts.
fn repoint_marker() -> PathBuf {
PathBuf::from("/var/lib/hyperhive/.meta-migration-done")
}
/// Substring that identifies the *current* agent flake boilerplate.
/// Bumped whenever the template changes so the startup migration
/// re-renders existing agents onto the new shape. Today the marker
/// is the `flakeInputs` module-arg forwarding line — older templates
/// (raw `import ./agent.nix`) get rewritten on next hive-c0re start.
const MODULE_FLAKE_MARKER: &str = "_module.args.flakeInputs";
pub async fn run(coord: &Arc<Coordinator>) -> Result<()> {
if std::env::var(KILL_SWITCH).is_ok() {
tracing::info!("migration: {KILL_SWITCH} set — skipping");
return Ok(());
}
// Stale meta index lock: a previous hive-c0re crash mid-`git add`
// can leave `.git/index.lock` behind, which blocks every
// subsequent meta op until somebody `rm`s it manually. We just
// booted so nothing of ours is holding it; safe to clear.
let meta_lock = std::path::PathBuf::from("/var/lib/hyperhive/meta/.git/index.lock");
if meta_lock.exists() {
match std::fs::remove_file(&meta_lock) {
Ok(()) => tracing::warn!("cleared stale meta/.git/index.lock"),
Err(e) => tracing::warn!(error = ?e, "clear stale meta lock failed"),
}
}
let names = enumerate_agents().await;
tracing::info!(count = names.len(), "migration: scanning");
// Phase 1 + 2: per-agent applied + proposed.
for name in &names {
if let Err(e) = migrate_applied_repo(name).await {
tracing::warn!(%name, error = ?e, "migration: applied repo rewrite failed");
}
if let Err(e) =
lifecycle::setup_proposed(&Coordinator::agent_proposed_dir(name), name).await
{
tracing::warn!(%name, error = ?e, "migration: setup_proposed failed");
}
}
// Phase 3: meta repo.
let agents = lifecycle::agents_for_meta_listing()
.await
.unwrap_or_default();
if let Err(e) = meta::sync_agents(
&coord.hyperhive_flake,
coord.dashboard_port,
&coord.operator_pronouns,
&coord.context_window_tokens,
&agents,
)
.await
{
tracing::warn!(error = ?e, "migration: meta sync_agents failed");
}
// Phase 4: container repoint, guarded by marker.
if repoint_marker().exists() {
tracing::debug!("migration: phase 4 marker present, skipping repoint");
return Ok(());
}
let mut all_ok = true;
for name in &names {
// Mark Rebuilding so the crash watcher skips this container
// during the brief stop+start window the nixos-container
// update activation triggers. Without this, crash_watch
// would fire ContainerCrash for every agent here and the
// manager would spuriously try to recover them.
let guard = coord.transient_guard(name, crate::coordinator::TransientKind::Rebuilding);
let result = repoint_container(name).await;
drop(guard);
if let Err(e) = result {
tracing::warn!(%name, error = ?e, "migration: container repoint failed");
all_ok = false;
}
}
if all_ok
&& !names.is_empty()
&& let Err(e) = std::fs::write(repoint_marker(), b"done\n")
{
tracing::warn!(error = ?e, "migration: write repoint marker failed");
}
Ok(())
}
async fn enumerate_agents() -> Vec<String> {
let containers = lifecycle::list().await.unwrap_or_default();
containers
.into_iter()
.filter_map(|c| {
if c == MANAGER_NAME {
Some(MANAGER_NAME.to_owned())
} else {
c.strip_prefix(AGENT_PREFIX).map(str::to_owned)
}
})
.collect()
}
async fn migrate_applied_repo(name: &str) -> Result<()> {
let dir = Coordinator::agent_applied_dir(name);
if !dir.join(".git").exists() {
return Ok(());
}
let flake_path = dir.join("flake.nix");
let cur = std::fs::read_to_string(&flake_path).unwrap_or_default();
if cur.contains(MODULE_FLAKE_MARKER) {
return Ok(());
}
let want = lifecycle::initial_flake_nix();
std::fs::write(&flake_path, want).with_context(|| format!("write {}", flake_path.display()))?;
raw_git(
&dir,
&[
"-c",
"user.name=c0re",
"-c",
"user.email=c0re@hyperhive",
"add",
"flake.nix",
],
)
.await?;
raw_git(
&dir,
&[
"-c",
"user.name=c0re",
"-c",
"user.email=c0re@hyperhive",
"commit",
"-m",
"migration: module-only flake",
],
)
.await?;
// Relocate deployed/0 to the migration commit so
// setup_applied's existence check passes.
raw_git(&dir, &["tag", "-f", "deployed/0", "HEAD"]).await?;
tracing::info!(%name, "migration: applied repo migrated to module-only flake");
Ok(())
}
async fn repoint_container(name: &str) -> Result<()> {
let container = lifecycle::container_name(name);
let flake_ref = format!("{}#{name}", meta::meta_dir().display());
let out = Command::new("nixos-container")
.args(["update", &container, "--flake", &flake_ref])
.output()
.await
.with_context(|| format!("nixos-container update {container}"))?;
if !out.status.success() {
anyhow::bail!(
"nixos-container update {container} exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
tracing::info!(%name, %container, "migration: container repointed at meta");
Ok(())
}
async fn raw_git(dir: &Path, args: &[&str]) -> Result<()> {
let out = lifecycle::git_command()
.current_dir(dir)
.args(args)
.output()
.await
.with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?;
if !out.status.success() {
anyhow::bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}

View file

@ -0,0 +1,319 @@
//! Question queue. Agents submit via `Ask`; the answer comes from
//! either the operator (via the dashboard, for `target IS NULL`) or
//! a peer agent (via `Answer`, for agent-to-agent questions).
//!
//! Despite the file name (kept for git history sanity), this table
//! now stores *all* asynchronous questions in the hive — both the
//! operator-targeted ones and the peer-to-peer ones. `target IS
//! NULL` is the operator path (back-compat with rows written before
//! the column existed); `target = '<agent-name>'` is the
//! agent-to-agent path.
use std::path::Path;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, bail};
use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize;
const SCHEMA: &str = r"
CREATE TABLE IF NOT EXISTS operator_questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asker TEXT NOT NULL,
question TEXT NOT NULL,
options_json TEXT NOT NULL,
asked_at INTEGER NOT NULL,
answered_at INTEGER,
answer TEXT
);
CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
ON operator_questions (id) WHERE answered_at IS NULL;
";
/// Add late-added columns to pre-existing databases. `ALTER TABLE
/// ADD COLUMN` has no `IF NOT EXISTS` form in sqlite, so we check
/// `pragma_table_info` first per column.
fn ensure_columns(conn: &Connection) -> Result<()> {
for (name, sql) in [
(
"multi",
"ALTER TABLE operator_questions ADD COLUMN multi INTEGER NOT NULL DEFAULT 0;",
),
(
"deadline_at",
"ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER;",
),
// `target` = recipient of the question. NULL = operator
// (back-compat default for rows written before agent-to-agent
// questions existed); a non-null agent name = peer-to-peer
// question. Dashboard's `pending()` filters on `target IS NULL`
// so peer questions never leak into the operator's queue.
(
"target",
"ALTER TABLE operator_questions ADD COLUMN target TEXT;",
),
] {
let has: bool = conn
.prepare(&format!(
"SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = '{name}'"
))?
.exists([])?;
if !has {
conn.execute_batch(sql)
.with_context(|| format!("add operator_questions.{name} column"))?;
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize)]
#[allow(clippy::doc_markdown)]
pub struct OpQuestion {
pub id: i64,
pub asker: String,
pub question: String,
pub options: Vec<String>,
pub multi: bool,
pub asked_at: i64,
/// Absolute unix-seconds deadline after which a watchdog auto-
/// resolves the question with answer `[expired]`. `None` = no
/// expiry. Surfaced on the dashboard as a remaining-time chip.
pub deadline_at: Option<i64>,
pub answered_at: Option<i64>,
pub answer: Option<String>,
/// Recipient of the question. `None` = the operator (dashboard
/// path); `Some(<agent>)` = a peer agent asked via
/// `Ask { to: Some(<agent>), ... }`. Agent-to-agent questions
/// never appear in `pending()` so the operator's queue stays clean.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
pub struct OperatorQuestions {
conn: Mutex<Connection>,
}
impl OperatorQuestions {
pub fn open(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("create operator_questions db parent {}", parent.display())
})?;
}
let conn = Connection::open(path)
.with_context(|| format!("open operator_questions db {}", path.display()))?;
conn.execute_batch(SCHEMA)
.context("apply operator_questions schema")?;
ensure_columns(&conn).context("migrate operator_questions columns")?;
Ok(Self {
conn: Mutex::new(conn),
})
}
pub fn submit(
&self,
asker: &str,
question: &str,
options: &[String],
multi: bool,
deadline_at: Option<i64>,
target: Option<&str>,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into());
conn.execute(
"INSERT INTO operator_questions
(asker, question, options_json, multi, deadline_at, target, asked_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
asker,
question,
options_json,
i64::from(multi),
deadline_at,
target,
now_unix(),
],
)?;
Ok(conn.last_insert_rowid())
}
/// Mark a pending question answered. `answerer` is who's actually
/// answering: `"operator"` for the dashboard path, or an agent's
/// own name when responding via `Answer`. Authorisation:
///
/// - Operator-targeted questions (`target IS NULL`) can only be
/// answered by `"operator"`. (Agents must not be able to spoof
/// answers to operator questions — the dashboard is the
/// privileged path.)
/// - Agent-targeted questions can only be answered by the
/// declared target agent, OR by `"operator"` (operator override
/// for stuck threads — useful when an agent is offline/down
/// and someone has to close the loop).
///
/// Returns `(question, asker, target)` so the caller can fire the
/// `QuestionAnswered` event with the right answerer label and route
/// it back to the original asker.
pub fn answer(
&self,
id: i64,
answer: &str,
answerer: &str,
) -> Result<(String, String, Option<String>)> {
let conn = self.conn.lock().unwrap();
let row: Option<(String, String, Option<String>, Option<i64>)> = conn
.query_row(
"SELECT question, asker, target, answered_at FROM operator_questions WHERE id = ?1",
params![id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.optional()?;
let Some((question, asker, target, answered_at)) = row else {
bail!("question {id} not found");
};
if answered_at.is_some() {
bail!("question {id} already answered");
}
// Authorisation check: must match the target, or be the operator
// (operator-targeted questions are operator-only; the operator
// can additionally override agent-to-agent questions to close
// stuck threads).
let authorised = match target.as_deref() {
None => answerer == hive_sh4re::OPERATOR_RECIPIENT,
Some(t) => answerer == t || answerer == hive_sh4re::OPERATOR_RECIPIENT,
};
if !authorised {
bail!(
"question {id} not addressed to '{answerer}' (target = {:?})",
target.as_deref().unwrap_or(hive_sh4re::OPERATOR_RECIPIENT)
);
}
conn.execute(
"UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3",
params![answer, now_unix(), id],
)?;
Ok((question, asker, target))
}
/// Cancel a pending question on behalf of `canceller`. Returns
/// `(question, asker, target)` so the caller can fire the usual
/// `QuestionAnswered` event to the asker with a `[cancelled by
/// <canceller>]` sentinel.
///
/// Auth: the canceller must be one of:
/// - the original asker (an agent withdrawing their own ask),
/// - the operator (already covered by the existing `answer` path
/// but allowed here too for symmetry / dashboard cancel),
/// - the manager (privileged hive-wide cleanup).
///
/// Not the target — that's covered by `answer` (responding with
/// an actual reply, sentinel or otherwise).
pub fn cancel(
&self,
id: i64,
canceller: &str,
) -> Result<(String, String, Option<String>)> {
let conn = self.conn.lock().unwrap();
let row: Option<(String, String, Option<String>, Option<i64>)> = conn
.query_row(
"SELECT question, asker, target, answered_at FROM operator_questions WHERE id = ?1",
params![id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.optional()?;
let Some((question, asker, target, answered_at)) = row else {
bail!("question {id} not found");
};
if answered_at.is_some() {
bail!("question {id} already answered/cancelled");
}
let authorised = canceller == asker
|| canceller == hive_sh4re::OPERATOR_RECIPIENT
|| canceller == hive_sh4re::MANAGER_AGENT;
if !authorised {
bail!(
"question {id}: '{canceller}' not allowed to cancel (asker = '{asker}')"
);
}
let sentinel = format!("[cancelled by {canceller}]");
conn.execute(
"UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3",
params![sentinel, now_unix(), id],
)?;
Ok((question, asker, target))
}
#[allow(dead_code)]
pub fn get(&self, id: i64) -> Result<Option<OpQuestion>> {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
FROM operator_questions WHERE id = ?1",
params![id],
row_to_question,
)
.optional()
.map_err(Into::into)
}
/// Every pending question, operator-targeted or peer-to-peer.
/// Drives the dashboard's questions pane now that peer threads
/// are surfaced for visibility + operator override-answer.
pub fn pending_all(&self) -> Result<Vec<OpQuestion>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
FROM operator_questions
WHERE answered_at IS NULL
ORDER BY id ASC",
)?;
let rows = stmt.query_map([], row_to_question)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
/// Last `limit` answered questions across both target kinds,
/// newest-first. Companion to `pending_all`.
pub fn recent_answered_all(&self, limit: u64) -> Result<Vec<OpQuestion>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
FROM operator_questions
WHERE answered_at IS NOT NULL
ORDER BY answered_at DESC
LIMIT ?1",
)?;
let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
let rows = stmt.query_map(params![limit_i], row_to_question)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
}
fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
let options_json: String = row.get(3)?;
let options: Vec<String> = serde_json::from_str(&options_json).unwrap_or_default();
let multi: i64 = row.get(4)?;
Ok(OpQuestion {
id: row.get(0)?,
asker: row.get(1)?,
question: row.get(2)?,
options,
multi: multi != 0,
asked_at: row.get(5)?,
answered_at: row.get(6)?,
answer: row.get(7)?,
deadline_at: row.get(8)?,
target: row.get(9)?,
})
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0)
}

257
hive-c0re/src/questions.rs Normal file
View file

@ -0,0 +1,257 @@
//! Shared dispatch helpers for the `Ask` / `Answer` flow. Both the
//! agent socket and the manager socket call into here so the routing
//! semantics — recipient = operator vs. peer agent, answerer
//! authorisation, asker-notification — only live in one place.
//!
//! Routing rules at a glance:
//!
//! - `Ask { to: None | Some("operator") }` → stored with `target = NULL`;
//! the dashboard's `pending()` query surfaces it; operator answers
//! via the dashboard.
//! - `Ask { to: Some(<agent>) }` → stored with `target = <agent>`;
//! a `HelperEvent::QuestionAsked` is pushed into `<agent>`'s
//! inbox so they can `Answer { id, answer }` on their own socket.
//! - `Answer { id, answer }` → permission-checked in
//! `OperatorQuestions::answer` (only the target agent or the
//! operator can answer; both paths fire the same
//! `QuestionAnswered` event to the asker).
use std::sync::Arc;
use crate::approvals::kind_to_str;
use crate::coordinator::Coordinator;
use crate::limits;
use crate::manager_server::spawn_question_watchdog;
/// Cap on how long an asker can demand an answer before the watchdog
/// auto-resolves with `[expired]`. Six hours mirrors typical agent
/// session lifetimes — beyond that an unanswered question is
/// effectively a dead thread and should be re-asked, not blocked on.
const MAX_TTL_SECONDS: u64 = 6 * 60 * 60;
/// Handle either surface's `Ask` request. Returns the queued
/// question id on success or a caller-ready error string. Caller is
/// responsible for wrapping in the matching `*Response::Err` /
/// `QuestionQueued` variant.
pub fn handle_ask(
coord: &Arc<Coordinator>,
asker: &str,
question: &str,
options: &[String],
multi: bool,
ttl_seconds: Option<u64>,
to: Option<&str>,
) -> Result<i64, String> {
limits::check_size("question", question)?;
// Normalise `Some("operator")` → None so the storage layer
// only has to think about NULL vs. non-NULL targets, not
// "is this string the operator?".
let target = match to {
None => None,
Some(t) if t == hive_sh4re::OPERATOR_RECIPIENT => None,
Some("") => {
return Err("ask: `to` cannot be empty (omit it for the operator path)".to_owned());
}
Some(t) if t == asker => {
return Err("ask: cannot ask yourself a question (would loop forever)".to_owned());
}
Some(t) => Some(t),
};
let ttl = ttl_seconds.map(|s| s.min(MAX_TTL_SECONDS));
let deadline_at = ttl.and_then(|s| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
i64::try_from(s).ok().map(|s| now + s)
});
let id = coord
.questions
.submit(asker, question, options, multi, deadline_at, target)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %asker, ?target, ?deadline_at, "question queued");
// Agent-targeted questions need to wake the recipient — drop a
// QuestionAsked event into their inbox so the answerer doesn't
// have to poll. Operator-targeted questions show up on the
// dashboard's pending pane via `pending()` instead, plus a
// `QuestionAdded` dashboard event so the browser updates live.
if let Some(target_agent) = target {
coord.notify_agent(
target_agent,
&hive_sh4re::HelperEvent::QuestionAsked {
id,
asker: asker.to_owned(),
question: question.to_owned(),
options: options.to_vec(),
multi,
},
);
}
// Always fire on the dashboard channel — both operator-targeted
// and peer threads now surface in the dashboard's questions pane.
coord.emit_question_added(id, asker, question, options, multi, deadline_at, target);
if let Some(t) = ttl {
spawn_question_watchdog(coord, id, t);
}
Ok(id)
}
/// Handle either surface's `Answer` request. Returns `Ok(())` on
/// success or a caller-ready error string. Authorisation lives in
/// `OperatorQuestions::answer` — we only have to wire the result
/// back to the asker as a `QuestionAnswered` event.
pub fn handle_answer(
coord: &Arc<Coordinator>,
answerer: &str,
id: i64,
answer: &str,
) -> Result<(), String> {
limits::check_size("answer", answer)?;
let (question, asker, target) = coord
.questions
.answer(id, answer, answerer)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %answerer, %asker, "question answered");
// Use answerer as the broker `from` so the asker's terminal shows
// the real name (agent or "operator") instead of "system".
coord.notify_agent_from(
answerer,
&asker,
&hive_sh4re::HelperEvent::QuestionAnswered {
id,
question,
answer: answer.to_owned(),
answerer: answerer.to_owned(),
},
);
// Dashboard surfaces both operator-targeted and peer threads;
// emit unconditionally so the derived store moves the row.
// `cancelled = false` because this path is a real answer (the
// operator-cancel button goes through `post_cancel_question`).
coord.emit_question_resolved(id, answer, answerer, false, target.as_deref());
Ok(())
}
/// Handle `CancelLooseEnd` from either surface. Dispatches by kind to
/// either `OperatorQuestions::cancel` or `Broker::cancel_reminder_as`,
/// both of which do their own auth check (canceller == owner /
/// asker, or `operator`, or `manager`). On question cancel, fires
/// the `QuestionAnswered` event back to the asker so the harness
/// loop can react (mirrors the operator-cancel dashboard path).
pub fn handle_cancel_loose_end(
coord: &Arc<Coordinator>,
canceller: &str,
kind: hive_sh4re::CancelLooseEndKind,
id: i64,
) -> Result<(), String> {
match kind {
hive_sh4re::CancelLooseEndKind::Question => {
let (question, asker, target) = coord
.questions
.cancel(id, canceller)
.map_err(|e| format!("{e:#}"))?;
let sentinel = format!("[cancelled by {canceller}]");
tracing::info!(%id, %canceller, %asker, "question cancelled");
// Only notify the asker if they didn't cancel it themselves.
// Self-cancels are already known to the canceller — sending
// a QuestionAnswered back would cause the harness to process
// its own cancel as an incoming answer.
if asker != canceller {
coord.notify_agent_from(
canceller,
&asker,
&hive_sh4re::HelperEvent::QuestionAnswered {
id,
question,
answer: sentinel.clone(),
answerer: canceller.to_owned(),
},
);
}
coord.emit_question_resolved(id, &sentinel, canceller, true, target.as_deref());
Ok(())
}
hive_sh4re::CancelLooseEndKind::Reminder => {
let owner = coord
.broker
.cancel_reminder_as(id, canceller)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %canceller, %owner, "reminder cancelled");
Ok(())
}
hive_sh4re::CancelLooseEndKind::Approval => {
// Manager-only: only the agent that can submit approvals
// is allowed to withdraw them. Sub-agents would have no
// pending approvals of their own to cancel anyway.
check_approval_canceller_is_manager(canceller)?;
let approval = coord
.approvals
.mark_cancelled(id, canceller)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %canceller, agent = %approval.agent, "approval cancelled");
let sha_short = approval
.fetched_sha
.as_deref()
.map(|s| s[..s.len().min(12)].to_owned());
coord.emit_approval_resolved(
approval.id,
&approval.agent,
kind_to_str(approval.kind),
sha_short,
"cancelled",
approval.note,
approval.description,
);
Ok(())
}
}
}
/// Manager-only guard on the `Approval` cancel arm. Pulled out so
/// the auth check has its own focused unit test (argus nit on #508)
/// — testing the full `handle_cancel_loose_end` flow would need a
/// `Coordinator` fixture (broker + sqlite + in-memory questions),
/// which we don't have today. The check is a single string compare,
/// so a function-level test gives the same coverage with no harness.
fn check_approval_canceller_is_manager(canceller: &str) -> Result<(), String> {
if canceller != hive_sh4re::MANAGER_AGENT {
return Err("cancel_loose_end: only the manager can cancel approval rows".to_owned());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn approval_cancel_rejects_sub_agent_callers() {
// Argus nit on #508: sub-agents must not be able to cancel
// approval rows even if they invent an id. The guard is
// server-side so client cooperation is irrelevant.
let err = check_approval_canceller_is_manager("bitburner").unwrap_err();
assert!(err.contains("only the manager"), "{err}");
// Bonus: empty / operator strings also rejected (only the
// exact MANAGER_AGENT constant passes).
assert!(check_approval_canceller_is_manager("").is_err());
assert!(
check_approval_canceller_is_manager(hive_sh4re::OPERATOR_RECIPIENT)
.is_err(),
"operator surface uses the dashboard cancel path, not this dispatcher",
);
}
#[test]
fn approval_cancel_allows_manager() {
check_approval_canceller_is_manager(hive_sh4re::MANAGER_AGENT)
.expect("MANAGER_AGENT must pass the guard");
}
}
// Real coverage needs a `Coordinator` fixture (broker + sqlite +
// in-memory questions). Skipped for now — the normalisation branches
// in `handle_ask` are short enough to read line-by-line; once we add
// a coord test harness, drop integration tests here for: self-target
// rejection, operator-string passthrough, agent-to-agent QuestionAsked
// emission, and `Answer` authorisation.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,304 @@
//! Background loop that drains due reminders out of the broker and
//! delivers them as inbox messages. Mirrors the `events_vacuum` /
//! `crash_watch` shape — a single `spawn(coord)` entry point started
//! from `main.rs`.
//!
//! File-path semantics: a reminder may carry a `file_path` (the
//! agent-visible path inside its container). On delivery we:
//!
//! - Translate the container path (`/agents/<agent>/state/foo.md`) to
//! the host path (`/var/lib/hyperhive/agents/<agent>/state/foo.md`)
//! so hive-c0re can write to it from outside the container.
//! - Reject anything that isn't under the agent's own state subtree,
//! contains `..` (path traversal), or has an empty relative tail.
//! Falling outside the allowed prefix means the file write is
//! skipped and the original message is delivered inline (with a
//! noted warning) — the reminder still fires, just without the
//! payload split.
//! - Defend against symlink escape: after `create_dir_all`, the
//! parent dir is canonicalized and re-verified to live under the
//! agent's host state root. Then we open the final file with
//! `O_NOFOLLOW | O_CREAT | O_TRUNC` so an existing-symlink basename
//! can't redirect the write either. Without this an agent could
//! `ln -s /etc /agents/foo/state/escape` and bounce a write to an
//! arbitrary host path.
//! - Write the reminder body to disk and deliver a short pointer
//! message in its place, so the agent's inbox/wake-prompt stays
//! small and the bulky payload can be read out of band.
//!
//! Atomicity of the inbox INSERT + `reminders.sent_at` UPDATE is handled
//! inside `Broker::deliver_reminders_batch`; this module only computes the
//! body strings before calling it.
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use crate::coordinator::Coordinator;
/// Per-tick cap on reminders delivered. Anything over this stays due
/// in the table and gets picked up on the next tick — keeps a
/// 10k-deep backlog from flooding the broker (or hogging the broker
/// mutex) in one shot. 100/tick × 5s tick = sustained throughput cap
/// of ~20 reminders/sec; bump together if the loose-ends tracker
/// starts firing higher rates.
const REMINDER_BATCH_LIMIT: u64 = 100;
/// Poll interval. Trade-off between latency on a freshly due reminder
/// and CPU spent on empty sweeps; 5s matches the original inline
/// scheduler.
const POLL_INTERVAL: Duration = Duration::from_secs(5);
pub fn spawn(coord: Arc<Coordinator>) {
let mut shutdown = coord.shutdown_rx();
tokio::spawn(async move {
loop {
tick(&coord);
tokio::select! {
() = tokio::time::sleep(POLL_INTERVAL) => {}
_ = shutdown.changed() => {
tracing::info!("reminder scheduler: shutdown signal received");
break;
}
}
}
});
}
fn tick(coord: &Arc<Coordinator>) {
let due = match coord.broker.get_due_reminders(REMINDER_BATCH_LIMIT) {
Ok(rows) => rows,
Err(e) => {
tracing::warn!(error = ?e, "failed to query due reminders");
return;
}
};
if due.is_empty() {
return;
}
// Resolve body strings (file-path writes / inline) before entering
// the batch transaction so the DB lock is held as briefly as possible.
let items: Vec<(i64, String, String)> = due
.iter()
.map(|(agent, id, message, file_path)| {
let body = prepare_body(agent, message, file_path.as_deref());
(*id, agent.clone(), body)
})
.collect();
// Single-transaction batch: one DB lock acquisition for N reminders
// instead of N sequential lock/unlock cycles.
let results = coord.broker.deliver_reminders_batch(&items);
for ((id, agent, _body), result) in items.iter().zip(results.iter()) {
if let Err(e) = result {
let reason = format!("{e:#}");
tracing::warn!(
reminder_id = id,
%agent,
error = %reason,
"failed to deliver reminder"
);
// Persist the failure so the dashboard can surface it.
if let Err(persist_err) = coord.broker.record_reminder_failure(*id, &reason) {
tracing::warn!(
reminder_id = id,
error = ?persist_err,
"failed to persist reminder failure"
);
}
}
}
}
/// Build the inbox body for a due reminder. When `file_path` is None
/// the body is the original message verbatim. When set, we attempt to
/// persist the message body to the requested file and return a short
/// pointer string instead. Failures (bad prefix, symlink escape,
/// write error, missing parent) fall back to inline delivery with a
/// noted warning so the reminder still fires.
fn prepare_body(agent: &str, message: &str, file_path: Option<&str>) -> String {
let Some(req_path) = file_path else {
return message.to_owned();
};
let host_path = match resolve_host_path(agent, req_path) {
Ok(p) => p,
Err(reason) => {
tracing::warn!(%agent, %req_path, %reason, "reminder file_path rejected; delivering inline");
return inline_fallback(req_path, &format!("rejected: {reason}"), message);
}
};
match write_payload(agent, &host_path, message) {
Ok(()) => {
let bytes = message.len();
// debug! not info! — under load this would dominate the log.
tracing::debug!(%agent, path = %host_path.display(), bytes, "reminder body written to file");
format!(
"reminder body persisted to `{req_path}` ({bytes} bytes); read with your filesystem tools"
)
}
Err(reason) => {
tracing::warn!(%agent, path = %host_path.display(), %reason, "reminder file_path write failed; delivering inline");
inline_fallback(req_path, &reason, message)
}
}
}
fn inline_fallback(req_path: &str, reason: &str, message: &str) -> String {
format!("[reminder file_path '{req_path}' {reason}; delivering body inline]\n\n{message}")
}
/// Persist `message` to `host_path` with the symlink-escape defenses
/// described in the module docs. Returns `Ok(())` on success, or a
/// human-readable reason string on any failure (caller logs +
/// inline-falls-back). `pub` because `agent_server::handle_remind`
/// reuses it for the at-remind-time auto-file path.
pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), String> {
let Some(parent) = host_path.parent() else {
return Err("internal: host path has no parent".to_owned());
};
std::fs::create_dir_all(parent)
.map_err(|e| format!("parent dir create failed: {e}"))?;
// Resolve symlinks in the parent chain, then re-verify the
// canonical form still lives under the agent's host state root —
// catches `ln -s /etc state/escape` style attacks.
let parent_canonical = parent
.canonicalize()
.map_err(|e| format!("parent canonicalize failed: {e}"))?;
let agent_root = Coordinator::agent_notes_dir(agent)
.canonicalize()
.map_err(|e| format!("agent state root canonicalize failed: {e}"))?;
if !parent_canonical.starts_with(&agent_root) {
return Err(format!(
"symlink escape: canonical parent `{}` outside agent root `{}`",
parent_canonical.display(),
agent_root.display()
));
}
let basename = host_path
.file_name()
.ok_or_else(|| "missing basename".to_owned())?;
let target = parent_canonical.join(basename);
// O_NOFOLLOW on the final component refuses to open if the
// basename is itself an existing symlink. Combined with the
// canonicalize-parent check above, no symlink anywhere in the
// path can redirect the write.
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.custom_flags(libc::O_NOFOLLOW)
.open(&target)
.map_err(|e| format!("open failed: {e}"))?;
file.write_all(message.as_bytes())
.map_err(|e| format!("write failed: {e}"))?;
Ok(())
}
/// Container-visible state prefix the caller's `file_path` must live
/// under. Sub-agents see their state at `/agents/<name>/state/`;
/// the manager keeps the legacy `/state/` mount (see
/// `lifecycle::set_nspawn_flags`). Auto-file paths use the same
/// prefix so the round-trip is symmetric.
#[must_use]
pub fn container_state_prefix(agent: &str) -> String {
if agent == hive_sh4re::MANAGER_AGENT {
"/state/".to_owned()
} else {
format!("/agents/{agent}/state/")
}
}
/// Map an agent-visible container path to the matching host path,
/// validating that it lives under the agent's own state subtree, has
/// a non-empty relative tail, and doesn't try to traverse out via
/// `..`. Returns the host `PathBuf` on success, or a human-readable
/// reason string on rejection. `pub` so `agent_server::handle_remind`
/// can reuse it for the at-remind-time auto-file path.
pub fn resolve_host_path(agent: &str, req_path: &str) -> Result<PathBuf, String> {
let prefix = container_state_prefix(agent);
let Some(rel) = req_path.strip_prefix(&prefix) else {
return Err(format!(
"must be absolute and under `{prefix}` (got `{req_path}`)"
));
};
if rel.is_empty() {
return Err("file_path must include a filename, not just the state dir".to_owned());
}
let rel_path = Path::new(rel);
for comp in rel_path.components() {
match comp {
std::path::Component::Normal(_) => {}
other => {
return Err(format!(
"path component `{other:?}` not allowed (no traversal / absolute / root)"
));
}
}
}
Ok(Coordinator::agent_notes_dir(agent).join(rel_path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_paths_outside_agent_state() {
assert!(resolve_host_path("foo", "/etc/passwd").is_err());
assert!(resolve_host_path("foo", "/agents/bar/state/x.md").is_err());
assert!(resolve_host_path("foo", "relative.md").is_err());
}
#[test]
fn rejects_traversal() {
assert!(resolve_host_path("foo", "/agents/foo/state/../../etc/passwd").is_err());
assert!(resolve_host_path("foo", "/agents/foo/state/./x.md").is_err());
}
#[test]
fn rejects_empty_relative_tail() {
// Trailing slash → empty tail. Used to fall through to
// create_dir_all + write-to-dir → confusing inline fallback;
// explicit reject gives a cleaner log.
let err = resolve_host_path("foo", "/agents/foo/state/").unwrap_err();
assert!(err.contains("must include a filename"), "got: {err}");
}
#[test]
fn accepts_well_formed_path() {
let p = resolve_host_path("foo", "/agents/foo/state/reminders/123.md").unwrap();
assert_eq!(
p,
PathBuf::from("/var/lib/hyperhive/agents/foo/state/reminders/123.md")
);
}
#[test]
fn manager_uses_legacy_state_prefix() {
// The manager container mounts its state at `/state/` (legacy),
// not `/agents/manager/state/`. Same host path; different
// container-visible path. resolve_host_path needs to know.
assert_eq!(container_state_prefix("manager"), "/state/");
let p = resolve_host_path("manager", "/state/reminders/x.md").unwrap();
assert_eq!(
p,
PathBuf::from("/var/lib/hyperhive/agents/manager/state/reminders/x.md")
);
// And the sub-agent prefix must NOT be accepted for the manager.
assert!(resolve_host_path("manager", "/agents/manager/state/x.md").is_err());
}
#[test]
fn prepare_body_passthrough_when_no_file_path() {
let s = prepare_body("foo", "hello world", None);
assert_eq!(s, "hello world");
}
#[test]
fn prepare_body_falls_back_inline_on_bad_path() {
let s = prepare_body("foo", "payload", Some("/etc/passwd"));
assert!(s.starts_with("[reminder file_path '/etc/passwd' rejected:"));
assert!(s.contains("payload"));
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,391 @@
//! Background loop that drains due `scheduled_prompts` rows
//! (#444) and fans the body out as inbox `Message`s to each
//! active target. Mirrors `reminder_scheduler::spawn` shape:
//! single `spawn(coord)` entry, 5s poll cadence, shutdown-aware.
//!
//! ## Catch-up semantics
//!
//! When hive-c0re comes back from being down, a recurring row
//! whose `next_fire_at` is well in the past would otherwise fire
//! N delayed pulses in a row. Instead we fire ONCE and let
//! `ScheduledPrompts::rearm` bump `next_fire_at` to the next
//! interval slot ≥ `now`, recording the skipped-cycle count in
//! the per-target `last_result` so operators see how many
//! firings were caught up rather than losing the signal.
//!
//! ## Missing-target failure
//!
//! When a target name doesn't resolve to a known agent (the
//! container has been destroyed, the operator typo'd a name,
//! etc.) the worker:
//! 1. records `last_result = "no such agent: <name>"` against
//! the per-target row,
//! 2. sends a single advisory `Message` from `system` to
//! `operator` describing the schedule + target + reason,
//! 3. continues fanning out to the other (live) targets.
//!
//! Transient broker errors (sqlite lock contention, etc.) get
//! the per-target `last_result` annotated AND a `tracing::warn`,
//! but the post-fire bookkeeping treats the row the same way it
//! does on a clean fire:
//! - **recurring** rows re-arm — the next interval slot tries
//! the broker send again, so transient errors self-heal.
//! - **one-shots** delete unconditionally after their single
//! fan-out pass; a broker failure on a one-shot is NOT
//! retried (the operator advisory + last_result are the only
//! audit trail).
use std::sync::Arc;
use std::time::Duration;
use hive_sh4re::Message;
use crate::coordinator::Coordinator;
use crate::scheduled_prompts::Schedule;
/// Per-tick cap. Each schedule fires once per tick at most;
/// 100/tick × 5s tick = sustained throughput cap of ~20/sec,
/// matching `reminder_scheduler::REMINDER_BATCH_LIMIT`. Bump
/// together if real-world rates push past this.
const SCHEDULE_BATCH_LIMIT: u64 = 100;
/// Poll interval. Same 5s as the reminder scheduler — picking
/// up freshly-due rows within at most one tick keeps the
/// dashboard's "next fire in ..." countdown honest without
/// burning CPU on empty sweeps.
const POLL_INTERVAL: Duration = Duration::from_secs(5);
/// Reap cancelled schedules older than this from the table so
/// the dashboard list view doesn't accrue tombstones forever.
/// Cancelled rows live long enough that the operator can still
/// see what they cancelled in the recent past.
const CANCELLED_REAP_AGE: Duration = Duration::from_secs(3600);
pub fn spawn(coord: Arc<Coordinator>) {
let mut shutdown = coord.shutdown_rx();
tokio::spawn(async move {
loop {
tick(&coord);
tokio::select! {
() = tokio::time::sleep(POLL_INTERVAL) => {}
_ = shutdown.changed() => {
tracing::info!("scheduled_prompts worker: shutdown signal received");
break;
}
}
}
});
}
fn tick(coord: &Arc<Coordinator>) {
let now = now_unix();
let due = match coord.scheduled_prompts.due(now, SCHEDULE_BATCH_LIMIT) {
Ok(rows) => rows,
Err(e) => {
tracing::warn!(error = ?e, "scheduled_prompts: query due rows failed");
return;
}
};
if due.is_empty() {
// Periodic reaper still gets a chance even on empty ticks.
let cutoff = now - i64::try_from(CANCELLED_REAP_AGE.as_secs()).unwrap_or(0);
if let Err(e) = coord.scheduled_prompts.reap_cancelled(cutoff) {
tracing::warn!(error = ?e, "scheduled_prompts: reap cancelled failed");
}
return;
}
for schedule in due {
fire_schedule(coord, &schedule, now);
}
let cutoff = now - i64::try_from(CANCELLED_REAP_AGE.as_secs()).unwrap_or(0);
if let Err(e) = coord.scheduled_prompts.reap_cancelled(cutoff) {
tracing::warn!(error = ?e, "scheduled_prompts: reap cancelled failed");
}
}
/// Fan out one schedule's body to every active target. Records
/// per-target last_result; advances or reaps the parent row at
/// the end depending on whether `interval_seconds` is set.
fn fire_schedule(coord: &Arc<Coordinator>, schedule: &Schedule, now: i64) {
let known: std::collections::HashSet<String> = known_agents(coord);
for target_row in &schedule.targets {
if target_row.cancelled_at_unix.is_some() {
continue;
}
let target = &target_row.target;
// `operator` is a valid recipient (mara c4) — operator
// delivery uses the regular broker path; the dashboard
// mirrors `to == operator` into its own pane.
if target != hive_sh4re::OPERATOR_RECIPIENT && !known.contains(target) {
let reason = format!("no such agent: {target}");
if let Err(e) = coord.scheduled_prompts.record_target_result(
schedule.id,
target,
now,
&reason,
) {
tracing::warn!(error = ?e, schedule = schedule.id, %target, "record_target_result failed");
}
notify_operator_missing_target(coord, schedule, target);
continue;
}
let msg = Message {
from: "scheduled".to_owned(),
to: target.clone(),
body: schedule.body.clone(),
in_reply_to: None,
};
let result = coord.broker.send(&msg);
let result_str = match &result {
Ok(()) => "ok".to_owned(),
Err(e) => format!("broker send failed: {e:#}"),
};
if let Err(e) = result {
tracing::warn!(
schedule = schedule.id,
%target,
error = ?e,
"scheduled_prompts: broker send failed (will retry on next interval)"
);
}
if let Err(e) =
coord
.scheduled_prompts
.record_target_result(schedule.id, target, now, &result_str)
{
tracing::warn!(error = ?e, schedule = schedule.id, %target, "record_target_result failed");
}
}
// Advance or reap. One-shots delete; recurring re-arm with
// catch-up clamp.
if schedule.interval_seconds.is_some() {
match coord.scheduled_prompts.rearm(schedule.id, now) {
Ok(0) => {}
Ok(skipped) => {
tracing::info!(
schedule = schedule.id,
skipped,
"scheduled_prompts: caught up missed cycles"
);
}
Err(e) => {
tracing::warn!(error = ?e, schedule = schedule.id, "rearm failed");
}
}
} else if let Err(e) = coord.scheduled_prompts.delete(schedule.id) {
tracing::warn!(error = ?e, schedule = schedule.id, "delete one-shot failed");
}
}
/// Snapshot of live container names for the missing-target check.
/// Always seeds the manager name (which is always reachable);
/// adds every live nspawn container that matches the `h-` prefix.
/// On `lifecycle::list` failure the set stays at just the manager
/// — fail-CLOSED, meaning every non-operator/non-manager target
/// looks missing this tick and gets the same treatment as a
/// genuinely-destroyed agent: operator advisory + per-target
/// `last_result` annotation + skipped delivery. Recurring
/// schedules recover automatically on the next tick (the lifecycle
/// listing usually works); one-shots that land on this window
/// lose their single delivery. Logged at `warn`, not propagated.
fn known_agents(_coord: &Coordinator) -> std::collections::HashSet<String> {
// `lifecycle::list` is async; the worker tick is sync. Use the
// blocking variant via a small `tokio::runtime::Handle::block_on`
// wrapper. The worker runs in its own tokio task so this is
// safe (we're not in a `current_thread` runtime).
use std::collections::HashSet;
let mut out: HashSet<String> = HashSet::new();
out.insert(hive_sh4re::MANAGER_AGENT.to_owned());
let containers = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(crate::lifecycle::list())
});
match containers {
Ok(list) => {
for raw in list {
if let Some(name) = raw.strip_prefix(crate::lifecycle::AGENT_PREFIX) {
out.insert(name.to_owned());
} else if raw == crate::lifecycle::MANAGER_NAME {
out.insert(hive_sh4re::MANAGER_AGENT.to_owned());
}
}
}
Err(e) => {
tracing::warn!(error = ?e, "scheduled_prompts: container listing failed");
}
}
out
}
/// Send the operator a one-line advisory when a schedule fires
/// against an agent that no longer exists. Best-effort — failure
/// to send just gets logged; the schedule continues firing.
fn notify_operator_missing_target(coord: &Coordinator, schedule: &Schedule, target: &str) {
let body = format!(
"scheduled prompt #{id} fired but target `{target}` is not a live agent. \
body was:\n\n{body}",
id = schedule.id,
target = target,
body = schedule.body
);
let msg = Message {
from: "scheduled".to_owned(),
to: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
body,
in_reply_to: None,
};
if let Err(e) = coord.broker.send(&msg) {
tracing::warn!(error = ?e, schedule = schedule.id, %target, "operator advisory send failed");
}
}
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0)
}
/// Per-target outcome counts for one `fire_now` invocation.
/// Returned to the operator so the dashboard can render
/// "fired to N (M failed, K missing)" without a follow-up GET.
#[derive(Debug, Clone, serde::Serialize)]
pub struct FireNowReport {
/// Targets the broker accepted the message for.
pub ok: u32,
/// Targets where broker.send returned an error.
pub failed: u32,
/// Targets that didn't resolve to a known agent (and got the
/// operator-advisory treatment).
pub missing: u32,
/// Whether the one-shot was consumed by this manual fire.
/// `true` only when the schedule was a one-shot (recurring
/// schedules never auto-cancel on manual fire — they keep
/// their cadence).
pub one_shot_consumed: bool,
}
/// Manual / out-of-band fire of a scheduled prompt (#467 "fire
/// now" button). Mirrors the per-target fan-out of `fire_schedule`
/// but skips the rearm step entirely — manual fires don't disturb
/// a recurring schedule's rhythm. For one-shots, a manual fire
/// **consumes** the schedule (operator intent: "send this now,
/// the scheduled time was wrong"); recurring schedules keep their
/// `next_fire_at_unix` unchanged.
///
/// `last_result` is annotated with the `manual fire:` prefix so
/// the dashboard's per-target last-result column can distinguish
/// scheduled fires from operator-initiated ones at a glance.
///
/// Returns Err if the schedule is missing, cancelled, or fully
/// drained of active targets — the dashboard can surface those
/// as plain 4xxs instead of pretending to fire a phantom row.
pub async fn fire_now(
coord: &std::sync::Arc<Coordinator>,
schedule_id: i64,
) -> anyhow::Result<FireNowReport> {
let now = now_unix();
let schedule = coord
.scheduled_prompts
.get(schedule_id)?
.ok_or_else(|| anyhow::anyhow!("schedule {schedule_id} not found"))?;
if schedule.cancelled_at_unix.is_some() {
anyhow::bail!("schedule {schedule_id} is already cancelled");
}
if !schedule.targets.iter().any(|t| t.cancelled_at_unix.is_none()) {
anyhow::bail!("schedule {schedule_id} has no active targets");
}
let known = known_agents_async().await;
let mut report = FireNowReport {
ok: 0,
failed: 0,
missing: 0,
one_shot_consumed: false,
};
for target_row in &schedule.targets {
if target_row.cancelled_at_unix.is_some() {
continue;
}
let target = &target_row.target;
if target != hive_sh4re::OPERATOR_RECIPIENT && !known.contains(target) {
let reason = format!("manual fire: no such agent: {target}");
if let Err(e) = coord.scheduled_prompts.record_target_result(
schedule_id,
target,
now,
&reason,
) {
tracing::warn!(error = ?e, schedule = schedule_id, %target, "record_target_result failed");
}
notify_operator_missing_target(coord, &schedule, target);
report.missing += 1;
continue;
}
let msg = Message {
from: "scheduled".to_owned(),
to: target.clone(),
body: schedule.body.clone(),
in_reply_to: None,
};
let result = coord.broker.send(&msg);
let result_str = match &result {
Ok(()) => "manual fire: ok".to_owned(),
Err(e) => format!("manual fire: broker send failed: {e:#}"),
};
if result.is_ok() {
report.ok += 1;
} else {
report.failed += 1;
tracing::warn!(
schedule = schedule_id,
%target,
error = ?result.as_ref().err(),
"fire_now: broker send failed (no retry — manual fires don't loop)"
);
}
if let Err(e) =
coord
.scheduled_prompts
.record_target_result(schedule_id, target, now, &result_str)
{
tracing::warn!(error = ?e, schedule = schedule_id, %target, "record_target_result failed");
}
}
if schedule.interval_seconds.is_none() {
// One-shot is consumed by the manual fire. Recurring
// schedules stay untouched — their cadence is the whole
// point and a manual fire is meant to be additive.
if let Err(e) = coord.scheduled_prompts.cancel_all(schedule_id) {
tracing::warn!(error = ?e, schedule = schedule_id, "cancel_all after one-shot manual fire failed");
} else {
report.one_shot_consumed = true;
}
}
Ok(report)
}
/// Async variant of `known_agents` for `fire_now`. Same logic +
/// same fail-closed degradation; the difference is just that the
/// dashboard handler is genuinely async so we `await` the
/// `lifecycle::list` directly instead of going through the
/// `block_in_place` shim.
async fn known_agents_async() -> std::collections::HashSet<String> {
use std::collections::HashSet;
let mut out: HashSet<String> = HashSet::new();
out.insert(hive_sh4re::MANAGER_AGENT.to_owned());
match crate::lifecycle::list().await {
Ok(list) => {
for raw in list {
if let Some(name) = raw.strip_prefix(crate::lifecycle::AGENT_PREFIX) {
out.insert(name.to_owned());
} else if raw == crate::lifecycle::MANAGER_NAME {
out.insert(hive_sh4re::MANAGER_AGENT.to_owned());
}
}
}
Err(e) => {
tracing::warn!(error = ?e, "fire_now: container listing failed");
}
}
out
}

206
hive-c0re/src/server.rs Normal file
View file

@ -0,0 +1,206 @@
use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
use hive_sh4re::{HostRequest, HostResponse};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use crate::actions;
use crate::coordinator::Coordinator;
use crate::lifecycle;
pub async fn serve(socket: &Path, coord: Arc<Coordinator>) -> Result<()> {
if let Some(parent) = socket.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create socket parent {}", parent.display()))?;
}
if socket.exists() {
std::fs::remove_file(socket).context("remove stale socket")?;
}
let listener = UnixListener::bind(socket)
.with_context(|| format!("bind admin socket {}", socket.display()))?;
tracing::info!(socket = %socket.display(), hyperhive_flake = %coord.hyperhive_flake, "hive-c0re admin listening");
loop {
let (stream, _) = listener.accept().await.context("accept connection")?;
let coord = coord.clone();
tokio::spawn(async move {
if let Err(e) = handle(stream, coord).await {
tracing::warn!(error = ?e, "connection failed");
}
});
}
}
async fn handle(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
let (read, mut write) = stream.into_split();
let mut reader = BufReader::new(read);
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line).await?;
if n == 0 {
return Ok(());
}
let resp = match serde_json::from_str::<HostRequest>(line.trim()) {
Ok(req) => dispatch(&req, coord.clone()).await,
Err(e) => HostResponse::error(format!("parse error: {e}")),
};
let mut payload = serde_json::to_string(&resp)?;
payload.push('\n');
write.write_all(payload.as_bytes()).await?;
write.flush().await?;
}
}
#[allow(clippy::too_many_lines)]
async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
let result: anyhow::Result<HostResponse> = async {
Ok(match req {
HostRequest::Spawn { name } => {
tracing::info!(%name, "spawn");
let agent_dir = coord.ensure_runtime(name)?;
let proposed_dir = Coordinator::agent_proposed_dir(name);
let applied_dir = Coordinator::agent_applied_dir(name);
let claude_dir = Coordinator::agent_claude_dir(name);
let notes_dir = Coordinator::agent_notes_dir(name);
match lifecycle::spawn(
name,
&coord.hyperhive_flake,
&agent_dir,
&proposed_dir,
&applied_dir,
&claude_dir,
&notes_dir,
coord.dashboard_port,
&coord.operator_pronouns,
&coord.context_window_tokens,
)
.await
{
Ok(()) => {
coord.notify_manager(&hive_sh4re::HelperEvent::Spawned {
agent: name.clone(),
ok: true,
note: None,
sha: None,
});
}
Err(e) => {
// Roll back socket registration if container creation failed.
coord.unregister_agent(name);
coord.notify_manager(&hive_sh4re::HelperEvent::Spawned {
agent: name.clone(),
ok: false,
note: Some(format!("{e:#}")),
sha: None,
});
return Err(e);
}
}
HostResponse::success()
}
HostRequest::RequestSpawn { name } => {
tracing::info!(%name, "request_spawn");
let id =
coord
.approvals
.submit_kind(name, hive_sh4re::ApprovalKind::Spawn, "", None)?;
tracing::info!(%id, %name, "spawn approval queued");
HostResponse::success()
}
HostRequest::Kill { name } => {
tracing::info!(%name, "kill");
lifecycle::kill(name).await?;
coord.unregister_agent(name);
coord.notify_manager(&hive_sh4re::HelperEvent::Killed {
agent: name.clone(),
});
HostResponse::success()
}
HostRequest::Destroy { name, purge } => {
actions::destroy(&coord, name, *purge).await?;
HostResponse::success()
}
HostRequest::Rebuild { name } => {
tracing::info!(%name, "rebuild");
let agent_dir = coord.ensure_runtime(name)?;
let applied_dir = Coordinator::agent_applied_dir(name);
let claude_dir = Coordinator::agent_claude_dir(name);
let notes_dir = Coordinator::agent_notes_dir(name);
let result = lifecycle::rebuild(
name,
&coord.hyperhive_flake,
&agent_dir,
&applied_dir,
&claude_dir,
&notes_dir,
coord.dashboard_port,
&coord.operator_pronouns,
&coord.context_window_tokens,
)
.await;
// Mirror auto_update::rebuild_agent — the manager wants
// to know about every rebuild attempt regardless of
// which surface triggered it, especially failures
// (build error → manager can adjust the agent's
// agent.nix). Without this the admin-socket CLI was
// a notify-gap.
match &result {
Ok(()) => {
coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {
agent: name.clone(),
ok: true,
note: None,
sha: None,
tag: None,
});
// Wake the agent's next turn with the
// "you were rebuilt" hint. Same pattern as
// auto_update::rebuild_agent and the dashboard
// rebuild path — this is the CLI's equivalent.
coord.kick_agent(name, "container rebuilt");
}
Err(e) => coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {
agent: name.clone(),
ok: false,
note: Some(format!("{e:#}")),
sha: None,
tag: None,
}),
}
result?;
HostResponse::success()
}
HostRequest::List => HostResponse::list(lifecycle::list().await?),
HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?),
HostRequest::Approve { id } => {
actions::approve(coord.clone(), *id).await?;
HostResponse::success()
}
HostRequest::Deny { id } => {
actions::deny(&coord, *id, None).await?;
HostResponse::success()
}
HostRequest::SetParent { child, new_parent } => {
tracing::info!(%child, ?new_parent, "set_parent");
crate::topology::set_parent(child, new_parent.as_deref())
.map_err(anyhow::Error::msg)?;
// ContainerView.parent is read from topology.json — a
// change here means every container row potentially
// moves in the dashboard tree. Rescan + diff-emit so
// open viewers repaint without polling.
coord.rescan_containers_and_emit().await;
HostResponse::success()
}
})
}
.await;
match result {
Ok(r) => r,
Err(e) => HostResponse::error(format!("{e:#}")),
}
}

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