diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt
index 9c254f7d85..9d0d7bbe65 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt
@@ -79,6 +79,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
+import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -92,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
+import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -132,7 +134,7 @@ fun JobBoardScreen(
var composing by remember { mutableStateOf(false) }
Scaffold(
- topBar = { TopBarWithBackButton("Backlog", nav) },
+ topBar = { TopBarWithBackButton(stringRes(R.string.buzz_job_board_title), nav) },
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { composing = true },
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt
index 553c01ee79..73392f1934 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt
@@ -183,7 +183,8 @@ fun RelayGroupTopBar(
// outright. So a DM shows the icon only when a canvas already exists — which is also what
// keeps us from advertising "start a shared doc" in a two-person conversation.
val hasCanvas by observeBuzzCanvas(channel.groupId.id)
- if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) && (!isDm || hasCanvas)) {
+ val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) }
+ if (isBuzzRelay && (!isDm || hasCanvas)) {
IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id, channel.groupId.relayUrl.url)) }) {
Icon(
symbol = MaterialSymbols.Dashboard,
@@ -191,22 +192,6 @@ fun RelayGroupTopBar(
modifier = Modifier.size(20.dp),
)
}
- // Buzz agent backlog (kinds 43001-43006): the channel's shared job board.
- IconButton(onClick = { nav.nav(Route.BuzzJobBoard(channel.groupId.id, channel.groupId.relayUrl.url)) }) {
- Icon(
- symbol = MaterialSymbols.Checklist,
- contentDescription = "Backlog",
- modifier = Modifier.size(20.dp),
- )
- }
- // Buzz workflow runs (46020 + lifecycle): the channel's shared run board + approval gates.
- IconButton(onClick = { nav.nav(Route.BuzzWorkflowBoard(channel.groupId.id, channel.groupId.relayUrl.url)) }) {
- Icon(
- symbol = MaterialSymbols.Gavel,
- contentDescription = "Workflow runs",
- modifier = Modifier.size(20.dp),
- )
- }
}
// remember the bech32 (naddr) encode — this top bar recomposes on every roster/metadata
@@ -263,6 +248,26 @@ fun RelayGroupTopBar(
},
)
}
+ // The agent surfaces: this channel's job backlog (43001-43006) and workflow runs
+ // (46020 + lifecycle). Menu entries rather than icons — they are two more views of
+ // the channel, reached occasionally, and as icons they pushed the bar back to four,
+ // truncating the channel name and relay the title row is there to show.
+ if (isBuzzRelay && !isDm) {
+ DropdownMenuItem(
+ text = { Text(stringRes(R.string.buzz_job_board_title)) },
+ onClick = {
+ menuOpen = false
+ nav.nav(Route.BuzzJobBoard(channel.groupId.id, channel.groupId.relayUrl.url))
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(stringRes(R.string.buzz_workflow_runs_title)) },
+ onClick = {
+ menuOpen = false
+ nav.nav(Route.BuzzWorkflowBoard(channel.groupId.id, channel.groupId.relayUrl.url))
+ },
+ )
+ }
if (naddr != null) {
val context = LocalContext.current
DropdownMenuItem(
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index b830adf5f7..5d453094bc 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -4854,6 +4854,7 @@
Public and ephemeral — no history, anyone in the cell can read it.
+ Backlog
Workflow runs
New run
Needs your approval
diff --git a/cli/plans/2026-07-25-buzz-agent-support-channel.md b/cli/plans/2026-07-25-buzz-agent-support-channel.md
index f1ea5ed3bb..bccdcb77a8 100644
--- a/cli/plans/2026-07-25-buzz-agent-support-channel.md
+++ b/cli/plans/2026-07-25-buzz-agent-support-channel.md
@@ -147,8 +147,10 @@ is a coherent *owner telemetry* surface and should stay that — just get a bett
The **shared work surface is a different thing and belongs at the channel level.** A Buzz job is
`h`-scoped to a channel, so the backlog is *per-channel* — exactly like the Canvas (40100) and
Forum, which launch from `RelayGroupTopBar` gated by `BuzzRelayDialect.isBuzz`. So the Jobs
-board sits there too (a `Checklist` action → `Route.BuzzJobBoard(channelId, relayUrl)`), NOT
-inside the owner Console. Keeping "owner fleet telemetry" and "this channel's shared backlog"
+board sits there too (→ `Route.BuzzJobBoard(channelId, relayUrl)`), NOT inside the owner
+Console. It lives in that bar's **overflow menu** rather than as an icon: Canvas is the only
+affordance holding an icon there, because a fourth and fifth one squeeze the title row until the
+channel name and relay truncate. Keeping "owner fleet telemetry" and "this channel's shared backlog"
as separate surfaces is the right call.
Note the model change also **deprioritizes the workflow-approval inbox (46010/46030/46031)**: with
@@ -162,7 +164,8 @@ create/interact surface** for the two kinds that define the workflow. Priorities
**P0 — the shared work surface**
- **P0-2 Jobs board — ✅ LANDED.** `JobBoardScreen` + `JobBoardViewModel` (per-channel,
- `Route.BuzzJobBoard(channelId, relayUrl)`, entered from `RelayGroupTopBar` on Buzz relays).
+ `Route.BuzzJobBoard(channelId, relayUrl)`, entered from the `RelayGroupTopBar` overflow menu
+ on Buzz relays).
Reads job kinds + kind-7 upvotes scoped to the channel `h`, folds via `BuzzJobAggregator`,
groups by state (In progress / Queued-by-upvotes / Done / Closed), live via `subscribeAsFlow`.
Three write actions through new `Account` helpers: **file** a task (43001, FAB → dialog),
@@ -231,10 +234,14 @@ wire handling, both verified against geode:
- `cli/tests/buzz/workflow-loop.sh` — end-to-end headless harness (alice triggers, bot runs,
carol approves/denies) through embedded geode; 14/14 green, including the deny path and
worktree cleanup.
+- `cli/tests/buzz/agent-exec.sh` — covers the real `--exec` wrapper the loop harnesses stub out:
+ task → agent → commit → push → PR url, plus the paths that must fail (no diff, empty task,
+ missing scheduler env) and the default-branch guard, asserting `main` is left unmoved. Stubbed
+ `gh` + agent, so no network, credentials, or Claude Code; 19/19 green.
**Landed (Android app):**
- `WorkflowRunBoardScreen` + `WorkflowRunBoardViewModel` (per channel, `Route.BuzzWorkflowBoard`,
- entered from `RelayGroupTopBar` on Buzz relays). Folds the workflow kinds via
+ entered from the `RelayGroupTopBar` overflow menu on Buzz relays). Folds the workflow kinds via
`WorkflowRunAggregator`, groups runs by state with **"Needs your approval" pinned first**, and the
named approver grants/denies a paused run inline (46030/46031). Merge stays on GitHub.
- `Account.triggerBuzzWorkflow` / `approveBuzzWorkflowRun` / `denyBuzzWorkflowRun` (same
diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore
index 77d0da270e..a74c7de8b8 100644
--- a/cli/tests/.gitignore
+++ b/cli/tests/.gitignore
@@ -9,3 +9,4 @@ blossom/state-blossom-live/
git/state-git-nip34/
buzz/state-job-loop/
buzz/state-workflow-loop/
+buzz/state-agent-exec/
diff --git a/cli/tests/buzz/agent-exec.sh b/cli/tests/buzz/agent-exec.sh
new file mode 100755
index 0000000000..8ebff83d5b
--- /dev/null
+++ b/cli/tests/buzz/agent-exec.sh
@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+#
+# agent-exec.sh — self-contained headless test for the `--exec` wrapper that turns a Buzz job
+# into a pull request (tools/buzz-agent/agent-exec.sh).
+#
+# job-loop.sh proves the scheduler drives *an* --exec program; this proves the real one does the
+# right thing with git and `gh`. Both the agent and `gh` are stubbed, so it needs no network, no
+# credentials, and no Claude Code — it exercises the plumbing around them:
+#
+# task on stdin → agent → verify a diff → commit → push the job branch → open/reuse PR → url
+#
+# The failure paths matter as much as the happy one: an agent that changed nothing must become a
+# job error, and the default-branch guard must actually hold.
+#
+# Usage: ./agent-exec.sh
+set -uo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
+EXEC="$REPO_ROOT/tools/buzz-agent/agent-exec.sh"
+BASE="$SCRIPT_DIR/state-agent-exec"
+PASS=0; FAIL=0
+check() { if [[ "$2" == "$3" ]]; then echo " ✓ PASS $1 ($2)"; PASS=$((PASS+1)); else echo " ✗ FAIL $1: got [$2] want [$3]"; FAIL=$((FAIL+1)); fi; }
+contains() { if [[ "$2" == *"$3"* ]]; then echo " ✓ PASS $1"; PASS=$((PASS+1)); else echo " ✗ FAIL $1: [$2] lacks [$3]"; FAIL=$((FAIL+1)); fi; }
+
+rm -rf "$BASE"; mkdir -p "$BASE/bin"
+
+# --- stub gh: default branch, no existing PR, and a PR URL on create -------------------------
+cat > "$BASE/bin/gh" <<'GH'
+#!/usr/bin/env bash
+case "$1 $2" in
+ "repo view") echo main ;;
+ "pr list") echo "" ;; # no open PR for this head
+ "pr create") echo "https://github.com/example/repo/pull/42" ;;
+ *) exit 1 ;;
+esac
+GH
+chmod +x "$BASE/bin/gh"
+export PATH="$BASE/bin:$PATH"
+
+# --- a bare "origin" so the real `git push` in the script has somewhere to go ----------------
+git init -q --bare "$BASE/origin.git"
+git init -q "$BASE/repo"
+cd "$BASE/repo"
+git config user.email t@t; git config user.name t
+echo seed > seed.txt; git add -A; git -c commit.gpgsign=false commit -qm seed
+git branch -M main
+git remote add origin "$BASE/origin.git"
+git push -q -u origin main
+
+run_case() { # run_case ; echoes exit code, sets OUT/ERRTXT
+ local branch="$1" agentcmd="$2" task="${3:-Add a greeting file}"
+ git -C "$BASE/repo" checkout -q main
+ git -C "$BASE/repo" worktree remove --force "$BASE/wt" 2>/dev/null
+ git -C "$BASE/repo" worktree add -q -b "$branch" "$BASE/wt" main 2>/dev/null
+ OUT="$(cd "$BASE/wt" && printf '%s' "$task" | env \
+ BUZZ_JOB_ID=job123 BUZZ_REQUESTER=alice BUZZ_BRANCH="$branch" \
+ BUZZ_WORKTREE="$BASE/wt" BUZZ_BASE_REF=main AGENT_CMD="$agentcmd" \
+ bash "$EXEC" 2>"$BASE/err.txt")"
+ RC=$?
+ ERRTXT="$(cat "$BASE/err.txt")"
+ return $RC
+}
+
+echo "> 1. happy path: agent edits a file → commit → push → PR url on stdout"
+run_case "claude/job-1" 'echo "wrote greeting" && echo hello > greeting.txt'
+check "exit code" "$?" "0"
+contains "stdout is the PR url" "$OUT" "https://github.com/example/repo/pull/42"
+check "branch landed on origin" "$(git -C "$BASE/origin.git" rev-parse --verify -q claude/job-1 >/dev/null && echo yes || echo no)" "yes"
+check "commit was made" "$(git -C "$BASE/wt" rev-list --count main..HEAD)" "1"
+contains "commit subject uses the task's first line" "$(git -C "$BASE/wt" log -1 --pretty=%s)" "feat: Add a greeting file"
+contains "file the agent wrote is in the commit" "$(git -C "$BASE/wt" show --stat --oneline HEAD)" "greeting.txt"
+contains "the agent got the task on stdin" "$ERRTXT" "Add a greeting file"
+
+echo "> 2. agent makes no changes → job error, non-zero exit"
+run_case "claude/job-2" 'echo "thought about it"'
+check "exit code" "$?" "1"
+contains "explains why" "$ERRTXT" "no changes"
+
+echo "> 3. agent commits by itself → wrapper pushes, does not double-commit"
+run_case "claude/job-3" 'echo x > f.txt && git add -A && git -c user.email=a@b -c user.name=a commit -qm "agent: own commit"'
+check "exit code" "$?" "0"
+check "exactly one commit" "$(git -C "$BASE/wt" rev-list --count main..HEAD)" "1"
+contains "kept the agent's own subject" "$(git -C "$BASE/wt" log -1 --pretty=%s)" "agent: own commit"
+
+echo "> 4. refuses to operate on the default branch"
+# the worktree sits on a scratch branch, but the scheduler hands it BUZZ_BRANCH=main —
+# exactly the mistake the guard exists to stop.
+git -C "$BASE/repo" worktree remove --force "$BASE/wt" 2>/dev/null
+git -C "$BASE/repo" worktree add -q -b scratch-guard "$BASE/wt" main
+OUT="$(cd "$BASE/wt" && printf 'do a thing' | env BUZZ_JOB_ID=job4 BUZZ_BRANCH=main BUZZ_WORKTREE="$BASE/wt" BUZZ_BASE_REF=main AGENT_CMD='echo hi > x.txt' bash "$EXEC" 2>"$BASE/err.txt")"; RC=$?
+ERRTXT="$(cat "$BASE/err.txt")"
+check "exit code" "$RC" "1"
+contains "says why" "$ERRTXT" "refusing to operate on the default branch"
+check "nothing was pushed to main" "$(git -C "$BASE/origin.git" rev-parse main)" "$(git -C "$BASE/repo" rev-parse main)"
+
+echo "> 5. missing scheduler env is a hard error, not a silent no-op"
+OUT="$(printf 'task' | env -u BUZZ_BRANCH BUZZ_WORKTREE="$BASE/wt" bash "$EXEC" 2>&1)"; RC=$?
+check "exit code" "$RC" "1"
+contains "names the missing var" "$OUT" "BUZZ_BRANCH"
+
+echo "> 6. empty task is rejected before the agent runs"
+run_case "claude/job-6" 'echo should-not-run > ran.txt' " "
+check "exit code" "$?" "1"
+contains "says why" "$ERRTXT" "empty task"
+
+echo
+echo "> RESULTS: $PASS passed, $FAIL failed"
+[[ $FAIL -eq 0 ]]