diff --git a/docs/forge.md b/docs/forge.md
index a2dfd793..527ca5ac 100644
--- a/docs/forge.md
+++ b/docs/forge.md
@@ -129,16 +129,16 @@ best-effort: logged at debug/warn and retried next tick.
Forgejo fires notifications for the agent's own actions (it opened a
PR, posted a comment, submitted a review). Surfacing those would
-loop claude on its own writes. Two filter rules drop them silently
-(mark-read without delivery):
+loop claude on its own writes. The comment/review case is dropped
+silently (mark-read without delivery):
-- **Self-authored new items** — notifications with
- `reason == "author"` AND subject state `open` (or missing). State
- transitions (merge / close) on the agent's own PRs DO surface,
- since those are triggered by someone else.
- **Self-authored comments / reviews** — comment payload's
`user.login` matches `own_login`.
+Self-authored *new items* (an agent opening its own PR/issue) are not
+filtered and do surface — the notification subject carries no author
+field to match against without a per-notification fetch.
+
`own_login` is fetched once at startup via `GET /api/v1/user`. On
fetch failure the filter degrades open (no filtering) rather than
crashing the task — a noisy inbox beats a silently-stuck poller.
@@ -181,11 +181,11 @@ Five shapes, distinguished by the notification's classification:
| Trigger | Wrapper |
| --- | --- |
-| Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...\nreason: mention` |
-| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nreviewer: body\nassignee: ...\nreason: review_requested` |
-| New issue / PR | `[new PR #N owner/repo] title\nurl: ...\n\n
\nassignee: ...\nreason: subscribed` |
-| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...\nreason: subscribed` |
-| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...\nreason: subscribed` |
+| Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` |
+| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nreviewer: body\nassignee: ...` |
+| New issue / PR | `[new PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...` |
+| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...` |
+| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` |
Review labels come from the Forgejo `state` field: `APPROVED` →
`approved`, `REQUEST_CHANGES` → `changes requested`, `COMMENT` →
@@ -222,14 +222,6 @@ Every wrapper ends with one or more of:
the line shape is stable.
- `reviewer: ` — PR notifications only, present only when
`requested_reviewers` is non-empty.
-- `reason: ` — always present when the notification
- carries a reason; absent when the field is null/missing.
-
-The `reason` line distinguishes otherwise-identical messages: Forgejo
-emits one notification per applicable reason for the same event
-(e.g. both `mention` and `subscribed` arrive for a PR comment that
-tags the agent). Without the suffix, the agent would see duplicated
-wrapper text with no signal which Forgejo path triggered each copy.
### Review-request override
diff --git a/docs/tools/forge.md b/docs/tools/forge.md
index 4240360d..2e4fdd53 100644
--- a/docs/tools/forge.md
+++ b/docs/tools/forge.md
@@ -19,7 +19,7 @@ under `issue` and `pr` parent commands — `hive-forge pr close 42`,
refuses an issue number, which the old generic `close` couldn't). 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/labels/assign/timeline as applicable).
+comments/close/reopen/labels/assign/timeline as applicable).
The flat forms below (`close 42`, `pr-create …`, `pr-status …`, …) still work
as **hidden back-compat aliases** during the transition and are dropped from
@@ -27,7 +27,9 @@ as **hidden back-compat aliases** during the transition and are dropped from
```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 --pr 42 # PR health (mergeable / CI / reviews)
hive-forge issue create --title "..." --body "..."
# --- flat aliases below remain valid (hidden) ---
diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md
index 3d34ba73..78fb338d 100644
--- a/docs/web-ui/dashboard.md
+++ b/docs/web-ui/dashboard.md
@@ -925,7 +925,7 @@ frosted-mauve bar slides up from the bottom of the viewport
- `■ ST0P` — running agents only
- `▶ ST4RT` — stopped agents only
- `↻ R3BU1LD` — always available
- - `DESTR0Y` / `PURG3` — sub-agents only (disabled if the root/bootstrap container selected)
+ - `DESTR0Y` / `PURG3` — always available
- `⇡ M0V3 → ROOT` — promote selected agents to top-level
(parent = null); disabled when all selected are already at root.
Backend `topology::set_parent` refuses moves it can't satisfy
diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js
index 967f89c4..6c0965a9 100644
--- a/frontend/packages/dashboard/src/call.js
+++ b/frontend/packages/dashboard/src/call.js
@@ -314,17 +314,18 @@ export function renderApprovals() {
for (const a of pending) {
const isApply = a.kind === 'apply_commit';
const isInit = a.kind === 'init_config';
+ const isMergePr = a.kind === 'merge_config_pr';
const li = el('li', { class: 'approval-card' });
// ── identity header ──────────────────────────────────────────
const head = el('div', { class: 'approval-head' },
- el('span', { class: 'glyph' }, isApply ? '→' : '⊕'),
+ el('span', { class: 'glyph' }, isApply ? '→' : isMergePr ? '⇒' : '⊕'),
el('span', { class: 'id' }, '#' + a.id),
el('span', { class: 'agent' }, a.agent),
- el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') },
- isApply ? 'apply' : isInit ? 'init' : 'spawn'),
+ el('span', { class: 'kind' + ((isApply || isMergePr) ? '' : ' kind-spawn') },
+ isApply ? 'apply' : isMergePr ? 'merge-pr' : isInit ? 'init' : 'spawn'),
);
- if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short));
+ if ((isApply || isMergePr) && a.sha_short) head.append(el('code', {}, a.sha_short));
// When the approval was requested — relative time, right-aligned.
// Goes amber once it's been pending an hour so a stale request is
// obvious at a glance (see docs/web-ui.md::Approval card).
@@ -357,6 +358,19 @@ export function renderApprovals() {
}, '↳ commit on forge ↗'));
}
body.append(drill);
+ } else if (isMergePr) {
+ // PR-based config deploy: link to the reviewed PR on the forge
+ // (mirrors the apply_commit "commit on forge" link). The config
+ // diff side-panel is apply_commit-only for now.
+ const drill = el('div', { class: 'drill-ins' });
+ if (forgeBase && a.pr_number != null) {
+ drill.append(el('a', {
+ class: 'panel-trigger', target: '_blank', rel: 'noopener',
+ href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`,
+ title: 'review this config PR on the hive forge',
+ }, '↳ review PR on forge ↗'));
+ }
+ body.append(drill);
} else {
body.append(el('span', { class: 'meta' },
isInit
@@ -403,7 +417,7 @@ function renderApprovalHistory(root, history) {
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ',
- el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ',
+ el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ',
);
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
row.append(
diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js
index bfa66b06..8b9f4c5a 100644
--- a/frontend/packages/dashboard/src/tabs.js
+++ b/frontend/packages/dashboard/src/tabs.js
@@ -249,8 +249,8 @@ window.marked = marked;
// ─── per-agent context menu ──────────────────────────────────────────
// Three-dot (⋮) button on each agent card for quick single-agent
// lifecycle actions without needing to select first. State-aware:
- // restart/stop only shown when running, start only shown when stopped,
- // destroy/purge hidden for the manager.
+ // restart/stop only shown when running, start only shown when
+ // stopped; rebuild + destroy/purge always shown.
// The button is CSS-invisible until the row is hovered (or menu is
// open) so it doesn't clutter quiet rows.
@@ -383,20 +383,18 @@ window.marked = marked;
`view ${c.name} journal logs`),
);
- {
- dropdown.append(
- menuSep(),
- menuItem('DESTR0Y', {
- action: '/api/destroy/',
- confirm: `destroy ${c.name}? container removed; state + creds kept.`,
- }),
- menuItem('PURG3', {
- action: '/api/destroy/',
- body: { purge: 'on' },
- confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
- }),
- );
- }
+ dropdown.append(
+ menuSep(),
+ menuItem('DESTR0Y', {
+ action: '/api/destroy/',
+ confirm: `destroy ${c.name}? container removed; state + creds kept.`,
+ }),
+ menuItem('PURG3', {
+ action: '/api/destroy/',
+ body: { purge: 'on' },
+ confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
+ }),
+ );
if (c.deployed_sha && forgeBase) {
const li = el('li', { role: 'presentation' });
diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs
index b56b4c7d..4a8c5b26 100644
--- a/hive-ag3nt/src/forge_notify.rs
+++ b/hive-ag3nt/src/forge_notify.rs
@@ -382,8 +382,7 @@ async fn format_notification(
};
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);
+ let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr);
// Determine whether this notification was triggered by a comment/review or
// by creation/state-change of the subject itself.
@@ -396,7 +395,6 @@ async fn format_notification(
num,
repo,
meta_suffix,
- reason,
subject,
is_pr,
};
@@ -411,7 +409,7 @@ async fn format_notification(
)
.await
} else {
- format_state_change_notification(notif, &meta, own_login)
+ Some(format_state_change_notification(notif, &meta, own_login))
}
}
@@ -423,19 +421,15 @@ struct NotifMeta<'a> {
num: String,
repo: String,
meta_suffix: String,
- /// Forgejo `reason` value (e.g. "mention", "assigned", "subscribed").
- /// Appended to every wrapper as the `reason:` line in the meta
- /// suffix (see `docs/forge.md::Meta suffix`).
- reason: &'a str,
/// Fetched subject detail (issue/PR JSON); used for review-request detection.
subject: Option,
is_pr: bool,
}
-/// Build the `\nassignee: ...` (and optionally `\nreviewer: ...` and
-/// `\nreason: ...`) suffix appended to every wrapper. Shape +
-/// presence rules live in `docs/forge.md::Meta suffix`.
-fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool, reason: &str) -> String {
+/// Build the `\nassignee: ...` (and optionally `\nreviewer: ...`)
+/// suffix appended to every wrapper. Shape + presence rules live in
+/// `docs/forge.md::Meta suffix`.
+fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool) -> 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())
@@ -459,21 +453,10 @@ fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool, reason: &
} else {
None
};
- // Always include reason so multiple notifications for the same
- // event (each with a different Forgejo reason) stay
- // distinguishable.
- 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
}
@@ -574,7 +557,7 @@ 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`:
@@ -583,14 +566,9 @@ fn format_state_change_notification(
// - Forgejo API type is "Pull" / "Issue", never "Pull Request".
let notif_state = notif["subject"]["state"].as_str().unwrap_or("");
- // Self-notification filter: drop new items we authored ourselves
- // (`reason == "author"` + open state). State transitions on our
- // own PRs (merge / close) come from someone else, so those stay.
+ // "New" = the subject is open (or state is absent). Used below for
+ // the review-request override.
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,
@@ -599,7 +577,6 @@ fn format_state_change_notification(
num,
repo,
meta_suffix,
- reason: _,
subject,
is_pr,
} = meta;
@@ -658,7 +635,7 @@ fn format_state_change_notification(
let mut out = format!("[{kind}] {title}\nurl: {html_url}{body_block}");
out.push_str(meta_suffix);
- Some(out)
+ out
}
/// Decide whether a state-change notification represents the subject's
diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs
index 383e2a23..a4591049 100644
--- a/hive-c0re/src/coordinator.rs
+++ b/hive-c0re/src/coordinator.rs
@@ -131,6 +131,15 @@ pub struct Coordinator {
/// agent is in this set — the inbound fence. Cleared when the agent
/// reports `GracefulStopComplete` or the container is stopped.
graceful_stop_pending: Mutex>,
+ /// Logical agent names that were running at the last broad-scope
+ /// `hivectl stop`. A subsequent broad-scope `hivectl start` restores
+ /// only this set (intersected with the requested scope) rather than
+ /// every configured container, so agents the operator intentionally
+ /// left stopped stay stopped. `None` when no broad stop has happened
+ /// since the last start (or since daemon boot) — start then falls
+ /// back to "start all". In-daemon memory only (hive-c0re survives
+ /// `hivectl stop`); host-reboot persistence is a separate follow-up.
+ last_stopped_running: Mutex