Watch
0
0
Fork
You've already forked hyperhive
0

docs: move the agent tool docs to 4-implementation/agent-tools

This commit is contained in:
atlas 2026-11-06 23:50:28 +01:00 • committed by mara
commit aa227a95c1
9 changed files with 94 additions and 83 deletions

View file

@ -398,4 +398,4 @@ delivers every unread notification it's handed. A hive-forge CLI
subscription verb bounds the firehose explicitly (dropping broad repo
watches an agent doesn't need) — the poller never guesses which
watches to drop. See the `subscription` verb in
[`docs/2-handbook/tools/forge.md`](../tools/forge.md).
[`docs/4-implementation/agent-tools/forge.md`](../../4-implementation/agent-tools/forge.md).

View file

@ -1,11 +1,8 @@
# Tools
`hivectl` is _your_ tool — the operator's own host CLI. Everything
else here documents the tool surface your **agents** get inside their
containers (the MCP tools an agent's own claude session can call).
You never call these directly, but they're the reference for what an
agent can actually do — useful when you're trying to understand or
debug agent behavior.
`hivectl` and `swarmctl` are _your_ tools — the operator's own CLIs. The
tool surface your agents get inside their containers lives in
[`4-implementation/agent-tools/`](../../4-implementation/agent-tools/README.md).
## For the operator
@ -25,27 +22,3 @@ debug agent behavior.
(authelia's subject store, edited in place) and `agent create`
(queues the swarm-controller's creation job graph). No curated guide
yet; add one here if/when that grows.
- **[swarm-logs-cli](swarm-logs-cli.md)** — the exhaustive, autogenerated
flag-by-flag reference for `swarm-logs`, kept in lockstep with the
binary by CI. No curated guide.
- **[swarm-metrics-cli](swarm-metrics-cli.md)** — the exhaustive,
autogenerated flag-by-flag reference for `swarm-metrics`, kept in
lockstep with the binary by CI. No curated guide.
## What your agents can do
- **[bash](bash.md)** — background shell execution (`mcp__bash__*`),
available on every agent unconditionally.
- **[subagent](subagent.md)** — spawn nested headless claude sessions
(`mcp__subagent__{start,continue,status,interrupt}`), shipped
default-on for every agent today alongside `bash` (expected to become a
real opt-in capability later).
- **[forge](forge.md)** — the `hive-forge` Forgejo CLI every agent has
for issues, PRs, and comments. Not an MCP tool — a binary agents
shell out to instead of ad-hoc curl.
- **[forge-cli](forge-cli.md)** — the exhaustive, autogenerated
flag-by-flag reference for `hive-forge`, kept in lockstep with the
binary by CI the same way `hivectl-cli.md` is.
- **[matrix](matrix.md)** — the matrix MCP tool surface
(`mcp__matrix__*`) for agents with a matrix account, multiple
accounts per agent, and declaring extra MCP servers generally.

View file

@ -1,91 +0,0 @@
# Bash execution tools
For the operator: this page covers the daemon as deployed and what the tools do
around the edges. How an agent calls them is in the tools' own MCP descriptions.
Background shell execution via `hive-bash-daemon`. Tools land as
`mcp__bash__<tool>` (the MCP server name is `bash`, not `hyperhive`).
`nix/agent-modules/mcp.nix` registers bash into `services.hyperhive.agent.extraMcpServers`
by default (`lib.mkDefault`, with `allowedTools = ["*"]`) — an `agent.nix`
can override or drop the entry, so this is a default, not an
unconditional grant. Even where the entry is present, the server is only
actually rendered into an agent's claude config when the agent also
holds the **`execution`** permission: `extra_server_required_permission` in
`hive-agent/src/mcp_config.rs` suppresses it entirely without that
permission, so an agent lacking `execution` gets no `mcp__bash__*` tools at
all, not just an unused grant. The harness rewrites the config each turn,
so a change applies at the next one. See "Relationship to the
`execution` permission" below.
## Tools
Each tool's parameters and return values are in its own MCP description, which
is what an agent reads at call time. This section says what the tools do around
the edges.
- **`run`** submits a shell command (through `bash`) as a background task. Its
stdout and stderr stream to `harness/bash-tasks/<id>.{out,err}`. When the
task finishes, times out or its process errors, it surfaces as a todo in the
agent's loose-ends, with the exit code and a pointer to the captured output.
`run` waits up to 3 seconds (30 at most) for a result before it returns; a
result delivered that way makes no todo. A caller-chosen `name` replaces the
generated id. The daemon accepts the name again once its task has finished,
and rejects it while the task is still pending or running.
- **`status`** polls a task: its state (`pending`, `running`, `done`,
`timed_out`, `interrupted` or `killed`), the exit code, the run time and the last
4 KiB of each stream. A call that sees a finished task clears its completion
todo, since the agent already has the result; in a narrow race both can still
appear. A task marked `interrupted` had its process killed by a harness
restart, and a best-effort todo is still made so the agent isn't blocked
silently.
- **`kill`** stops a running or pending task and returns without waiting. The
completion surfaces as a todo like any other. It signals the task's whole
process group, so children the shell started stop too: SIGINT by default,
SIGKILL with `force`. The daemon cancels a task still pending before it
starts, and the task ends as `killed`.
## Namespace note
`run` and `status` live in the `bash` MCP server, not `hyperhive`. The
tool names in claude are `mcp__bash__run` and `mcp__bash__status`.
hyperhive blocks the `Bash` built-in tool — all shell execution goes through
this structured path so tasks get task-id tracking and structured output.
## Architecture
`hive-bash-daemon` is a single long-running process (one per agent
container, systemd service in `nix/agent-modules/mcp.nix`) — no stdio
bridge. It owns subprocess management, output file writing, todo delivery
on the harness's in-agent socket, **and** serves the `run`/`status`/`kill`
MCP tools directly over streamable-http on `services.hyperhive.agent.mcp.bashHttpPort`
(declared in `services.hyperhive.agent.extraMcpServers.bash` as `{ type = "http"; url =
...; }`). Same shape as the built-in `hyperhive` surface (`hive-mcp-http`)
— claude reconnects to the stable URL every turn instead of respawning a
stdio child, so there's no per-turn MCP re-registration race and no
round-trip socket hop for tool calls.
### Completion as a todo (loose-ends v2)
When a bash task changes state, `hive-bash-daemon` upserts a single keyed
todo (`key = task id`) on the harness's in-agent socket (`HIVE_AGENT_SOCKET`)
— "running" at start, then the completion summary when it finishes. The
summary change signals the harness turn loop directly (in-process, no broker
round-trip), so the harness drives a turn for the agent to handle it via
`get_loose_ends`, then clears the todo with `cancel_loose_end(kind: "todo", id: N)` (dials the
in-container socket directly — no bash task involved, so clearing doesn't
spawn another todo). Same mechanism the matrix daemon uses for
unread rooms. An inline `wait_seconds` / `status` observation that already
delivered the result instead clears the keyed todo, so no redundant
loose-end follows.
## Relationship to the `execution` permission
`Permission::Execution` (in `Permission::DEFAULT`) is the real gate on bash:
`extra_server_required_permission` (in `hive-agent/src/mcp_config.rs`)
controls whether the `bash` MCP server reaches the agent's claude
config at all, suppressing every `mcp__bash__*` tool
when absent — not just leaving them unused. Its own `tools()` returns
`&[]`: `mcp__bash__run`/`status`/`kill` live on the separate `bash` MCP
server that `extra_server_required_permission` gates, not on the
`mcp__hyperhive__*` allowlist `tools()` builds. Removing `execution`
from an agent's permissions **does** remove bash availability.

File diff suppressed because it is too large Load diff

View file

@ -1,376 +0,0 @@
# hive-forge CLI
`hive-forge` is the Forgejo API wrapper available in every agent
container (installed via `nix/agent-modules/forge.nix`, on `PATH` as a
proper Rust binary). Use it instead of ad-hoc curl pipelines.
This page is the curated guide. For the exhaustive flag-by-flag reference
autogenerated from the binary's own command tree, see
[`forge-cli.md`](forge-cli.md) — emitted by the hidden `hive-forge
markdown-docs` subcommand and kept in lockstep with the code by the
`hive-forge-docs` flake check (CI fails if the committed copy drifts).
Regenerate with `nix build .#hive-forge && ./result/bin/hive-forge
markdown-docs > docs/2-handbook/tools/forge-cli.md`.
## Credentials and repo defaults
- Credentials: `$HIVE_FORGE_TOKEN_FILE` (the token the agent fetched from
the swarm secret store), else `$HYPERHIVE_STATE_DIR/forge-token`
- Active repo resolves, highest priority first: global `-r/--repo` flag
> the `origin` remote of the cwd's git checkout > `$HIVE_FORGE_REPO`
(last-resort override, unset by default) > a hard error.
`client::Client::from_env` assumes no single repo by default.
- Deadline: one invocation runs for at most `$HIVE_FORGE_DEADLINE` seconds
(default 900, five minutes above the largest per-request budget). Past
it, `hive-forge` prints `hive-forge: FAILED: no result after <n>s …` and
exits with code 75, so a forge that accepts a connection and then stalls
can't block the caller forever. The command stops wherever it was: a write verb
may already have reached the forge, so check its result before retrying.
## Verbs
**Kind-namespaced commands (preferred):** hive-forge groups issue/PR operations
under `issue` and `pr` parent commands — `hive-forge pr close 42`,
`hive-forge issue create --title …`, `hive-forge pr status 42`. The
`pr <verb>` / `issue <verb>` forms validate the number's kind (for example, `pr close`
refuses an issue number). Run
`hive-forge pr --help` / `hive-forge issue --help` for the full subcommand
list (show/create/edit/status/merge/reviews/commits/diff/view/comment/
comments/close/reopen/labels/assign/dependency/reaction/timeline as applicable).
```bash
hive-forge pr close 42 # close a PR (kind-validated)
hive-forge pr reopen 42 # reopen a closed PR (kind-validated)
hive-forge issue close 42 # close an issue (kind-validated)
hive-forge issue reopen 42 # reopen a closed issue (kind-validated)
hive-forge pr status 42 # PR health (mergeable / CI / reviews)
hive-forge issue create --title "..." --body "..."
# --- flat aliases below remain valid (hidden) ---
hive-forge view 42 # title + body + comments (marks thread read)
hive-forge comments 42 # list all comments (human-readable; marks thread read); for PRs, review bodies merged in
hive-forge comments 42 --tail 10 # last 10 comments (fetches the whole thread, shows the tail)
hive-forge --json comments 42 # same as above, JSON array (global flag)
hive-forge comment 42 --body "..." # post comment (inline body)
hive-forge comment 42 --body-file - <<EOF # ...or pipe a HEREDOC
multi-line body
EOF
hive-forge comment 42 --body "..." --force # post even if thread has unread activity
hive-forge comment-show 18042 # fetch one comment by id
hive-forge comment-edit 18042 --body "..." # edit a comment
hive-forge assign 42 damocles
hive-forge close 42
hive-forge labels 42 add feature
hive-forge issue dependency 42 # list issue 42's dependencies (issues it's blocked by)
hive-forge issue dependency 42 add 40 41 # issue 42 is now blocked by 40 and 41
hive-forge issue dependency 42 remove 40 # drop the dependency on 40
hive-forge pr dependency 55 add 42 # same verb, PR-scoped (Forgejo shares the issue/PR index)
hive-forge issue reaction 42 # list issue 42's reactions
hive-forge issue reaction 42 add heart # react to issue 42 with :heart:
hive-forge issue reaction 42 --comment 18042 add +1 # react to a specific comment instead
hive-forge issue reaction 42 remove heart # remove your own :heart: reaction
hive-forge issue reaction 1 --list-allowed # this instance's actual configured shortcodes
hive-forge issue create --title "..." --body "..."
hive-forge issue create --title "..." --body "..." --label area/ops --label type/bug # repeatable
hive-forge issue edit 42 --title "new title"
hive-forge pr edit 42 --title "new title" # same edit, PR-kind-validated
hive-forge pr edit 42 --base main # retarget a stacked PR onto main
hive-forge pr 42 # PR metadata as JSON
hive-forge pr create --title "..." --head my-branch --push # also `git push forge my-branch`
hive-forge pr create --title "..." --head my-branch --label area/ops # repeatable, same as issue create
hive-forge pr reviews 42 # list reviews; each inline comment as `[#id path:line] text`, tagged (by X) when its poster is not the reviewer / (resolved by X) / (no position)
hive-forge pr reviews 42 --only unresolved # every inline comment nobody has resolved yet
hive-forge pr timeline 42 # review rows list their line comments (id, path:line, state) too
hive-forge pr reviews 42 --approve # submit APPROVED review
hive-forge pr reviews 42 --request-changes -m "msg" # submit REQUEST_CHANGES review
hive-forge pr reviews 42 --comment -m "msg" # submit COMMENT review
hive-forge pr assign-reviewer 42 argus # set argus as a requested reviewer on the PR
hive-forge pr assign-reviewer 42 argus --remove # withdraw the request; fails if argus is still listed afterwards (a reviewer who already reviewed stays listed)
hive-forge pr assign-committer 42 damocles # add damocles to the PR's assignee list
hive-forge pr diff 42 # unified diff (lockfile hunks collapsed by default)
hive-forge pr diff 42 --full # include unfiltered lockfile hunks
hive-forge list # open issues/PRs
hive-forge list --kind pr --state all --page 2 # page 2 of all PRs (walk --page 1,2,… with --limit as page size for a repo-wide sweep)
hive-forge list --search "trust bundle" --state all # full-text over title AND body — the duplicate check
hive-forge list --milestone 11 --state all # what's left in a milestone (name or id, repeatable)
hive-forge milestone # list milestones
hive-forge branches deployed/ # filter branches by pattern
hive-forge tree-sha main # git tree SHA for a ref
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 # PRs with zero formally requested reviewers
hive-forge lint no-reviewer --reviewer argus # PRs where argus specifically isn't a requested reviewer
hive-forge lint stale-branches --days 14 # branches with no recent activity, each with its merge outcome (PR #n merged / closed unmerged / no PR)
hive-forge lint assignments # per-assignee open item count
hive-forge lint unlabeled --scope type # open issues/PRs with no exclusive type/* label (any scope works, e.g. --scope area)
hive-forge pr status 42 # PR health: mergeable, CI, reviews, last comment (exit 0 = ready)
hive-forge pr status --sha <sha> # CI-only fast path for an explicit commit sha
hive-forge pr merge 42 # merge (refuses unless mergeable + CI not red + no changes-requested); deletes head branch
hive-forge pr merge 42 --method rebase # rebase-merge instead of a merge commit (no squash option)
hive-forge pr merge 42 --keep-branch --force # keep the head branch; override the readiness gate
hive-forge timeline 42 # audit trail: closes, label changes, assignments, commit refs
hive-forge attach-issue 42 /path/to/file # upload a file attachment to an issue; prints download URL
hive-forge attach-comment 18042 /path/to/file # upload a file attachment to a comment; prints download URL
hive-forge attachment-get <uuid> # download an attachment; prints resolved path to stdout
hive-forge artifact-get pr1ma-paper-pdf --run 51 # download a CI run's Actions artifact zip (run number from the run-page URL)
hive-forge ci-log --run 51 # print a CI run's job step logs (run number from the run-page URL); --job i / --step i to narrow
hive-forge ci-rerun --branch foo # re-run CI without an empty commit (dispatches a fresh run; --run n also works unless that run was PR-triggered; --pr refuses, see below)
hive-forge subscription --watch # subscribe to repo notifications
hive-forge subscription --unwatch # unsubscribe
hive-forge subscription --list # list every repo you watch (audit the notification firehose)
hive-forge -r internal/knowledge clone # clone with creds auto-injected
hive-forge -r internal/knowledge pr create --agit --topic foo --title "..." # open PR via AGit (no fork)
# --- repo management ---
hive-forge repo-create my-repo # create under authenticated user; prints URL
hive-forge repo-create my-repo --org myorg --private --auto-init # under an org, private, with initial commit
hive-forge repo-add-collaborator alice # add write collaborator (default permission)
hive-forge repo-add-collaborator alice --permission read # read-only collaborator
hive-forge repo-labels # list every label defined on the repo
hive-forge repo-labels bug # filter by name substring
hive-forge repo-labels --json # full label objects (id, name, color, description)
```
### Which read verb when
Multiple verbs read an issue/PR and look overlapping, but each has a
distinct output shape — pick by what you need:
| Need | Verb | Shape |
| ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The full human view of one item — title, body, **and** every comment in order | `view <n>` | prose dump; **marks the thread read** (clears the read-before-comment guard). Top-level `view` autodetects issue-or-PR by number; scoped `issue view <n>` / `pr view <n>` do the same when you already know the kind |
| Just the structured metadata of one item (state, labels, assignees, head sha…) | `issue <n>` / `pr <n>` | JSON object |
| Only the comments (skip the body), for example to catch up a long thread; for PRs, review bodies (approve/request-changes/comment summaries) come along too | `comments <n>` [`--tail N`] | comment + review-body list; **marks read** |
| One specific comment by its id | `comment-show <id>` | single comment |
| The audit trail — closes, label/assignee changes, commit refs, merges (no comment bodies) | `timeline <n>` | event list |
| The code change | `pr diff <n>` | unified diff |
| Is this PR ready to merge — mergeable + CI + reviews | `pr status <n>` | health view; exit code = merge-readiness |
| The review verdicts + inline review comments | `pr reviews <n>` | review list |
Rule of thumb: **`view` first** when you're about to act on a thread (it
shows everything and clears the unread guard so your follow-up `comment`
isn't rejected); the JSON verbs (`issue`/`pr`) when a script needs one
field; `pr status` when the only question is whether it can merge.
### Contributing to a read-only repo (`clone` + `pr create --agit`)
<!-- vale write-good.Passive = NO -->
Agents are read-only collaborators on some repos (e.g.
`internal/knowledge`) and so can't push branches. Forgejo's AGit flow
lets a read-only user open a PR by pushing the current `HEAD` to the
magic ref `refs/for/<base>/<topic>`. Two verbs cover the workflow:
<!-- vale write-good.Passive = YES -->
```
hive-forge -r internal/knowledge clone # clone, auth handled for you
cd knowledge
# add / edit / delete any files, then commit normally
git add -A && git commit -m "add foo runbook"
hive-forge -r internal/knowledge pr create --agit \
--topic foo-runbook \ # groups pushes into ONE PR; reuse to update it
--title "add foo runbook" \
[--body "details"] # PR description (also accepts --body-file)
```
<!-- vale write-good.Passive = NO -->
`clone` derives the dest dir from the repo basename (override with a
positional arg); it passes `--branch` / `--depth` through. The clone
URL and the `origin` remote it leaves behind carry no credentials —
`clone` instead configures `origin`'s `credential.helper` to invoke
`hive-forge credential-helper` (a hidden verb, not meant to be run by
hand), which git calls fresh on every fetch/push. That's what lets
`pr create --agit` (default remote `origin`) push without re-auth,
without a durable token sitting in the checkout's `.git/config`.
<!-- vale write-good.Passive = YES -->
`pr create --agit` prints the PR URL. Re-running with the same
`--topic` force-updates the existing open PR (the AGit ref is
agent-owned scratch). Opens a reviewable PR the operator merges — never
commits straight to `main`.
`hive-forge <verb> --help` prints the full signature for any verb.
### `pr status`
One-stop PR health view (`<n>`, positional — `--pr <n>` also still works):
mergeable state, CI checks, requested reviewers + review verdicts, and the
last-comment timestamp — the things you need to know whether a PR is ready
to merge (CI must pass before merge). `--sha <sha>` is a CI-only fast path
for a raw commit. The `reviewers` line lists pending requests only: a
reviewer whose verdict still stands drops off the line even though the forge
keeps them in its requested list, while one whose review is stale or dismissed
stays.
A failing/erroring CI context prints its job link. The process exit code
is a **merge-readiness** verdict for a PR number (0 only when CI is green
AND the PR is mergeable AND no review requests changes), so it composes:
`hive-forge pr status 42 && echo ready`. `--sha` mirrors the CI
verdict alone.
```
hive-forge pr status 42
# PR #42: feat(...): ... # lint:allow: sample CLI output, not a real tag
# state: open (mergeable: yes)
# CI: e39a87ea3949: ✅ success (1 context(s))
# ✅ CI / nix flake check (pull_request): success — Successful in 1m50s
# reviewers: (none requested)
# reviews: ✅ argus: APPROVED
# last comment: 2026-06-05T19:13:28+02:00 by argus
```
Note: review verdicts come from _formal_ Forgejo reviews (the
approve / request-changes API). Reviewers who post their verdict as a
plain comment show under `last comment`, not `reviews`.
### Repo management
Agents **can't create repos directly via forge token** — Forgejo
disables push-to-create and the agent token doesn't have the Create
scope.
The CLI verbs below (`repo-create` / `repo-add-collaborator`) use the
agent's own forge token, so a repo lands under the agent's user account
or an org the agent belongs to.
**`repo-create <name>`** — create a repo under the authenticated user
and print its URL. Key flags:
- `--org <ORG>` — create under an org namespace instead
- `--private` — private repo (default: public)
- `--auto-init` — seed an initial commit so the repo is non-empty and
immediately cloneable; omit for a bare repo you push into
- `--description <TEXT>` — repo description
**`repo-add-collaborator <user>`** — grant a forge user access to the
active repo (see the repo-resolution chain above). Companion to
`repo-create`. The
`--permission` flag accepts `read` / `write` (default) / `admin`.
`hive-c0re` uses this internally when it initialises an agent's config
repo.
**`repo-labels [PATTERN]`** — list every label defined on the repo,
optionally filtered by a name substring (case-sensitive). Distinct from
`labels <n>` which shows the labels on one specific issue/PR. Use this
to discover valid label names before triaging or to audit the label set.
`--json` emits full label objects (id, name, color, description).
## Notes
<!-- vale write-good.Passive = NO -->
- **Read-before-comment guard:** `comment` refuses to post when forge still
has an unread notification for the thread (that is, someone commented since
you last read it). This prevents accidentally replying to old activity without
seeing the new context. Read the thread with `hive-forge view <n>` or
`hive-forge comments <n>` (which mark the notification read and clear the
block), or pass `--force` to skip the guard.
- `comment --body "..."` with backticks in the body: always use
`--body-file -` with a HEREDOC to avoid shell escaping issues.
- `pr create --push` also runs `git push forge <head>` and suppresses
the post-push "Create a pull request" hint (hive-forge prints the
canonical URL itself).
- `attachment-get` saves to `/tmp/forge-attachment-{uuid}` by default
and prints the resolved path. Pass `-o -` to stream to stdout.
- `artifact-get <name> --run <n>` downloads a CI Actions artifact. `<n>`
is the run number from the run-page URL (`/actions/runs/<n>`, which
`pr status` surfaces as a CI context's target_url). Forgejo 15 serves
artifacts only via the web route, not REST, and keys that route by
the run's internal global id, so the verb translates the run number
first. Saves a zip to `/tmp/forge-artifact-<name>.zip` by default; pass
`-o -` to stream to stdout.
- `ci-log --run <n> [--job i] [--step i] [--attempt n]` prints a CI
run's job step logs. `<n>` is the run number from the run-page URL
(same value `artifact-get` takes; `pr status` surfaces it as a CI
context's target_url). Two log sources, tried in **completeness
order**: the verb tries the **durable persisted-log download** the run page's "view
raw logs" link uses (`…/runs/<n>/jobs/<job>/attempt/<a>/logs`, a flat
whole-job log) first — complete once it exists, which covers
any run that has already finished; it's only absent while the job is
still running, in which case the verb falls back to the web run-view
**streamer** the run page polls (rich per-step framing, but only a
snapshot of the live `act_runner` task record, so a still-buffering
multi-minute phase can come back thin). Passing `--step` reverses
that order — only the streamer honors per-step framing (the persisted
log is flat), so `--step` goes straight to the streamer and an
out-of-range index surfaces as a hard error instead of silently
falling back. `--job` selects the job (0-based, default 0);
`--attempt` picks the run attempt for the durable path (default 1;
re-runs increment it). `--json` wraps the output.
- `ci-rerun` re-runs CI: it dispatches a
**fresh** run of the workflow via the workflow-dispatch API
(`POST …/actions/workflows/<workflow>/dispatches {"ref":"<branch>"}`).
Resolve the branch with `--run <n>` (the same run number `ci-log` /
`artifact-get` take — resolves the branch + workflow from that run) or
`--branch <name>` (directly). `--pr <n>`, and `--run <n>` against a run
that was itself PR-triggered, both refuse instead of dispatching:
a workflow-dispatch run writes no commit status, so it can't clear a
red `(pull_request)` check on that PR's sha — re-run from the web UI
instead. `--workflow <file>` picks the workflow file for `--branch`
(default `ci.yml`). Dispatch re-runs the whole workflow — there is no
single-job variant.
- `issue dependency <n> add <dep...>` / `pr dependency <n> add <dep...>`
set "blocked by" links via Forgejo's dependency feature — the
operator's preferred way to track blocking relationships over labels
(labels can go stale; dependencies are structured and show in the
forge UI's dependency panel). Same-repo only, matching the web UI (no
cross-repo dependency support). `list` (the default action) and `add`/
`remove` all print the resulting dependency list as JSON.
- `issue reaction <n> add <content>` / `pr reaction <n> add <content>` add
an emoji reaction (Forgejo's shortcode vocabulary — `+1`, `heart`,
`rocket`, …, same set the web UI's picker offers, not a raw emoji
character); `--comment <id>` targets a specific comment's reactions
instead of the issue/PR itself. `list`/`add`/`remove` all print the
resulting reaction list as JSON, same shape as `dependency`. `view`,
`issue`, `pr`, and `comment-show` always show a reaction summary;
`comments --show-reactions` adds one per comment shown (opt-in — one
extra request per comment, no server-side inline count). `reaction
<n> --list-allowed` prints the instance's actual configured shortcode
list (`GET /settings/ui`'s `allowed_reactions`) instead of guessing —
instance-global, this path ignores `<n>`/`--comment`.
- **Don't** use raw `curl` for forge access -- the CLI handles auth,
error checking, and output formatting.
- `issue create --label <name>` / `pr create --label <name>` are
repeatable and take the same spelling `labels <n> add` does. **An
unknown name is an error, not a silent drop** — the command fails
listing the names that didn't resolve plus every label the repo has,
so it's fixable from the message without a second call. On
`pr create --agit`, hive-forge applies labels as a follow-up call once
it parses the PR number back out of the push output (the AGit push
itself has no label field), so they're silently skipped if that
parse fails — same fallback as the deferred multi-line body.
- `list --label <name>` / `list --milestone <name>` are repeatable and
validated the same way, for a sharper reason: the forge **discards** a
filter value it doesn't recognise, so a typo returns the
**unfiltered** list rather than an empty one. That doesn't waste a
query, it inverts the answer — "is anything open in this milestone"
comes back as every open issue. `--milestone` takes a title or an id,
and closed milestones count as valid (filtering on a shipped one is a
normal query).
- ⚠️ `--assignee` / `--author` are deliberately **not** validated: someone
who has left still legitimately appears on old issues, so a login that
isn't a current member isn't necessarily a typo.
- `list --limit N` is a *request*: the forge clamps page size to its own
`api.MAX_RESPONSE_ITEMS` (50 by default), so `--limit 400` returns at
most 50 rows. The stderr trailer (printed in `--json` mode too)
is driven by the forge's own `Link: rel="next"` / `X-HasMore`
signal, so it stays right when the forge clamps `--limit`: while a next
page exists it prints the rows shown so far (out of the response's
`X-Total-Count` when sent, `… 50 of 187 shown … 137 more`) and a
`re-run with --page N+1` hint. The last page prints no trailer, so
silence means the list is complete. Two cases stay ambiguous: without
`X-Total-Count` the headers didn't come through, and it falls back to
`N shown … more results may exist` on a page of exactly `--limit` rows
(silent on a clamped one); a proxy that strips only `Link` while
keeping `X-Total-Count` reads as a last page.
- `repo-search` reads `X-Total-Count` the same way `list` does and warns
with the real remaining count when it's present (`… 50 of 187 shown …
137 more exist`); without it (an older forge, a stripping proxy) it
falls back to the cruder `count == limit` heuristic, same
trust-the-trailer-not-the-row-count principle as `list` above. Unlike
`list`, this verb has no `--page` — so once the forge's own page cap
has clamped the result (`count < limit`), raising `--limit` again asks
for the same clamp and gets nothing new; the warning says to narrow
`--query` instead in that case, and only suggests raising `--limit`
while still under the cap. Either way it can't tell "doesn't exist"
from "you lack permission to see it" — both render as an absent row
with no signal either way — and it matches repo **name** only, not
owner, so there's no way to enumerate an org's repos through this
verb.
<!-- vale write-good.Passive = YES -->

View file

@ -1,175 +0,0 @@
# Matrix MCP tools and extra MCP servers
For the operator: this page covers how the harness adds the matrix tools and what
they do around the edges. How an agent calls them is in the tools' own MCP
descriptions.
## Built-in matrix MCP (`mcp__matrix__*`)
When the agent has at least one `services.hyperhive.agent.matrixAccounts`
entry, the harness autoinjects `hive-matrix-daemon`'s
streamable-http endpoint as a second MCP server (no stdio bridge —
see Architecture below). No `matrix.enable` switch exists: the account
set *is* the switch, and the module declares the hive-internal `main`
account for you as soon as `services.hyperhive.agent.matrix.url` is
non-null — which hive-c0re fills in per agent whenever the operator
configures the host-level matrix tuwunel. Tools land as `mcp__matrix__<name>`.
Each tool's parameters and return values are in its own MCP description, which
is what an agent reads at call time. They fall into four groups:
- **Messaging:** `send_message`, `send_dm`, `send_reply`, `send_reaction`,
`send_file`, `send_redact` and `open_dm`. A room is `!id:server` or
`#alias:server`, and the daemon resolves either. `open_dm` finds or creates the
DM room and returns its id without sending anything, for a case where
`send_dm`'s body is inconvenient, such as a file attachment. `send_file` caps
an upload at 50 MiB. Redacting someone else's event needs moderator power.
- **Reading:** `read_room`, `download_file`, `list_rooms` and
`list_room_members`. `read_room` shows media as `[file: …]`, `[image: …]`,
`[audio: …]` or `[video: …]` markers, and the event id of one goes to
`download_file`. An edited message shows once, with its latest text and
`edited: true`. The edit event doesn't appear as a second message and doesn't
count as unread. When the original is outside the window read, the edit stays
as its own event, and its `replaces_event_id` names the message it replaces.
- **Room membership:** `invite_user`, `list_invites`, `resolve_invite` and
`join_room`. `resolve_invite` accepts or rejects a pending invite and is the
path for invites; `join_room` is for a public room the agent wasn't invited to.
`list_invites` gives the inviter and the topic, so an agent can judge an
invite before it answers.
- **Receipts:** `mark_read` advances the read receipt.
**DM recipient check.** `send_dm` and `open_dm` report where the other user
stands in the room through a `recipient` field. `joined` means they see the
message. `invited` means the invite is pending, and the result carries a
`warning`. When the user is in neither state, because the invite failed
or they left, the call fails and sends nothing.
**Unread guard.** `send_message`, `send_dm`, `send_file` and `send_reply` fail
while the room has unread messages. The agent calls `read_room` and then
`mark_read` on the latest event before sending to a room it hasn't read.
## Multiple accounts
<!-- vale write-good.Passive = NO -->
`services.hyperhive.agent.matrixAccounts` (declared in `agent.nix`) is
the agent's full set of matrix identities — for example an
external-facing account alongside the internal one — **and, being
non-empty, is what turns matrix on for the agent at all.** Each entry is
keyed by account name and specifies `tokenFile` (bearer token,
provisioned out-of-band; basename must start with `matrix-token`),
`sessionDir` (per-account matrix-sdk sqlite state — crypto keys +
cache), and an optional `homeserver` (defaults to
`services.hyperhive.agent.matrix.url`). The hive-internal account is
always named `main` and is always the primary; the matrix module
declares it for you as an ordinary entry of this map, from
`services.hyperhive.agent.matrix.url` + agent state, so what you add
here are the *further* accounts. Its token comes from the swarm secret
store (`swarm-controller` mints it); its `tokenFile` stays pinned to
`<state>/matrix-token` as the fallback, and the dashboard's link-account
route refuses to create an account by that name.
<!-- vale write-good.Passive = YES -->
`main` is present exactly when that URL is non-null, which is the whole
mechanism behind "accounts are the enable signal": an agent with no
homeserver and no account of its own has an empty set, so it gets no
daemon, no path watcher and no injected MCP entry. That's how you give
an agent no matrix tools. Declaring an external account with
its own `homeserver` is enough on its own; a hive homeserver isn't
required.
An account linked from the swarm UI needs no entry in this map.
`swarm-controller` stores it at `swarm/agents/<agent>/matrix/<account>`.
The daemon lists that directory under the agent's own certificate at
start and every two minutes, and restarts itself when the set of linked
accounts changes. A declared entry of the same name wins.
Every matrix tool above takes an optional `account` parameter (a name
from this map, or a linked account's) to act as that identity instead
of the primary one.
Declaring an extra account also changes what the agent *receives*: the
summaries of unread-room and invite todos gain an `[acct:<name>]` prefix — see
[Architecture](#architecture) below.
## Architecture
`hive-matrix-daemon` is a single long-running process (one per agent
container, systemd service in `nix/agent-modules/matrix.nix`) — no
stdio bridge, no separate bin. It owns the matrix-sdk `Client` + sync
loop per configured account **and** serves the matrix tool surface
directly over streamable-http on `services.hyperhive.agent.mcp.matrixHttpPort`
(declared in `services.hyperhive.agent.extraMcpServers.matrix` as
`{ type = "http"; url = ...; }`). Same shape as `hive-bash-daemon` and
the built-in `hyperhive` surface (`hive-mcp-http`) — claude reconnects
to the stable URL every turn instead of respawning a stdio child.
Silently exits when the primary account has no token yet; the
`systemd.paths.hive-matrix-daemon` watcher restarts it when a token file
appears, and, on an agent with a store, a timer restarts it while it's
down so it re-reads a token the swarm minted into the store.
**Wake path**: after every sync the daemon upserts a harness-local todo
on the in-agent socket for each unread room (keyed by its room id) and
each pending invite (below). A new or changed todo wakes the agent with
the generic prompt `you have todos — call get_loose_ends to see them`
(from `todo`). The `[matrix]` lines reach the agent through
`get_loose_ends`, one per room, as
`- todo #<id> [matrix <room_id>, <age>s old]: <summary> (cancel_loose_end …)`.
The summary depends on the room's unread state:
- **One unread message**: `[matrix] <sender> in <room>: <body> —
use read_room to view, mark_read to clear`
- **Multiple unread messages**: `[matrix] N unread in <room> —
use read_room to view, mark_read to clear`
**Multi-account prefix**: when the daemon serves more than one account
(`services.hyperhive.agent.matrixAccounts`), it prefixes the summary of every
unread-room todo above and every invite todo below with `[acct:<name>] `, so
`get_loose_ends` shows
`- todo #<id> [matrix <room_id>, …]: [acct:ccc] [matrix] <sender> in <room>: …`. A single-account agent gets the
formats exactly as written — the daemon adds nothing — which is why matching
on a leading `[matrix]` works right up until you declare a second account.
hive-matrix-daemon includes the same per-room breakdown in the `UnreadMatrix`
entry `get_loose_ends` returns, so unread rooms surface in the
loose-ends list between turns.
**Invite wakes**: the daemon sweeps `invited_rooms()` after every sync
callback (deliberately not a one-shot `m.room.member` event handler —
a one-shot signal that raced a socket-down window vanished with no
retry, leaving the agent deaf until manually prompted) and upserts a
todo (keyed `invite:<room>`) for each pending invite on the harness's
in-agent socket, which drives a turn. The daemon does **not**
autojoin — the agent calls `list_invites` to see pending invites and
`resolve_invite` to accept or reject them.
**Pending invites as loose ends**: the daemon upserts pending invites as
keyed todos, which appear in `get_loose_ends` output as
`[matrix] invited to <room> (<room_id>) — use list_invites to see
pending invites, resolve_invite to accept or reject`. The daemon clears
the keyed todo when a `resolve_invite` (or `join_room`) call resolves
the invite.
See [`docs/2-handbook/integrations/matrix.md`](../integrations/matrix.md) for the homeserver setup,
provisioning flow, and federation config.
## Extra MCP servers (per-agent)
Each agent's NixOS config can declare additional MCP servers via
`services.hyperhive.agent.extraMcpServers.<key> = { type, command, args, env, url,
allowedTools }` — `type = "stdio"` (the default, uses `command`/`args`/
`env`) or `type = "http"` (uses `url`, a long-lived streamable-http
endpoint — see `hive-bash-daemon` and `hive-matrix-daemon` above for
the `"http"` shape). 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 you pull in external MCP-server flakes
by adding them to `inputs.*` and reference them as
`flakeInputs.<name>.packages.${pkgs.system}.default` — the resolved
sha lands in the agent's own `flake.lock` and rolls up to `internal/meta`'s.
`allowedTools` defaults to `["*"]`, which expands to
`mcp__<key>__*` (every tool from that server autoapproved). Restrict
to specific tool names when you want finer control.

View file

@ -1,463 +0,0 @@
# Subagent daemon
`hive-subagent-daemon` (crate `hive-subagent-mcp`) spawns nested headless
`claude` sessions on request. Own process, own systemd unit, own MCP
server (`subagent`, not `hyperhive`) — independent of `hive-bash-daemon`:
a subagent is a full nested claude process, a materially heavier
capability than a background shell command, so it gets its own
deployable/restartable unit rather than living inside the bash daemon.
Shipped default-on for every agent — `nix/agent-modules/mcp.nix` injects
`subagent` into `services.hyperhive.agent.extraMcpServers` via `lib.mkDefault`
(`allowedTools = ["*"]`), same as `bash`. Default-on rather than
unconditional: an `agent.nix` can override or drop the entry, which is
what `mkDefault` is there for.
For what the tools do and when an agent should reach for them, see the
`subagent` MCP server's own tool descriptions and the
`base:claude-subagents` skill — this page covers the daemon as deployed
infrastructure, not the agent-facing API.
## Tools
Served under the `subagent` MCP server (`mcp__subagent__<tool>`): `start`,
`continue`, `status`, `interrupt`.
A second route on the same port serves the two tools a **subagent** calls
about its own run — `goal_reached` and `need_help`. It isn't part of the
`subagent` server an agent's own config points at; the daemon writes it
into each subagent's `--mcp-config` itself, under `subagent_control`. Two
routes rather than six tools on one, so that being able to report on a run
never carries the ability to start one: there's no route a subagent holds
that `start` is reachable from.
## A subagent can't name a session, not even its own
Neither signal tool takes a session name. **The URL is the identity.** At
each spawn the daemon mints that run an unguessable token, serves it at
`/signal/mcp/<token>`, and writes that one URL into that one subagent's own
`--mcp-config` — a file per session, not a shared one. The daemon resolves
a request to a session before dispatching it, and the tools read the
session off the resolution.
A subagent therefore has no field in which to name a sibling, and knowing
a sibling's name buys nothing: a name isn't a token. Two subagents running
concurrently can't signal each other — which the earlier shape, a shared
route plus a `name` argument guarded by a liveness check, allowed.
A token that resolves to nothing — never minted, or revoked when its run
ended — gets a bare **404**, the same answer for every token, so nothing
about the refusal says whether some other session exists.
## State
In-memory only: what's running now, where each name's session lives, how
each name's last turn ended, when each running turn last produced output,
what each session is working toward, how far through its turn budget it
is, why its run stopped, where it writes its report, and which signal
token belongs to it. All of it lives only as long as the daemon process
does — so a daemon restart invalidates every signal URL it had issued,
which is the same thing as it having stopped the runs those URLs belonged
to. A daemon
restart stops whatever was running rather than adopting it. The durable
record of a subagent's existence is claude's own on-disk session
(`hive_claude::SessionStore`), which `continue` reattaches to independent
of the daemon's own lifetime — a restart loses the _in-flight turn_, not
the subagent's history.
`continue`, and each later turn of a goal run, hands claude a session id,
not the name: the id of the live session carrying that name in `dir`'s
project directory, read off disk at every resume. claude looks a bare name
up across every git worktree of the repo and refuses when two sessions
match, and `start` archives a prior session only in its own directory, so
a name reused under a sibling worktree would otherwise stop resuming. When
`dir` holds no session under the name, the daemon refuses and never hands
claude the bare name — see below.
Because the remembered directory goes with the rest of it, a `continue`
after a restart has to re-supply `dir` when the session lives anywhere
other than the agent's work dir (the default when it exists) — and a restart is the
situation you reach for `continue` in most often.
## Roles
A `start` may name a **role**: `role: "reviewer"` runs that subagent under
the spawning agent's own `reviewer.md`. Role files live in one directory
per agent, `subagent_roles/` inside that agent's state directory, one
markdown file per role, and the `role` argument is the filename without
its extension.
One convention directory of named files, because a named set enumerates.
Anything that watches roles — a check for a safety clause that has gone
stale, an operator asking what this agent can dispatch — lists the
directory and has the answer. Per-agent rather than hive-wide: the clauses
a role carries come out of that one agent's own prompt files, and the
daemon only ever spawns on that agent's behalf.
**The task is the turn, never a standing system prompt — with or without a
role.** A task baked into the system prompt re-asserts itself as an
instruction on every later turn of the session (`continue`, `goal`), not
just the one the caller wrote it for — the system prompt is the
subagent's standing identity, not a one-shot channel, so the daemon always
reads the task instructions (`prompt_file`) and folds them ahead of the
turn's own prompt instead, whether or not the session has a role. The
caller's own task file stays untouched either way.
A role additionally names a standing identity: its text, alone, becomes a
per-session file under the harness directory, and `--append-system-prompt-file`
points at that — nothing about the task ever reaches it. Without a role
there is no such identity to hold a file open for, so the daemon omits
`--append-system-prompt-file` altogether rather than pointing it at
anything task-shaped.
⚠️ This applies to every dispatch, not just role-bearing ones.
### A role with no file refuses the call
A named role with no file **fails the `start`** and names the roles the
directory does hold. No fallback, no spawn, no reservation — the refusal
lands before the daemon claims the session name, so a caller that fixes
the name may retry it immediately.
The reason is the state every agent starts in. The directory starts
empty: hyperhive ships no role files, and nothing creates one until an
agent writes its own, so a name with no file behind it counts as the
ordinary first-run answer rather than a rare corruption. Falling back to a spawn
without the role would hand that subagent a prompt missing every clause
the role existed to carry, and would do it quietly. An empty role file
draws the same refusal, for the same reason.
## Goals, and turns toward them
`start` takes an optional `goal`. Without one a session is a single turn,
exactly as it always was. With one, the daemon keeps the session going:
when a turn ends and nothing has said to stop, it starts another turn
re-prompting the subagent toward that goal, quoting it verbatim and saying
which turn of the budget this is. `max_turns` caps that, defaulting to
**5**.
`status` reports `Turn N of M` for such a session in every state it
reaches. Read alongside the last-event age below, it's what separates a
subagent that's working from one that's wedged from one that's out of
turns — without `ps` and without opening a file.
Five things stop a run; the daemon records each distinctly, reports it via
`status`, and appends it to the one todo it pushes when the run ends:
- **the turn ended and there was no goal** — the single-turn case;
- **`goal_reached`**, which the subagent calls itself;
- **`need_help`**, likewise;
- **the turn cap**, which says so rather than stopping quietly: the todo
states that the run hit the harness limit and the goal was never
reported reached, so the work stopped where it had got to;
- **`interrupt`**, which cancels the whole run and not merely the turn
that was in flight.
A killed or failed turn ends the run too, and keeps the records it already
had — see [A killed turn](#a-killed-turn).
`interrupt` being on that list is load-bearing, not bookkeeping. Killing
the process isn't by itself a cancel: `interrupt` sends SIGINT by
default, and `claude --print` handles SIGINT by writing its terminal
result event and exiting **zero**, which is indistinguishable from a turn
that finished. `interrupt` therefore records the cancel before it
signals, and the continuation loop reads that record rather than guessing
from how the child exited. `continue` clears it and hands the session a
fresh turn allowance, which is what makes an interrupt a pause you can
undo rather than a session you have to abandon.
On `claude`, the signal is always SIGINT, and it goes to claude's whole
process group. The signal doesn't reach a command that moved itself out of
the group (`setsid`), and claude's Bash tool starts every command that way:
claude stops those commands itself when it shuts down on SIGINT. A SIGKILL
would leave them running, so nothing escalates to one. `interrupt` returns
at once, and the name stays claimed until claude has exited: `status` says
**stopping**, and the daemon refuses `start` and `continue` on that name
until then. A claude that ignores the SIGINT stays stopping until someone kills
it by hand or the unit stops.
When the session knows where its report goes — `start`'s `report_file`,
or the path the subagent names when it signals — the daemon appends the
stop reason to that file as well, so the artifact you were going to read
anyway also says how the run ended. The daemon infers nothing: with no
path given, it touches no file.
## `goal_reached` is a label, not a gate
Both signals stop the continuation and **extend** the done message. Extend,
not replace: the turn's observed end and the reason the run stopped are
different facts, and the second never stands in for the first.
`goal_reached` is **self-reported**, by a subagent that has just been
re-prompted with "you haven't reached the goal" — which is precisely the
incentive to claim it. It's the same failure class as a build report
asserting "done, tests pass": a claim about an artifact, not the artifact.
Nothing in this daemon treats it as verification, and every surface that
renders it says so. Read the diff and the gate output regardless.
`need_help` is the blocking signal. It stops the run and shows up in
`status` as its own state — blocked, with the subagent's reason — so a
parent that polls `status` sees the block without reading anything else.
`continue` is how you answer it.
## Telling working from wedged
`status` reporting **running** says the daemon tracks a process, which a
wedged subagent satisfies as fully as a busy one. A running answer therefore
carries the age of that turn's last event too: seconds means it's working,
an age climbing into the minutes means it's stuck. That one number
replaces inferring the same thing from `ps` output and CPU-time deltas.
It resets at each turn's spawn, so on a goal run it describes the turn in
flight rather than the run — which is what you want, since a run that's
making progress spends a few perfectly healthy minutes.
Every line the subagent's `claude` process writes bumps the timestamp —
stream-json events, plain stdout chatter and stderr alike — and what the
subagent actually said is never read. The record says the child is alive,
not what it's doing. The clock starts at the spawn, so a subagent that wedged
before it ever emitted anything still reports a climbing age rather than
no age at all. It's dropped when the turn ends, since a finished turn has
no progress left to describe.
## A `continue` that finds no session
`continue` looks for the session before it spawns anything. The lookup is
the live session titled the name in the project directory of the `dir` in
effect: the one passed, or else the one the daemon remembers for the name.
No such session refuses the call — nothing spawns, the name stays free
for a retry, and the session keeps its `status` (stop reason and turn
budget) and its remembered `dir` — and the error names the directory
searched:
```
continue error: no session titled `n` under /home/agent/.claude/projects/-home-agent-work; pass the `dir` the session was started in
```
A name is never searched for in any other directory, because claude would
resolve a bare title across every worktree of the repo and could pick, or
refuse, the wrong session. A session started in another directory
needs that directory passed as `dir` — and after a daemon restart, which
forgets the remembered one, a session outside the agent's work dir
needs it again. A store location the daemon can't resolve
refuses the same way, with the io error.
A goal run's next turn does the same lookup; a miss there ends the run as
a failed turn, with the same message in its end-of-turn todo.
The refusal covers a session missing from the directory searched.
It can't cover one that vanishes between the lookup and the spawn, so
`continue` still waits for claude's answer instead of returning the
instant the process exists. Where `start` returns at the spawn — it
creates its session, so the spawn succeeding is the whole story — a
resumed turn can fail a moment _after_ a pid exists. `continue` holds the
tool call open until the turn is underway or the resume has come back
missed, and reports a miss as the call's own error, carrying claude's
message plus the location this daemon searched:
```
continue error: claude error: no session matched the requested id or title (searched /home/agent/.claude for cwd /home/agent/work; if it was started elsewhere, pass the `dir` it was started in)
```
The directory is the part claude's own message never names, and the part
that resolves the confusion: pass `dir` to point `continue` at the
directory where the session began.
Nothing here is a fixed delay on the way to a successful turn. The wait
ends on whichever comes first — the turn's first stream event or its
early exit — and on this box both land inside a second, so a `continue`
that works answers about as fast as it did before. A five-second cap
bounds the one case neither covers: a child that neither speaks nor
exits, reported as started, with the end-of-turn todo left to say how it
goes. That todo carries every failure that happens after the one
`continue` already returned to the caller.
## A killed turn
A subagent whose `claude` process dies on a signal — the kernel's OOM
killer under memory pressure, a stopped unit, an `interrupt` from the
owning agent — didn't finish its turn, and the daemon says so rather
than letting it settle back into `idle`:
- `status` reports the session **killed**, naming the signal, instead of
the `idle` it reports for a turn that ended on its own;
- the end-of-turn todo the daemon pushes unprompted says something killed
the subagent mid-turn, not that it finished;
- `continue` still resumes such a session — often what you want — but
its reply says something killed the previous turn, so nobody carries on
from cut-off work believing it was complete.
The record is per-name, in memory with the rest of this daemon's state,
and the next confirmed spawn under that name clears it. A daemon restart
loses it along with everything else — the daemon has to outlive the kill
to report it, which is what `OOMPolicy=continue` on the unit is for.
## Compaction trade-off
<!-- vale write-good.Passive = NO -->
Built on `hive_claude::Claude::spawn` + `RunningClaude::wait` directly
rather than `InfiniteSession::run`, since only the low-level driver
exposes a cancel handle to stop a turn mid-flight — that's what makes
`interrupt` genuinely stop a running turn rather than only cancelling a
still-pending one. The cost: a turn that overflows the context window
surfaces as an error rather than self-healing via reactive compaction.
Subagents are meant to be bounded, single-batch work, not sessions
long-lived enough to need in-place compaction — a real follow-up if that
assumption stops holding.
<!-- vale write-good.Passive = YES -->
## Configuration
`services.hyperhive.agent.mcp.subagentHttpPort` — the daemon's streamable-http listen
port. Same pattern as `bashHttpPort`/`matrixHttpPort`: a per-agent default
assigned by `nix/agent-modules/mcp.nix`, only worth overriding for an
agent that needs a stable or non-default port.
Own systemd unit, defined alongside the other per-agent MCP daemons in
`nix/agent-modules/mcp.nix`.
## Which models a subagent may run on
The daemon refuses a `start` or `continue` that names a `model` outside the
agent's list before anything spawns, and the error names the list and where
it came from. One without a `model` runs on claude's own default, which the
daemon doesn't check. The list is:
- the swarm's `subagent_models` for this agent, when it sets one
([`inference.md`](../../4-implementation/swarm/inference.md)). It replaces the agent's own
list, so it can name models that list leaves out.
- otherwise `services.hyperhive.agent.availableModels`, which reaches the
daemon as `HIVE_AVAILABLE_MODELS`. An empty list restricts nothing.
The daemon reads the swarm's value on each such call, so a change applies
to the next one. When the read fails, or doesn't answer within 2 seconds,
the daemon logs a warning and checks against `availableModels`. The same
happens until the daemon's first connection to the swarm queue succeeds.
The match ignores case. On an ACP agent the names are the agent's own, such
as `<provider>/<model>` on `opencode`.
## How many subagents may run at once
When the swarm sets `max_subagents` for the agent
([`inference.md`](../../4-implementation/swarm/inference.md)), the daemon refuses a `start`, or a
`continue` of a session that isn't running, once that many subagents are
running, before anything spawns:
```text
3 subagents running, cap is 3 (set in swarm-ui → this agent → inference) — wait for one to finish, or `interrupt` one
```
Every subagent the daemon tracks counts, whatever its role: one running a
turn, one starting, a goal run between turns, and an interrupted one whose
process hasn't exited yet. A goal run's next turn isn't a new start, and the
cap never stops it. Lowering the cap stops nothing already running.
The daemon reads the cap with the model list, on every `start` and
`continue`, so a change applies to the next call. A cap of 0 refuses every
`start` and `continue`, and the message says the cap is 0. Without a swarm value, or when the read fails, no cap applies. A failed read logs a warning naming the agent and the error, and the start proceeds: an unreachable swarm must not block subagent work.
## Runtime
A subagent runs on its parent agent's runtime
(`services.hyperhive.agent.runtime`). The unit gets the harness's
`HIVE_RUNTIME`/`HIVE_ACP_*` variables, and the daemon reads them once at
startup (`hive_runtime::RuntimeSpec`). On `claude`, everything else on this
page applies as written.
On `acp`:
- each run spawns its own copy of the parent's ACP agent. The end of the
run sends SIGINT to it and every command in its process group. The unit loads
`backendEnvironmentFile` for it. On the `opencode` preset, the daemon
reads a provider key that file leaves unset from the swarm secret store
at `swarm/agents/<agent>/acp-provider`, as the harness does.
- the session id lives in `subagent-acp-session-<name>` under the harness
dir. `start` moves an old one aside, `continue` loads it into a fresh
agent process, and a `continue` with none recorded fails straight away.
- a role goes in front of the session's first prompt, since ACP has no
system prompt.
- `interrupt` sends `session/cancel`. The runtime sends SIGINT to the
process group of an agent that ignores the cancel for 10s, and when the
run ends. Nothing escalates past that SIGINT. The name is free as soon as
`interrupt` sends the cancel.
- the runtime sets `model` and `effort` on the session when the agent
offers that value for its `model` or `thought_level` config option. Any
other value does nothing (`hive_runtime::AcpRuntime`).
- the agent's permission requests get the same answers a claude subagent's
tool list gives: MCP tools and file tools, `fetch` with `web_tools`,
`execute` with `execution` (`session::acp_permits`).
## The tool surface a subagent gets
Two flags, each covering one half, and neither covering the other:
`--tools` governs claude's built-in tools, `--strict-mcp-config` governs
the MCP ones. Dropping either brings that half back in full; in
particular `--tools` does **not** filter `mcp__*` tools.
### Built-in tools (`--tools`)
**A subagent gets exactly the built-ins its parent agent has, plus one
deliberate exception** — the base resolution
(`hive_sh4re::permissions::subagent_builtin_tools_for`, wrapping the
harness's own `builtin_tools_for`) over the agent's permissions, which the
daemon reads from the swarm on each `start`/`continue`:
`Edit`, `Glob`, `Grep`, `Read`, `Skill`, `Write`, plus `WebFetch`/
`WebSearch` for an agent granted `web_tools` and not otherwise. A running
subagent keeps the tools it started with. See
[the harness's own allowlist](../../4-implementation/turn-loop/mcp.md#tool-allowlist-hive_sh4repermissionsallowed_builtin_tools)
for what that list contains and why.
The exception: **a subagent gets Claude's built-in `Bash` iff its parent
holds the `execution` permission** — the permission that already grants the
parent shell execution, via the out-of-process `bash` MCP server
(`mcp__bash__run`/`status`/`kill`). A subagent has no MCP server of its
own by default (see [MCP servers](#mcp-servers---strict-mcp-config)
below), so there is no `mcp__bash__*` for it to inherit that capability
through; it gets Claude's own tool instead. The rule is the capability
transfers, not the mechanism — the parent itself never gains built-in
`Bash` no matter which permissions it holds, only a subagent spawned by an
`execution`-holding parent does.
Inheriting rather than listing is the point: a hardcoded subagent list
would hand web egress (or `Bash`) to the subagent of an agent that isn't
allowed it, and would diverge from the parent's on the first tool anyone
adds to either.
Everything else in claude's built-in set is absent, in particular the
tools that let a session act outside the run for which it began: peer and
operator messaging, nested agents (including the stop verb, which takes
an _agent_ id rather than a session), schedule and webhook creation, and
worktree switching.
One rule worth knowing before editing any of this: **the daemon never
emits an empty `--tools` value**, and asserts rather than doing so. Not
because of what an empty value does — that's exactly the point. The hive's own
measurement and the installed `claude --help` disagree about whether
`--tools ""` means "no tools" or reads as the flag being absent, and the
answer belongs to whichever claude release the system has installed
rather than to this repo. Refusing the case is correct under either reading, so the code
never has to know which holds. Nothing here wants a subagent with no
built-in tools anyway.
### MCP servers (`--strict-mcp-config`)
A subagent runs with `--strict-mcp-config` and, by default, exactly one
MCP server: the two-tool `subagent_control` route above — not the
parent's `mcp__bash__*` / `mcp__hyperhive__*` surface. Those two signal
tools are deliberately unnamed in `--tools`, which doesn't govern them;
they survive on this flag alone. Nothing implicit reaches it: the built-in
hyperhive surface (todos/messaging) isn't an `extraMcpServers` entry at
all, and the automatically injected `bash`/`subagent` entries default to excluded
too (a subagent can't spawn hive-bash tasks or its own nested subagents
unless an operator opts them in explicitly, same as anything else).
Set `services.hyperhive.agent.extraMcpServers.<name>.availableToSubagents = true` on a
specific entry to hand that one server to subagents as well — useful for,
say, a read-only lookup or scraper MCP a subagent's bounded, single-batch
task might need. `hive-subagent-mcp`'s `mcp_config` module renders the
opted-in subset into a `--mcp-config` file per session, rewritten each
turn; an entry left at the default `false` never appears there. Per
session rather than one shared file, because the `subagent_control` entry
in it carries that session's own signal URL — one file for everyone would
be a race over whose identity each subagent reads at startup.

View file

@ -1,51 +0,0 @@
# Command-Line Help for `swarm-logs`
This document contains the help content for the `swarm-logs` command-line program.
**Command Overview:**
* [`swarm-logs`↴](#swarm-logs)
* [`swarm-logs query`↴](#swarm-logs-query)
## `swarm-logs`
query the swarm's log store from an agent
**Usage:** `swarm-logs <COMMAND>`
###### **Subcommands:**
* `query` — Run a LogsQL query and print the matched log lines
## `swarm-logs query`
Run a LogsQL query and print the matched log lines
**Usage:** `swarm-logs query [OPTIONS] <LOGSQL>`
###### **Arguments:**
* `<LOGSQL>` — The LogsQL query. A bare word is a full-text search for it.
Quote it: LogsQL uses characters the shell also claims, and an unquoted `|` pipes this command into the rest of your query instead of sending it.
###### **Options:**
* `--limit <LIMIT>` — Stop after this many records.
Unset means the store's own default. Worth setting on a broad query — nothing between here and the store narrows one, so a bare word matches across every hive in the swarm.
* `--format <FORMAT>` — What to print for each matched record
Default value: `message`
Possible values:
- `message`:
One log message per line — the `_msg` field and nothing else
- `json`:
The store's response body, unmodified, one JSON object per line

View file

@ -1,123 +0,0 @@
# Command-Line Help for `swarm-metrics`
This document contains the help content for the `swarm-metrics` command-line program.
**Command Overview:**
* [`swarm-metrics`↴](#swarm-metrics)
* [`swarm-metrics query`↴](#swarm-metrics-query)
* [`swarm-metrics query-range`↴](#swarm-metrics-query-range)
* [`swarm-metrics series`↴](#swarm-metrics-series)
* [`swarm-metrics labels`↴](#swarm-metrics-labels)
* [`swarm-metrics label-values`↴](#swarm-metrics-label-values)
## `swarm-metrics`
query the swarm's metrics store from an agent
**Usage:** `swarm-metrics <COMMAND>`
###### **Subcommands:**
* `query` — Evaluate a `MetricsQL` (`PromQL`) expression at one instant
* `query-range` — Evaluate an expression over a window, one point per step
* `series` — List the series that match one or more selectors
* `labels` — List label names, optionally only those on matching series
* `label-values` — List one label's values; `__name__` lists every metric name
## `swarm-metrics query`
Evaluate a `MetricsQL` (`PromQL`) expression at one instant
**Usage:** `swarm-metrics query [OPTIONS] <PROMQL>`
###### **Arguments:**
* `<PROMQL>` — The expression. Quote it: `{`, `|` and `"` are the shell's too.
OTLP metric names keep their dots, so match them by `{__name__="jobq.nodes"}` rather than as a bare name.
###### **Options:**
* `--time <TIME>` — The instant: RFC 3339, a unix timestamp, or relative (`-5m`). Unset means now
## `swarm-metrics query-range`
Evaluate an expression over a window, one point per step
**Usage:** `swarm-metrics query-range [OPTIONS] --start <START> <PROMQL>`
###### **Arguments:**
* `<PROMQL>` — The expression, quoted as for `query`
###### **Options:**
* `--start <START>` — Start of the window: RFC 3339, a unix timestamp, or relative (`-1h`). Required, so a range is always bounded
* `--end <END>` — End of the window, in the same forms. Unset means now
* `--step <STEP>` — Seconds or a duration (`1m`) between points. Unset means the store's default
## `swarm-metrics series`
List the series that match one or more selectors
**Usage:** `swarm-metrics series [OPTIONS] <MATCHERS>...`
###### **Arguments:**
* `<MATCHERS>` — A series selector, such as `{"service.name"="nats"}`. Repeatable
###### **Options:**
* `--start <START>` — Start of the window: RFC 3339, a unix timestamp, or relative (`-1h`).
Unset means the store's default: the last day, from 00:00 UTC.
* `--end <END>` — End of the window, in the same forms. Unset means now
* `--limit <LIMIT>` — Return at most this many entries
## `swarm-metrics labels`
List label names, optionally only those on matching series
**Usage:** `swarm-metrics labels [OPTIONS]`
###### **Options:**
* `--match <MATCHERS>` — A series selector to narrow to. Repeatable
* `--start <START>` — Start of the window: RFC 3339, a unix timestamp, or relative (`-1h`).
Unset means the store's default: the last day, from 00:00 UTC.
* `--end <END>` — End of the window, in the same forms. Unset means now
* `--limit <LIMIT>` — Return at most this many entries
## `swarm-metrics label-values`
List one label's values; `__name__` lists every metric name
**Usage:** `swarm-metrics label-values [OPTIONS] <NAME>`
###### **Arguments:**
* `<NAME>` — The label name
###### **Options:**
* `--match <MATCHERS>` — A series selector to narrow to. Repeatable
* `--start <START>` — Start of the window: RFC 3339, a unix timestamp, or relative (`-1h`).
Unset means the store's default: the last day, from 00:00 UTC.
* `--end <END>` — End of the window, in the same forms. Unset means now
* `--limit <LIMIT>` — Return at most this many entries