Status line, question and result toasts, and a read-only brgr_run tool. The mod never accepts, rejects, applies, revises, or acknowledges a result.

Let Codex or Claude Code hand work to other coding agents, each running in its own Herdr pane beside you, without anyone clicking through their prompts.

Above: one request to Claude Code. brgr opens three Claude Code workers beside it, each worker's report comes back as a FROM BRGR notice, Claude accepts it, and brgr closes that worker's pane. Shown at 2x speed.
brgr brings the orchestration part of Orca, a desktop app for running many coding agents, to Herdr. Your agent stays the one that decides. brgr starts the workers, clicks through the trust and continue prompts that would stop them, and hands every result back for an explicit accept or reject.
--keep-worktree keeps it).FROM BRGR notice into your agent's pane, and the agent reads the result and accepts or rejects it. A diff is applied only after an explicit accept.Workers can be Claude Code, Codex CLI, Gemini CLI, GitHub Copilot CLI, Pi, OpenCode, Cursor CLI, Devin CLI, Cline, OMP or Command Code (GJC is experimental); brgr calls each registered worker CLI a harness. The agent that starts workers, the owner, can be a Codex or Claude Code session.
Workers always run with full permissions (
--yolo,--dangerously-skip-permissionsand equivalents). That is the point of unattended orchestration, so only point brgr at code you trust. A clean Git repository gets a separate worktree for each task.
cargo) to install. Release archives exist but are unsigned and not notarized; see the unsigned distribution guide.# 1. The brgr CLI
cargo install --locked brgr-cli
# 2. Teach your agent how to use brgr
brgr integrate claude install # Claude Code: installs the brgr skill
brgr integrate codex install # Codex: installs the skill and session hooks
# 3. Register a worker. This runs one small prompt through it.
mkdir -p ~/brgr-scratch
brgr harness add "$(command -v claude)" --workspace ~/brgr-scratch --prompt "Reply with OK"
brgr doctor
brgr doctor prints JSON. It is ready when "status" is "ok" and the worker you added shows "health": "healthy". Then start a new Claude Code or Codex session inside Herdr: an agent reads its skills when a session starts, so one that was already running does not know brgr yet.
The Herdr plugin adds a task board and workspace actions on top:
herdr plugin install justn-hyeok/brgr --ref v2.15.6
Inside Herdr, ask your agent in plain words, for example:
Use brgr to have two Claude Code workers review this diff, one for bugs and one for missing tests. Accept the results that hold up.
The agent runs commands like these, which you can also run yourself:
brgr run "Review this change" --harness local.claude-code --criterion "findings cite changed lines"
brgr result TASK
brgr accept TASK --reason "criteria verified"
brgr land TASK --workspace . --reason "criteria verified" # code: check, accept, apply
| You see | What to do |
|---|---|
could not find the calling Codex pane | Start each brgr command from Codex with --as SESSION; the installed skill does this. brgr doctor shows under codex_trace whether your Codex release was checked. |
| A worker fails with "insufficient credits", a usage limit or an HTTP 4xx | The worker's own account or model failed, not brgr. Use another harness, or pick a model with brgr config set model MODEL --harness local.claude-code. |
a screen no brgr rule answers | The error quotes the screen. Answer it with brgr input TASK --key KEY or --text TEXT, and open an issue so a rule can be added. |
brgr doctor shows a harness as probe_evidence_changed | Its CLI updated itself. Register it again with brgr harness add PATH --workspace ~/brgr-scratch --prompt "Reply with OK". |
| A failure note after every brgr command | A failed run waits for you: inspect it with brgr result TASK, then brgr result TASK --ack. |
v2 is the current release line. Its author uses it daily, on macOS only, and it changes quickly; see the changelog. Two checks from its readiness record are still open: concurrency across several processes at once, and a full review of the code by someone other than the author. See the readiness status.
The execution model roadmap plans continuity foundations, Head Migration, and Dynamic Workflows while preserving the existing run contract.
brgr is written by AI coding agents under one maintainer's direction, so its code quality is not guaranteed; its behaviour is what the tests check. CONTRIBUTING.md says what that means and how to help.
The detailed contract, covering plugin internals, routes, pane mode, recursive workers, the completion loop and worktree cleanup, is in docs/guides/reference.md.
hooks/register.ts 178 lines1// @ts-nocheck — engine types live in Claude Code, not this repo.
2import type { EngineInterface, Register } from "claude-code"
3
4import {
5 MISSING_TOAST,
6 POLL_MS,
7 RUN_TIMEOUT_MS,
8 STATUS_FAILED_TOAST,
9 STATUS_TIMEOUT_MS,
10 STORE_KEY,
11 activeText,
12 emptyMemory,
13 isMemory,
14 joinTasks,
15 noteAnnouncements,
16 parseStatusList,
17 parseTree,
18 readRunInput,
19 runArgv,
20 summary,
21} from "./status.ts"
22import type { Memory, TaskView } from "./status.ts"
23
24let pollTimer: { cancel: () => void } | undefined
25let polling = false
26
27type Ran = { ok: true; stdout: string } | { ok: false; kind: "missing" | "exit"; detail: string }
28
29async function runBrgr(api: EngineInterface, argv: readonly string[], timeoutMs: number): Promise<Ran> {
30 try {
31 const ran = await api.process.run(argv, { timeoutMs })
32 if (ran.isStdoutTruncated || ran.isStderrTruncated) {
33 return { ok: false, kind: "exit", detail: "brgr output exceeded the limit" }
34 }
35 if (ran.exitCode !== 0) {
36 const detail = (ran.stderr || ran.stdout || `exit ${ran.exitCode}`).slice(0, 500)
37 return { ok: false, kind: "exit", detail }
38 }
39 return { ok: true, stdout: ran.stdout }
40 } catch (error) {
41 const detail = error instanceof Error ? error.message : String(error)
42 return { ok: false, kind: "missing", detail: detail.slice(0, 500) }
43 }
44}
45
46async function loadTasks(api: EngineInterface): Promise<{ ok: true; tasks: TaskView[] } | { ok: false; kind: "missing" | "exit"; detail: string }> {
47 const list = await runBrgr(api, ["brgr", "--json", "status"], STATUS_TIMEOUT_MS)
48 if (!list.ok) return list
49 let parsed: unknown
50 try {
51 parsed = JSON.parse(list.stdout)
52 } catch {
53 return { ok: false, kind: "exit", detail: "brgr status was not JSON" }
54 }
55 const listed = parseStatusList(parsed)
56 if (listed === null) return { ok: false, kind: "exit", detail: "brgr status had an unexpected shape" }
57 const nodesByTask: Record<string, { questions: number; outcome: string | null; harness: string; state: string } | undefined> = {}
58 for (const item of listed) {
59 const tree = await runBrgr(api, ["brgr", "--json", "status", item.id, "--tree"], STATUS_TIMEOUT_MS)
60 if (!tree.ok) return tree
61 let treeParsed: unknown
62 try {
63 treeParsed = JSON.parse(tree.stdout)
64 } catch {
65 return { ok: false, kind: "exit", detail: "brgr status --tree was not JSON" }
66 }
67 const nodes = parseTree(treeParsed)
68 if (nodes === null) return { ok: false, kind: "exit", detail: "brgr status --tree had an unexpected shape" }
69 nodesByTask[item.id] = nodes[item.id]
70 }
71 return { ok: true, tasks: joinTasks(listed, nodesByTask) }
72}
73
74async function readMemory(api: EngineInterface): Promise<Memory> {
75 try {
76 const stored = await api.store.get(STORE_KEY)
77 if (!isMemory(stored)) return emptyMemory()
78 return {
79 questions: { ...stored.questions },
80 results: { ...stored.results },
81 failure: stored.failure,
82 }
83 } catch {
84 return emptyMemory()
85 }
86}
87
88async function poll(api: EngineInterface): Promise<void> {
89 if (polling) return
90 polling = true
91 try {
92 const surfaces = await api.session.surfaces()
93 if (surfaces.length === 0) return
94 const loaded = await loadTasks(api)
95 const memory = await readMemory(api)
96 if (!loaded.ok) {
97 if (memory.failure !== loaded.kind) {
98 api.ui.toast(loaded.kind === "missing" ? MISSING_TOAST : STATUS_FAILED_TOAST)
99 memory.failure = loaded.kind
100 }
101 api.ui.status(undefined)
102 await api.store.set(STORE_KEY, memory)
103 return
104 }
105 for (const text of noteAnnouncements(loaded.tasks, memory)) api.ui.toast(text)
106 api.ui.status(summary(loaded.tasks))
107 await api.store.set(STORE_KEY, memory)
108 } catch {
109 // A timer that throws three times unloads the mod.
110 } finally {
111 polling = false
112 }
113}
114
115async function runTool(api: EngineInterface, event: unknown): Promise<{ result: unknown; text: string; isError?: true }> {
116 const parsed = readRunInput(event)
117 if (!parsed.ok) return { isError: true, result: parsed.text, text: parsed.text }
118 const argv = runArgv(parsed.input)
119 const ran = await runBrgr(api, argv, RUN_TIMEOUT_MS)
120 if (!ran.ok) return { isError: true, result: ran.detail, text: ran.detail }
121 try {
122 const value: unknown = JSON.parse(ran.stdout)
123 return { result: value, text: ran.stdout }
124 } catch {
125 return { isError: true, result: ran.stdout, text: ran.stdout }
126 }
127}
128
129export const register: Register = on => {
130 on("session.start", async ($, event, next) => {
131 try {
132 await $.command.register({
133 name: "brgr",
134 description: "List active brgr tasks. Read-only.",
135 })
136 await $.tool.register({
137 name: "brgr_run",
138 description:
139 "Start a brgr run and return its JSON. Always passes --mode auto. Does not accept, reject, apply, revise, or acknowledge.",
140 isDeferred: false,
141 inputSchema: {
142 type: "object",
143 additionalProperties: false,
144 required: ["objective", "harness", "criterion"],
145 properties: {
146 objective: { type: "string" },
147 harness: { type: "string" },
148 criterion: { type: "array", items: { type: "string" } },
149 model: { type: "string" },
150 capture_diff: { type: "boolean" },
151 },
152 },
153 })
154 if (event.isInteractive && event.surface !== null) {
155 pollTimer?.cancel()
156 pollTimer = $.clock.every(POLL_MS, () => {
157 void poll($)
158 })
159 }
160 } catch {
161 // Still let the session start when registration fails.
162 }
163 return next(event)
164 }).catch(($, event) => ({ cwd: event.cwd }))
165
166 on("command.run", { command: "brgr" }, async $ => {
167 const loaded = await loadTasks($)
168 if (!loaded.ok) return { text: loaded.kind === "missing" ? MISSING_TOAST : STATUS_FAILED_TOAST }
169 return { text: activeText(loaded.tasks) }
170 }).catch(() => ({ text: STATUS_FAILED_TOAST }))
171
172 on("tool.call", { tool: "mcp__brgr__brgr_run" }, async ($, event) => runTool($, event)).catch(() => ({
173 isError: true as const,
174 result: "brgr_run failed",
175 text: "brgr_run failed",
176 }))
177}
178hooks/status.ts 205 lines1// Pure reading of `brgr --json status` and `brgr --json status TASK --tree`.
2// The list is the owner's tasks (task_commands.rs). The tree row carries
3// unanswered_questions and outcome (tree_status.rs). The mod never decides.
4
5export const POLL_MS = 5000
6export const STATUS_TIMEOUT_MS = 3000
7export const RUN_TIMEOUT_MS = 10_000
8export const STORE_KEY = "announced"
9export const MISSING_TOAST = "brgr: the brgr executable is missing"
10export const STATUS_FAILED_TOAST = "brgr: brgr status failed"
11
12export type TaskView = {
13 id: string
14 state: string
15 harness: string
16 revision: number
17 questions: number
18 outcome: string | null
19}
20
21export type Memory = {
22 questions: Record<string, number>
23 results: Record<string, string>
24 failure: "missing" | "exit" | null
25}
26
27type Listed = {
28 id: string
29 state: string
30 harness: string
31 revision: number
32}
33
34type TreeNode = {
35 questions: number
36 outcome: string | null
37 harness: string
38 state: string
39}
40
41export function emptyMemory(): Memory {
42 return { questions: {}, results: {}, failure: null }
43}
44
45export function isMemory(value: unknown): value is Memory {
46 if (typeof value !== "object" || value === null) return false
47 const record = value as Record<string, unknown>
48 return (
49 typeof record.questions === "object" &&
50 record.questions !== null &&
51 typeof record.results === "object" &&
52 record.results !== null &&
53 (record.failure === null || record.failure === "missing" || record.failure === "exit")
54 )
55}
56
57function isRecord(value: unknown): value is Record<string, unknown> {
58 return typeof value === "object" && value !== null && !Array.isArray(value)
59}
60
61function stateText(value: unknown): string | null {
62 if (typeof value === "string" && value.length > 0) return value
63 if (value === null) return "unknown"
64 return null
65}
66
67export function parseStatusList(value: unknown): Listed[] | null {
68 if (!Array.isArray(value)) return null
69 const listed: Listed[] = []
70 for (const item of value) {
71 if (!isRecord(item) || !isRecord(item.task)) return null
72 const id = item.task.task_id
73 const state = stateText(item.state)
74 if (typeof id !== "string" || id.length === 0 || state === null) return null
75 const route = item.task.route
76 const harness = isRecord(route) && typeof route.harness_id === "string" ? route.harness_id : ""
77 const revision = typeof item.task.revision === "number" ? item.task.revision : 0
78 listed.push({ id, state, harness, revision })
79 }
80 return listed
81}
82
83export function parseTree(value: unknown): Record<string, TreeNode> | null {
84 if (!isRecord(value) || !Array.isArray(value.nodes)) return null
85 const nodes: Record<string, TreeNode> = {}
86 for (const node of value.nodes) {
87 if (!isRecord(node) || typeof node.task_id !== "string" || node.task_id.length === 0) return null
88 const questions = typeof node.unanswered_questions === "number" ? node.unanswered_questions : 0
89 const outcome = typeof node.outcome === "string" && node.outcome.length > 0 ? node.outcome : null
90 const harness = typeof node.harness === "string" ? node.harness : ""
91 const state = stateText(node.state) ?? ""
92 nodes[node.task_id] = { questions, outcome, harness, state }
93 }
94 return nodes
95}
96
97export function joinTasks(listed: readonly Listed[], trees: Readonly<Record<string, TreeNode | undefined>>): TaskView[] {
98 return listed.map(item => {
99 const node = trees[item.id]
100 return {
101 id: item.id,
102 state: node?.state || item.state,
103 harness: node?.harness || item.harness,
104 revision: item.revision,
105 questions: node?.questions ?? 0,
106 outcome: node?.outcome ?? null,
107 }
108 })
109}
110
111export function bucket(task: TaskView): "running" | "asking" | "done" {
112 if (task.state === "terminal") return "done"
113 if (task.questions > 0) return "asking"
114 return "running"
115}
116
117export function summary(tasks: readonly TaskView[]): string | undefined {
118 if (tasks.length === 0) return undefined
119 let running = 0
120 let asking = 0
121 let done = 0
122 for (const task of tasks) {
123 const kind = bucket(task)
124 if (kind === "running") running += 1
125 else if (kind === "asking") asking += 1
126 else done += 1
127 }
128 return `brgr: ${running} running · ${asking} asking · ${done} done`
129}
130
131export function id8(id: string): string {
132 return id.slice(0, 8)
133}
134
135export function activeText(tasks: readonly TaskView[]): string {
136 const lines = tasks
137 .filter(task => task.state !== "terminal")
138 .map(task => `${id8(task.id)} ${task.state} ${task.harness}`)
139 return lines.length === 0 ? "brgr: no active tasks" : lines.join("\n")
140}
141
142export function noteAnnouncements(tasks: readonly TaskView[], memory: Memory): string[] {
143 const toasts: string[] = []
144 for (const task of tasks) {
145 const seen = memory.questions[task.id] ?? 0
146 for (let count = seen; count < task.questions; count += 1) {
147 toasts.push(`brgr: ${id8(task.id)} asks you`)
148 }
149 memory.questions[task.id] = task.questions
150 if (task.outcome !== null) {
151 const key = `${task.revision}:${task.outcome}`
152 if (memory.results[task.id] !== key) {
153 toasts.push(`brgr: ${id8(task.id)} finished: ${task.outcome}`)
154 memory.results[task.id] = key
155 }
156 }
157 }
158 memory.failure = null
159 return toasts
160}
161
162export type RunInput = {
163 objective: string
164 harness: string
165 criterion: string[]
166 model?: string
167 captureDiff: boolean
168}
169
170export function readRunInput(value: unknown): { ok: true; input: RunInput } | { ok: false; text: string } {
171 if (!isRecord(value)) return { ok: false, text: "brgr_run input must be an object" }
172 const objective = value.objective
173 const harness = value.harness
174 const criterion = value.criterion
175 if (typeof objective !== "string" || objective.trim().length === 0) {
176 return { ok: false, text: "objective must be a string" }
177 }
178 if (typeof harness !== "string" || harness.trim().length === 0) {
179 return { ok: false, text: "harness must be a string" }
180 }
181 if (!Array.isArray(criterion) || criterion.some(item => typeof item !== "string" || item.trim().length === 0)) {
182 return { ok: false, text: "criterion must be a list of strings" }
183 }
184 const model = typeof value.model === "string" && value.model.trim().length > 0 ? value.model : undefined
185 return {
186 ok: true,
187 input: {
188 objective,
189 harness,
190 criterion: criterion.filter((item): item is string => typeof item === "string"),
191 model,
192 captureDiff: value.capture_diff === true,
193 },
194 }
195}
196
197export function runArgv(input: RunInput): string[] {
198 const argv = ["brgr", "--json", "run", input.objective, "--harness", input.harness]
199 for (const criterion of input.criterion) argv.push("--criterion", criterion)
200 if (input.model !== undefined) argv.push("--model", input.model)
201 if (input.captureDiff) argv.push("--capture-diff")
202 argv.push("--mode", "auto")
203 return argv
204}
205