Run worker agents (opencode, codex, claude) in herdr tabs with any provider; their reports come back to this session.

Run a team of coding agents from one Claude Code session. The coordinator plans the work, starts workers (Claude, opencode, codex) in herdr tabs, and their reports come back to it as prompts. You answer the questions only you can answer from a panel above the prompt.

Install it from the plugin marketplace, at a Claude Code prompt (details under Install):
/plugin install herdr-agents --marketplace contursofran/claude-herdr-orchestrator
The coordinator starts each worker in its own herdr tab, usually in its own git worktree, with a profile: the built-in planner, builder, reviewer and debugger, or your own on Claude, opencode or codex (smallTasks sends the small ones to a cheap model). A worker's first-run "trust this folder?" prompt is answered for it. Each worker's tab gets HERDR_AGENTS_ROLE=worker; the mod also loads in a Claude worker's own session, which runs none of its timers; for the workers it started, it sweeps and checks their reports only while one of them runs.
When a worker finishes, its RESULT.md comes back to the coordinator as a prompt; no polling, no copying. It shows in the chat as one dim line (ctrl+o for the full report). Workers start RESULT.md with a short header (status, PR, a 3-line summary); the coordinator gets the summary and the file's path, and reads the rest only when it needs to. Reports without a header are shortened to a pointer when long. herdr tells the mod the moment a worker ends its turn (herdr agent wait), and a RESULT.md is delivered within 3 seconds of landing, even while its worker is still finishing its turn (a worker with messages held for it waits for the end of its turn). A sweep each minute, with one herdr agent list for all workers, catches anything missed; a single failed herdr call never ends a job. Reports, your answers and ticked to-dos that arrive within a few seconds, or while the coordinator is busy, reach it as one prompt.
What only you can do (test a build, merge a PR, restart something) is pinned at the top of the panel, so it doesn't scroll away in a report. Tick Done and the coordinator carries on from it; it unpins what is no longer needed.

Questions that are yours (a product rule, a design choice, an approval) wait on the panel with their options and the recommended one starred, and herdr pings you. Answer with 1–4 from an empty prompt, with alt+d and the arrows, or with a click; o gives your own answer. A #12 links to that issue or PR.

Workers ask with DECISION: lines. The coordinator settles the ones that aren't yours, and the panel shows each answer with a ✓ for ten minutes, so you can overrule it.
Role, time and state for each worker: working, waiting on you, done. Four fit in the compact view; Details shows them all. A worker waiting on CI or a deploy leaves a WAIT.json with a check to run; the mod runs it and wakes the worker when it passes, with no model running meanwhile (⏸ and what it waits for).

/hrd-side docks the same things beside the transcript, in grey and white. At the top are the issues, one row per issue or PR. The planner, the builders and fix rounds, and the reviewer of the PR a builder linked share that row; the same number in two repos is two rows. A row whose work joins another row (a review that started before the builder's report named its PR) says so in its slot until clear done. A row's number opens its issue or PR on GitHub. Each row has a mark for plan, build, review, CI and you: ✓ done, ◐ running, ⏸ waiting, · not started, ? or ☐ waiting on you, ■ blocked. CI comes from the PR's checks, read with gh every two minutes while the sidebar is open. What needs you sits at the bottom, level with the prompt, over a box of fixed height for the item you pick; with nothing waiting it stays empty. For a question the box shows the answers, the recommended one first. For a to-do it shows Done and any link in the text.
The sidebar's background is Claude Code's composerSidebarBackground theme color. To match a terminal's background, add a custom theme in ~/.claude/themes/ (for example { "name": "Dark", "base": "dark", "overrides": { "composerSidebarBackground": "#101010" } }) and pick it with /theme. The line between the chat and the sidebar is Claude Code's resize grip.
Rows never move on their own. An answered or finished row stays in its slot, struck through, until you press clear done. A new one takes the next free slot. Answering swaps the box's contents in place, and the box stays on the answer until you press next. While the sidebar shows, the panel above the prompt steps aside. To start every session with the sidebar instead of the panel, set Show workers in to the sidebar in /hrd-config ("view": "side").
The panel shows the coordinator's context (ctx 75k / 200k); the sidebar shows it as a share of that limit (ctx 75k 38%). Past resetAt (200k tokens) the coordinator writes its state to COORDINATOR-STATE.md and is compacted, then reads it back before the next report, so a session can run all day.
Planner, then builder, then reviewer; each fix round goes to a fresh builder, and the debugger takes over after two failed rounds. The coordinator is reminded when fewer than target workers run.
/hrd-wrap-up, or the panel's Wrap up button, starts no new workers and lets the running ones finish; herdr notifies you when the last one is done. Worktrees whose PR merged are removed every half hour, with their tabs and branches. A worker's tab closes once it has sat idle half an hour after its report, unless it has a question open; a follow-up after that goes to a fresh worker.
/hrd-config lays the settings out above the prompt, like Claude Code's /config: for all folders or just this one.

What the sidebar shows is also kept in ~/.herdr-agents/board/<session id>.json, rewritten when it changes and removed when the session ends, so an app outside Claude Code (such as contr, a native herdr client) can draw it. The app presses the sidebar's buttons by dropping a JSON file in <session id>.actions/ beside it: answer a decision, tick or dismiss a to-do, clear done, wrap up or keep going. The coordinator gets the same prompts as from a click in the sidebar. The format is in docs/board-schema.json.
herdr-agents (the mod): the spawn, message and stop tools for workers, ask_owner, decided, pin_for_owner and unpin_for_owner for the panel, and everything above.herdr-agents-view (view/): draws worker reports as one dim line. It has to be a separate mod, because Claude Code never raises a mod's own ui.render hooks for the prompts that mod submits.agents/, shipped with the mod as herdr-agents:planner, herdr-agents:builder and herdr-agents:reviewer): planner (Opus high), builder (Haiku xhigh) and reviewer (Opus high); the debugger profile is Opus with full tools. They hold no repo rules of their own: they read the repo's AGENTS.md/CLAUDE.md and its .claude/skills checklists.You need herdr and Claude Code. At the prompt of a Claude Code session:
/plugin install herdr-agents --marketplace contursofran/claude-herdr-orchestrator
/plugin install herdr-agents-view@claude-herdr-orchestrator
Answer y to add the marketplace, then pick the user scope. The first installs the mod and its planner, builder and reviewer subagents; the second draws worker reports as one line. Start new sessions inside herdr to use it.
The first time the coordinator uses one of the mod's tools (spawn, ask_owner, …), Claude Code asks; choose "don't ask again", or allow them all at once in ~/.claude/settings.json:
{ "permissions": { "allow": ["mcp__herdr-agents"] } }
Optional: bind alt+d to move into the panel (ctrl+x tab works without it) in ~/.claude/keybindings.json:
{ "bindings": [{ "context": "Chat", "bindings": { "alt+d": "abovePrompt:focus" } }] }
Each release bumps the version in plugin.json. Run claude plugin update herdr-agents@claude-herdr-orchestrator (and the same for herdr-agents-view), or update them from /plugin, then start a new session. Watch the repository's releases on GitHub to hear about new versions.
templates/AGENTS.md to the repo's AGENTS.md (or CLAUDE.md) and fill it in: base branch, checks, how to update a branch, commit identity, who merges, what needs you. Every worker reads it, so this is where repo-specific rules go, not the subagents..claude/herdr-agents.json in the folder you coordinate from overrides ~/.claude/herdr-agents.json for that folder, e.g. {"target": 2}.claude --model opus --effort medium, then /hrd-orchestrate <goal>, e.g. /hrd-orchestrate the issues labelled ready. The session gets the coordinator playbook (state file, worktree per worker, planner → builder → reviewer, when to ask you) and starts workers./hrd-wrap-up (or the panel's Wrap up button) starts no new workers and lets the running ones finish; you get a herdr notification when the last one is done. /hrd-wrap-up off (Keep going) resumes.One coordinator per folder: its notes live in COORDINATOR-STATE.md there, and wrap-up applies to the coordinators of that folder.
All start with hrd- (commandPrefix changes it).
| Command | What it does |
|---|---|
/hrd-orchestrate <goal> | Make this session the coordinator, with the playbook and a goal. |
/hrd-wrap-up, /hrd-wrap-up off | Stop starting workers and let the running ones finish; resume. |
/hrd-side | Open or close the sidebar beside the chat. ctrl+x tab moves into it; there Tab walks its buttons and Enter presses one. It draws no hotkeys, so it stays grey and white. |
/hrd-decisions | List the questions and to-dos waiting for you. Answer on the panel: 1–4 from an empty prompt, or alt+d then arrows and Enter. |
/hrd-workers | List this session's workers. |
/hrd-prune | Remove worktrees whose PR is merged (also runs every pruneMinutes). |
/hrd-update | Pull the clone this mod was installed from (git pull --ff-only), then run its install.sh again. It answers with the commits and version; running sessions reload the new version by themselves. |
/hrd-config | The settings, laid out like /config, in the panel above the prompt: alt+d to move into them, ↑↓ to move, Enter to change (a switch flips, a choice steps on, a text opens a field), Done or /hrd-config again to close. The first row picks the file: all folders or this folder. Profiles are edited in the file. |
/hrd-help, /hrd-h | A short guide: the commands, the panel's keys, and the profiles and settings in effect for this folder. |
Change them with /hrd-config, or by hand in ~/.claude/herdr-agents.json (in $CLAUDE_CONFIG_DIR when set), with <folder>/.claude/herdr-agents.json over it (profiles merge by name):
| Key | Default | Meaning |
|---|---|---|
target | 4 | Workers the coordinator keeps busy. |
resetAt | 200000 | Context tokens past which a coordinator saves its state and is compacted. |
statePath | COORDINATOR-STATE.md | The state file, relative to the folder (or absolute, or ~/…). |
trustRoots | the folder and its parent | Folders where a worker's first-run "trust this folder?" prompt is answered for it. |
commandPrefix | hrd- | What every slash command starts with. Read when the session starts. |
profiles | see below | Worker profiles by name; one here replaces the built-in of that name. |
disable | none | Built-in profiles to turn off: any of planner (the builder plans first), reviewer (the coordinator reviews the PR itself), debugger (after two failed fix rounds the coordinator asks you). The builder always runs. |
smallTasks | none | A profile the coordinator may give small, well-defined tasks, e.g. "deepseek". |
playbook | none | A line, or a list of lines, added to the coordinator playbook, e.g. "Label every PR with the issue's area." |
pruneMinutes | 30 | How often merged worktrees are pruned; 0 turns it off (/hrd-prune still works). |
closeIdleMinutes | 30 | How long a worker's tab stays open, idle, after its report; 0 keeps it open. |
repos | from git remotes | Short names for #123 links, e.g. {"web": "owner/website"}. The repos of the session's and workers' folders are known already, by name and by the part after the last dash (acme-client, client). |
wrapUp | off | true wraps up every coordinator on the machine; /hrd-wrap-up off clears it. |
view | band | Where the workers and what needs you show: band above the prompt or side, the sidebar, opened at the start of each session. Only one shows; /hrd-side switches for the session. |
Built-in profiles, the playbook's steps: planner, builder, reviewer (the subagents above) and debugger (Opus medium, full tools, for root-cause hunts and fixes that failed twice). A profile of the same name under profiles changes its model or flags and keeps its place in the playbook; /hrd-help marks it customized. Any other name is a profile of your own. A profile is {"kind": "claude" | "codex" | "opencode", "args": [...], "env": {...}, "note": "..."}; kind is the CLI herdr starts and args its flags. Other CLIs or models are a profile away:
{
"profiles": {
"deepseek": {
"kind": "opencode",
"args": ["--standalone"],
"env": { "OPENCODE_CONFIG_CONTENT": "{\"model\": \"opencode-go/deepseek-v4.1-flash\"}" },
"note": "opencode DeepSeek V4.1 Flash"
},
"codex": { "kind": "codex", "args": ["-m", "gpt-6.1-sol", "--approve-for-me"], "note": "codex GPT-6.1 Sol" }
},
"smallTasks": "deepseek",
"disable": ["debugger"]
}
To run the mod from a clone instead of the marketplace (uninstall the marketplace copy first: two plugins of one name don't load):
git clone git@github.com:contursofran/claude-herdr-orchestrator.git
cd claude-herdr-orchestrator && npm install
./install.sh && npm run types # once Claude Code has loaded the mod: the plugin API's types
npm run check # formatting, type check, both manifests, tests
install.sh copies both mods to ~/.claude/mods/, creates ~/.claude/herdr-agents.json if missing, and adds to ~/.claude/settings.json and keybindings.json what loads the mods (CLAUDE_CODE_PLUGIN_DIRS), allows their tools and binds alt+d. After the first install, start new Claude Code sessions: /reload-plugins does not pick up new plugin folders. From then on, running sessions reload each update by themselves, and /hrd-update pulls and installs from inside a session.
npm run check runs format:check, typecheck, validate and test, each also on its own; run it before opening a pull request. CI runs the same on every pull request except typecheck, which needs the API types a local Claude Code writes. main takes changes only through pull requests, and only once CI passes. After a change, run ./install.sh again and bump version in .claude-plugin/plugin.json so running sessions reload the mod and marketplace users get the update. For a version worth announcing, tag it and publish a GitHub release saying what changed (gh release create v<version>).
hooks/register.tsx wires the engine's events to the modules beside it:
| Module | What it does |
|---|---|
config.ts | The settings files, the built-in profiles, the state file's path. |
defaults.ts | Default settings and tuning values. |
workers.ts | spawn, message, stop: starting workers, watching them, delivering RESULT.md, WAIT.json waits. |
report.ts | The report header workers write at the top of RESULT.md, and its parsing. |
owner.ts | Questions (ask_owner, DECISION: lines) and to-dos (pin_for_owner) waiting for the person. |
outbox.ts | Holds the coordinator's prompts and sends the ones that arrive together as one. |
coordinator.ts | The playbook, the worker target, wrap-up, and compacting the coordinator's context. |
target.ts | The line after a report or a stop: how many workers are still to start, or that wrap-up holds new ones. |
board.ts, band.tsx | The panel above the prompt: what it shows, and drawing it. |
side.tsx, tasks.ts | /hrd-side: the sidebar, and grouping workers into one row per issue or PR. |
links.ts | #123 links to the right repo, for the panel and the sidebar. |
settings.tsx | /hrd-config. |
commands.ts, tools.ts | The slash commands and the coordinator's tools. |
worktrees.ts, herdr.ts | Pruning merged worktrees; the herdr CLI. |
Each module keeps its own state (atoms in $.state) and offers functions to the others. Tests live in tests/, one file per area over a shared harness.tsx.
Ideas, issues and pull requests are welcome. Run npm run check before opening a PR.
MIT; see LICENSE.
hooks/register.tsx 167 lines1import type { Register } from 'claude-code'
2import { drawBand, shortenBrief } from './band'
3import { refresh, showContext } from './board'
4import { registerCommands, runCommand } from './commands'
5import { publishBoard, removeBoard } from './bridge'
6import { claimState, config, resetSettings } from './config'
7import { maybeReset } from './coordinator'
8import { CLOSE_IDLE_MINUTES, PR_POLL_MS, PRUNE_MINUTES, SWEEP_MS } from './defaults'
9import { restoreOutbox, retry, turnEnded, turnStarted } from './outbox'
10import { askOwner, noteReports, pinForOwner, pingAfterReports, settle, unpinForOwner } from './owner'
11import type { AskArgs, DecidedArgs, PinArgs } from './owner'
12import { session } from './session'
13import { drawSettings, trackFocus } from './settings'
14import { drawSide, pollPrs, SIDE, sideClosed, sideOpen, sideShown, syncSide, toggleSide } from './side'
15import { jobs, restoreJobs } from './state'
16import { registerTools } from './tools'
17import { checkReports, messageWorker, resumeWatching, spawnWorker, stopWorker, sweep } from './workers'
18import type { SpawnArgs } from './workers'
19import { pruneMerged } from './worktrees'
20
21// herdr-agents: a coordinator session starts worker agents in herdr tabs, and their
22// reports come back to it as prompts. This file only wires the engine's events to
23// the modules beside it.
24
25export const register: Register = on => {
26 on('session.start', async ($, e, next) => {
27 const started = await next(e)
28 // The mod loads in every worker's own Claude session too (same plugin dirs); spawn marks
29 // those tabs, and they run none of the coordinator's timers, only the sweep and the report
30 // check for the workers they started while one of them runs.
31 const isWorker = (await $.env.get('HERDR_AGENTS_ROLE')) === 'worker'
32 session.cwd = started.cwd
33 const settings = await config($)
34 if (typeof settings.commandPrefix === 'string') session.prefix = settings.commandPrefix
35 await registerTools($)
36 await registerCommands($)
37 const restored = await restoreJobs($)
38 if (restored > 0) $.ui.toast(`herdr-agents: ${restored} workers restored after a reload`)
39 const closeMinutes = settings.closeIdleMinutes ?? CLOSE_IDLE_MINUTES
40 resumeWatching($, Object.values(await jobs($)))
41 await restoreOutbox($)
42 if (isWorker) {
43 // A worker's own session sweeps the workers it started, but only while one of them runs:
44 // with no running job it makes no herdr call. Its report check does nothing with no running
45 // job either. The other timers stay with the coordinator.
46 const sweepIfRunning = async () => {
47 if (Object.values(await jobs($)).some(j => j.status === 'running')) await sweep($, closeMinutes)
48 }
49 void sweepIfRunning().catch(() => {})
50 $.clock.every(SWEEP_MS, () => {
51 void sweepIfRunning().catch(() => {})
52 retry($)
53 })
54 $.clock.every(3000, () => void checkReports($).catch(() => {}))
55 return started
56 }
57 void sweep($, closeMinutes).catch(() => {})
58 // view "side": the sidebar instead of the band, opened once there is room for it.
59 // A reload finds it open already.
60 if (settings.view === 'side' && !(await sideOpen($))) void toggleSide($).catch(() => {})
61 // The band, the sidebar and the bridge's inbox, every 3 s with no herdr calls: refresh
62 // reads the states the waits and the sweep last saw. The report check comes first, so a
63 // RESULT.md that landed is delivered at this tick.
64 $.clock.every(
65 3000,
66 () =>
67 void checkReports($)
68 .catch(() => {})
69 .then(() => refresh($))
70 .then(() => syncSide($))
71 .then(() => publishBoard($))
72 .catch(() => {}),
73 )
74 $.clock.every(PR_POLL_MS, () => void pollPrs($).catch(() => {}))
75 const pruneMinutes = settings.pruneMinutes ?? PRUNE_MINUTES
76 if (pruneMinutes > 0) $.clock.every(pruneMinutes * 60_000, () => void pruneMerged($))
77 // The sweep always runs: it is what catches a worker whose wait child died. Only its
78 // closeIdle step depends on closeIdleMinutes.
79 $.clock.every(SWEEP_MS, () => {
80 void sweep($, closeMinutes).catch(() => {})
81 retry($)
82 })
83 return started
84 })
85 on('session.end', async ($, e, next) => {
86 await removeBoard($).catch(() => {})
87 return next(e)
88 })
89
90 on('tool.call', { tool: 'mcp__herdr-agents__spawn' }, ($, e) => spawnWorker($, e as unknown as SpawnArgs))
91 on('tool.call', { tool: 'mcp__herdr-agents__message' }, ($, e) => messageWorker($, e as unknown as { worker: string; text: string }))
92 on('tool.call', { tool: 'mcp__herdr-agents__stop' }, ($, e) => stopWorker($, e as unknown as { worker: string }))
93 on('tool.call', { tool: 'mcp__herdr-agents__ask_owner' }, ($, e) => askOwner($, e as unknown as AskArgs))
94 on('tool.call', { tool: 'mcp__herdr-agents__decided' }, ($, e) => settle($, e as unknown as DecidedArgs))
95 on('tool.call', { tool: 'mcp__herdr-agents__pin_for_owner' }, ($, e) => pinForOwner($, e as unknown as PinArgs))
96 on('tool.call', { tool: 'mcp__herdr-agents__unpin_for_owner' }, ($, e) => unpinForOwner($, e as unknown as { ids?: string[] }))
97
98 // The session that writes the shared state file owns it (see resetSettings).
99 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
100 const out = await next(e)
101 await claimState($, e.file_path)
102 return out
103 })
104 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
105 const out = await next(e)
106 await claimState($, e.file_path)
107 return out
108 })
109
110 // A worker's DECISION: questions ping the person once the coordinator's turn
111 // reading that report ends, unless it settled them itself. The prompts held in the
112 // outbox (see outbox.ts) wait for the turn's end too.
113 on('turn.start', async ($, e, next) => {
114 if (e.agentId === undefined) {
115 noteReports(e.turnId, e.text)
116 turnStarted()
117 }
118 return next(e)
119 })
120 on('turn.complete', async ($, e, next) => {
121 const out = await next(e)
122 if (e.agentId === undefined) {
123 // turnEnded runs even when the ping throws: a busy flag left set would hold the outbox until a turn ends.
124 try {
125 await pingAfterReports($, e.turnId)
126 } finally {
127 await turnEnded($)
128 }
129 }
130 return out
131 })
132
133 // After each turn: show the context fill on the band and reset it past the limit.
134 on('session.measure', async ($, e, next) => {
135 const out = await next(e)
136 const tokens = e.context.tokens
137 if (tokens !== undefined) {
138 const { limit } = await resetSettings($)
139 await showContext($, tokens, limit)
140 void maybeReset($, tokens)
141 }
142 return out
143 })
144
145 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => next(shortenBrief(e)))
146
147 // The band above the prompt: the settings while /hrd-config has them open, else
148 // what waits for the person and the workers, unless the sidebar shows them.
149 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
150 session.bandId = e.requestId
151 return (await drawSettings($, e)) ?? ((await sideShown($)) ? next(e) : drawBand($, e, next))
152 })
153
154 // The sidebar (/hrd-side).
155 on('ui.render', { component: 'Pane', requestId: SIDE }, ($, e) => drawSide($, e))
156 on('ui.close', async ($, e, next) => {
157 if (e.id === SIDE) await sideClosed($)
158 return next(e)
159 })
160 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
161 await trackFocus($, e.element)
162 return next(e)
163 })
164
165 on('command.run', async ($, e, next) => (await runCommand($, e.command, e.args)) ?? next(e))
166}
167hooks/band.tsx 343 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderElement, RenderInput } from 'claude-code'
3import type { Row, View } from '../types'
4import { readBoard } from './board'
5import { toggleWrapUp } from './coordinator'
6import { SETTLED_MS, TODO_ROWS, WORKER_CELL, WORKER_LINES } from './defaults'
7import { ago, elapsed } from './format'
8import { linker } from './links'
9import { answered, choose, decisionTitle, openQuestions, pinnedItems, tick } from './owner'
10import { cmd } from './session'
11
12// Which decision the band shows, and whether Details lists every worker.
13const view = atom({ plugin: 'herdr-agents', key: 'view' } as const, { tab: 'workers', selected: null } as View)
14
15export type BandRender = RenderInput<'AbovePrompt'>
16export type ToolUseRender = RenderInput<'ToolUse'>
17
18// The band above the prompt. Wide: decisions listed on the left, the selected one on
19// the right with its answers, workers below. Under 120 columns the list folds into a
20// strip of titles above the question. Digits answer from an empty prompt; ctrl+x tab
21// lands on the recommended answer, so Enter picks it.
22export async function drawBand($: EngineInterface, e: BandRender, next: (e: BandRender) => Promise<RenderElement>): Promise<RenderElement> {
23 const { now, rows, context, target: want, wrapUp } = await readBoard($)
24 const open = await openQuestions($)
25 const { selected, workers: workerView } = await read($, view)
26 const items = await pinnedItems($)
27 const clock = await $.clock.now()
28 const recent = (await answered($)).filter(x => clock - x.at < SETTLED_MS)
29 if (e.props.hasSurvey || (rows.length === 0 && open.length === 0 && recent.length === 0 && items.length === 0)) return next(e)
30
31 const { Box, Button, Link, Text } = $.ui.resolve(e)
32 const { linkify, repoOf } = await linker($, rows, (href, text, key) => (
33 <Link key={key} href={href}>
34 {text}
35 </Link>
36 ))
37 // bodyColumns is the band's box; the border and padding take 4 of it.
38 const cols = Math.max(20, (e.props.bodyColumns ?? 100) - 4)
39 const isWide = cols >= 116
40 const rule = <Text color="subtle">{'─'.repeat(cols)}</Text>
41 const short = (from: string) => from.replace(/\s*\(.*$/, '').slice(0, 22)
42 const pick = (id: number) => update($, view, v => ({ ...v, selected: id }))
43
44 const waiting = new Map<string, string[]>()
45 for (const d of open) waiting.set(d.from, [...(waiting.get(d.from) ?? []), `D${d.id}`])
46 const rank = (r: Row) =>
47 waiting.has(r.name) ? 0 : r.state === 'blocked' || r.state === 'gone' ? 1 : r.state === 'working' ? 2 : r.state === 'done' ? 3 : 4
48 const sorted = [...rows].sort((x, y) => rank(x) - rank(y))
49 const look: Record<string, { label: string; color?: string; dim?: boolean }> = {
50 working: { label: 'Working', color: 'success' },
51 waiting: { label: 'Waiting', dim: true },
52 blocked: { label: 'Blocked · answer it in its tab', color: 'error' },
53 idle: { label: 'Idle', dim: true },
54 done: { label: 'Done · report pending', color: 'success' },
55 reported: { label: 'Reported', dim: true },
56 gone: { label: 'Tab gone', color: 'error' },
57 }
58 const width = Math.max(0, ...rows.map(r => r.name.length)) + 3
59 const running = rows.filter(r => r.state !== 'reported' && r.state !== 'waiting').length
60 // While wrapping up, every worker not yet reported counts, waiting ones too.
61 const left = rows.filter(r => r.state !== 'reported').length
62
63 // Compact (the default) shows the first WORKER_LINES lines of workers; Details shows them all.
64 const isList = workerView === 'list'
65 const toggleList = () => update($, view, ({ workers, ...v }) => (isList ? v : { ...v, workers: 'list' as const }))
66
67 // Two per line when the band is wide enough. Sorted, so the ones that need the person come first.
68 const perLine = cols >= 2 * WORKER_CELL + 3 ? 2 : 1
69 const cellWidth = perLine === 2 ? Math.floor((cols - 3) / 2) : cols
70 const onPage = isList ? sorted : sorted.slice(0, perLine * WORKER_LINES)
71 const hidden = sorted.length - onPage.length
72 const lines = Math.ceil(onPage.length / perLine)
73 const columns = perLine === 2 ? [onPage.slice(0, lines), onPage.slice(lines)] : [onPage]
74 // The header's mix of roles among the workers it counts, e.g. "3 builders · 1 reviewer".
75 const roles = new Map<string, number>()
76 for (const r of rows) if (r.state !== 'reported' && r.state !== 'waiting') roles.set(r.profile, (roles.get(r.profile) ?? 0) + 1)
77 const mix = [...roles]
78 .sort((x, y) => y[1] - x[1])
79 .map(([p, n]) => `${n} ${n > 1 && /er$/.test(p) ? `${p}s` : p}`)
80 .join(' · ')
81 const parked = rows.filter(r => r.state === 'waiting').length
82 const pwidth = Math.max(0, ...rows.map(r => r.profile.length)) + 2
83 const cell = (r: Row) => {
84 const ids = waiting.get(r.name)
85 const l = ids
86 ? { label: `Waiting for ${ids.join(', ')}`, color: 'claude', dim: false }
87 : r.state === 'waiting'
88 ? { label: `⏸ ${r.note ?? 'Waiting'}`, dim: true }
89 : (look[r.state] ?? { label: r.state, dim: true })
90 return (
91 <Text key={r.name} wrap="truncate-end">
92 <Text color={l.color} dimColor={l.dim}>
93 {'● '}
94 </Text>
95 <Text dimColor={l.dim}>{r.name.padEnd(width)}</Text>
96 <Text dimColor>{`${r.profile.padEnd(pwidth)}${elapsed((r.endedAt ?? now) - r.startedAt).padEnd(8)}`}</Text>
97 <Text color={ids ? 'claude' : undefined} dimColor={l.dim || !ids}>
98 {linkify(l.label, r.repo)}
99 </Text>
100 </Text>
101 )
102 }
103
104 // The person's to-dos and news (pin_for_owner), at the top.
105 const forYou = items.length ? (
106 <Box flexDirection="column">
107 {items.slice(0, TODO_ROWS).map(t => (
108 <Box key={`t${t.id}`} justifyContent="space-between" columnGap={2}>
109 <Text>
110 <Text color={t.kind === 'do' ? 'claude' : undefined} dimColor={t.kind === 'know'}>
111 {t.kind === 'do' ? '☐ ' : 'ℹ '}
112 </Text>
113 <Text bold={t.kind === 'do'}>{linkify(t.text, repoOf(t.from))}</Text>
114 <Text dimColor>
115 {` T${t.id} · `}
116 {linkify(short(t.from))}
117 {` · ${ago(clock - t.at)}`}
118 </Text>
119 </Text>
120 <Button key={`tick-t${t.id}`} plain dimColor onPress={() => tick($, t)}>
121 {t.kind === 'do' ? 'Done' : 'Dismiss'}
122 </Button>
123 </Box>
124 ))}
125 {items.length > TODO_ROWS ? <Text dimColor>{` +${items.length - TODO_ROWS} more · ${cmd('decisions')} lists them all`}</Text> : null}
126 </Box>
127 ) : null
128
129 const index = Math.max(
130 0,
131 open.findIndex(d => d.id === selected),
132 )
133 const shown = open[index]
134 // The recommended answer is always 1.
135 const options = shown
136 ? (shown.options ?? [])
137 .map((label, i) => ({ label, isRec: i === shown.recommended }))
138 .sort((x, y) => Number(y.isRec) - Number(x.isRec))
139 .slice(0, 4)
140 : []
141 const ctxColor = context
142 ? context.tokens >= context.limit * 0.95
143 ? 'error'
144 : context.tokens >= context.limit * 0.8
145 ? 'warning'
146 : undefined
147 : undefined
148
149 const detail = shown ? (
150 <Box flexDirection="column" flexGrow={1}>
151 <Box justifyContent="space-between">
152 <Text>
153 <Text bold color="claude">{`D${shown.id} `}</Text>
154 <Text dimColor>{linkify(shown.from)}</Text>
155 </Text>
156 <Text dimColor>{`${ago(now - shown.at)}${ago(now - shown.at) === 'now' ? '' : ' ago'}`}</Text>
157 </Box>
158 <Box marginTop={1}>
159 <Text bold>{linkify(shown.question, repoOf(shown.from))}</Text>
160 </Box>
161 <Box flexWrap="wrap" columnGap={4} marginTop={1}>
162 {options.map((o, i) => (
163 <Button
164 key={`d${shown.id}-o${i}`}
165 plain
166 hotkey={String(i + 1)}
167 variant={o.isRec ? 'primary' : undefined}
168 autoFocus={i === 0 ? true : undefined}
169 onPress={() => choose($, shown, o.label)}
170 >
171 {o.isRec ? <Text bold color="claude">{`★ ${o.label} (recommended)`}</Text> : o.label}
172 </Button>
173 ))}
174 </Box>
175 <Box columnGap={4} marginTop={options.length ? 1 : 0}>
176 <Button
177 key={`d${shown.id}-other`}
178 plain
179 hotkey="o"
180 dimColor
181 autoFocus={options.length === 0 ? true : undefined}
182 onPress={() => $.prompt.fill({ text: `D${shown.id}: `, mode: 'append' })}
183 >
184 {options.length ? 'Other answer' : 'Answer in chat'}
185 </Button>
186 {open.length > 1 ? (
187 <Button key="prev" plain hotkey="p" dimColor onPress={() => pick(open[(index - 1 + open.length) % open.length]!.id)}>
188 Previous
189 </Button>
190 ) : null}
191 {open.length > 1 ? (
192 <Button key="next" plain hotkey="n" dimColor onPress={() => pick(open[(index + 1) % open.length]!.id)}>
193 Next
194 </Button>
195 ) : null}
196 </Box>
197 </Box>
198 ) : null
199
200 // Wide: one two-line entry per decision, the selected one marked.
201 const list = (
202 <Box flexDirection="column" width={34} flexShrink={0}>
203 <Text dimColor>Decisions</Text>
204 {open.slice(0, 6).map(d => {
205 const isOn = d === shown
206 return (
207 <Button key={`pick-d${d.id}`} plain onPress={() => pick(d.id)}>
208 <Text bold={isOn} color={isOn ? 'claude' : undefined} dimColor={!isOn}>{`${isOn ? '▸' : ' '} D${d.id} `}</Text>
209 <Text bold={isOn}>{decisionTitle(d).slice(0, 24)}</Text>
210 {'\n'}
211 <Text dimColor>{` ${short(d.from)} · ${ago(now - d.at)}`}</Text>
212 </Button>
213 )
214 })}
215 {open.length > 6 ? <Text dimColor>{` +${open.length - 6} more · n for next`}</Text> : null}
216 </Box>
217 )
218
219 // Narrow: the titles as a strip.
220 const strip = (
221 <Box flexWrap="wrap" columnGap={3} marginBottom={1}>
222 {open.map(d => (
223 <Button key={`pick-d${d.id}`} plain dimColor={d !== shown} onPress={() => pick(d.id)}>
224 <Text bold={d === shown} inverse={d === shown} color={d === shown ? 'claude' : undefined}>
225 {` D${d.id} ${decisionTitle(d).slice(0, 18)} `}
226 </Text>
227 </Button>
228 ))}
229 </Box>
230 )
231
232 // The right column's height: header, gap, the wrapped question, gap, answers, gap, actions.
233 const detailWidth = Math.max(20, cols - 34 - 5)
234 const detailLines = shown ? 3 + Math.ceil(shown.question.length / detailWidth) + (options.length ? 2 : 0) + 1 : 0
235 const divider = shown ? (
236 <Box flexDirection="column" flexShrink={0} marginX={2}>
237 <Text color="subtle">{Array.from({ length: Math.max(Math.min(open.length, 6) * 2 + 1, detailLines) }, () => '│').join('\n')}</Text>
238 </Box>
239 ) : null
240
241 return (
242 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
243 {forYou}
244 {recent.map(x => (
245 <Text key={`s${x.id}`} wrap="truncate-end">
246 <Text color="success">{'✓ '}</Text>
247 <Text bold>{`D${x.id} ${x.title ?? x.question.slice(0, 40)}`}</Text>
248 <Text dimColor>{` settled by ${x.by} ${ago(clock - x.at)}: `}</Text>
249 {linkify(x.answer, repoOf(x.from))}
250 </Text>
251 ))}
252 {items.length > 0 || recent.length > 0 ? rule : null}
253 {shown && isWide ? (
254 <Box>
255 {list}
256 {divider}
257 {detail}
258 </Box>
259 ) : null}
260 {shown && !isWide ? (
261 <Box flexDirection="column">
262 {open.length > 1 ? strip : null}
263 {detail}
264 </Box>
265 ) : null}
266 {shown ? rule : null}
267 <Box justifyContent="space-between" columnGap={3}>
268 <Text>
269 {open.length > 0 ? (
270 <Text>
271 <Text color="claude">● </Text>
272 <Text bold>{`${open.length} decision${open.length === 1 ? '' : 's'} waiting`}</Text>
273 </Text>
274 ) : null}
275 {open.length > 0 ? <Text color="subtle">{' │ '}</Text> : null}
276 {wrapUp ? (
277 <Text color="warning" bold>{`⏸ Wrapping up · ${left} left`}</Text>
278 ) : (
279 <Text dimColor={open.length > 0}>{want ? `${running}/${want} workers` : `${running} worker${running === 1 ? '' : 's'}`}</Text>
280 )}
281 {mix ? <Text dimColor>{`: ${mix}`}</Text> : null}
282 {parked ? <Text dimColor>{` ⏸ ${parked} waiting`}</Text> : null}
283 </Text>
284 <Box columnGap={3} flexShrink={0}>
285 {rows.length > 0 ? (
286 <Button key="workers" plain dimColor onPress={toggleList}>
287 {isList ? 'Compact' : hidden > 0 ? `Details +${hidden}` : 'Details'}
288 </Button>
289 ) : null}
290 <Button key="wrap-up" plain dimColor={!wrapUp} onPress={() => toggleWrapUp($, !wrapUp)}>
291 {wrapUp ? 'Keep going' : 'Wrap up'}
292 </Button>
293 {context ? (
294 <Text color={ctxColor} dimColor={!ctxColor} bold={ctxColor === 'error'}>
295 {`ctx ${Math.round(context.tokens / 1000)}k / ${Math.round(context.limit / 1000)}k${ctxColor === 'error' ? ' · resets soon' : ''}`}
296 </Text>
297 ) : null}
298 </Box>
299 </Box>
300 {onPage.length > 0 ? (
301 <Box columnGap={3}>
302 {columns.map((list, i) => (
303 <Box key={`col${i}`} flexDirection="column" width={cellWidth}>
304 {list.map(cell)}
305 </Box>
306 ))}
307 </Box>
308 ) : null}
309 {shown ? (
310 <Box justifyContent="space-between">
311 <Text dimColor>
312 {options.length ? `Empty prompt: 1–${options.length} answer · ` : ''}
313 {`alt+d (or ctrl+x tab), then ${options.length ? 'Enter ★' : 'Enter: answer in chat'} / o / n / p · Esc: prompt`}
314 </Text>
315 <Text dimColor>click works too</Text>
316 </Box>
317 ) : null}
318 </Box>
319 )
320}
321
322// spawn and message calls drawn with their brief cut to its first line: the full
323// text is in the worker's TASK.md or MESSAGE file, not the transcript.
324export function shortenBrief(e: ToolUseRender): ToolUseRender {
325 const field = e.props.tool === 'mcp__herdr-agents__spawn' ? 'task' : e.props.tool === 'mcp__herdr-agents__message' ? 'text' : undefined
326 const input = e.props.input as Record<string, unknown> | undefined
327 const text = field && input ? input[field] : undefined
328 if (typeof text !== 'string' || text.length <= 160) return e
329 const first = (text.split('\n').find(l => l.trim()) ?? '').replace(/^#+\s*/, '').slice(0, 100)
330 const where =
331 field === 'task' && typeof input?.cwd === 'string' && typeof input?.worker === 'string'
332 ? ` · full brief in ${String(input.cwd).split('/').pop()}/.herdr-agents/${input.worker}/TASK.md`
333 : ''
334 const cwd = typeof input?.cwd === 'string' ? `…/${String(input.cwd).split('/').pop()}` : undefined
335 return {
336 ...e,
337 props: {
338 ...e.props,
339 input: { ...input, ...(cwd ? { cwd } : {}), [field!]: `${first} … (${text.length.toLocaleString('en')} chars${where})` },
340 },
341 }
342}
343hooks/board.ts 84 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3import type { Board, Links, Row } from '../types'
4import { config, target, wrappingUp } from './config'
5import { LINGER_MS, QUIET_MAX_MS } from './defaults'
6import { elapsed } from './format'
7import { lastStatus } from './herdr'
8import { openQuestions, pingQuiet } from './owner'
9import { session } from './session'
10import { jobs } from './state'
11
12// What the band shows of the workers, and the GitHub repo of each folder they used.
13const board = atom({ plugin: 'herdr-agents', key: 'board' } as const, { now: 0, rows: [] } as Board)
14const links = atom({ plugin: 'herdr-agents', key: 'links' } as const, { byCwd: {}, extra: {} } as Links)
15
16export function readBoard($: EngineInterface) {
17 return read($, board)
18}
19export function repoLinks($: EngineInterface) {
20 return read($, links)
21}
22
23// After each turn: the context fill, shown on the band.
24export async function showContext($: EngineInterface, tokens: number, limit: number) {
25 await update($, board, b => ({ ...b, context: { tokens, limit } }))
26}
27
28// Folders whose git remotes were read this load (see learnRepos).
29const scanned = new Set<string>()
30
31// The GitHub repo (owner/name) of each folder not seen yet, from its git remotes, and the
32// config's `repos` (short name -> owner/name): what a #123 on the band links to.
33async function learnRepos($: EngineInterface, cwds: string[]): Promise<Links> {
34 const found: Record<string, string> = {}
35 for (const cwd of new Set(cwds)) {
36 if (!cwd || scanned.has(cwd)) continue
37 scanned.add(cwd)
38 try {
39 const ran = await $.process.run(['git', '-C', cwd, 'remote', '-v'])
40 const slug = ran.exitCode === 0 ? ran.stdout.match(/github\.com[:/]([^/\s]+\/[^\s]+?)(?:\.git)?\s/)?.[1] : undefined
41 if (slug) found[cwd] = slug
42 } catch {}
43 }
44 const extra: Record<string, string> = (await config($)).repos ?? {}
45 const held = await read($, links)
46 if (Object.keys(found).length === 0 && JSON.stringify(extra) === JSON.stringify(held.extra)) return held
47 let out = held
48 await update($, links, l => (out = { byCwd: { ...l.byCwd, ...found }, extra }))
49 return out
50}
51
52// Puts each shown worker's state into the band's atom. It makes no herdr calls: a running
53// worker shows the state its wait or the sweep last saw (lastStatus), and a failed sweep
54// leaves that as it was, so one error never shows a worker as gone.
55export async function refresh($: EngineInterface) {
56 const now = await $.clock.now()
57 await pingQuiet($, d => now - d.at > QUIET_MAX_MS)
58 const { byCwd } = await learnRepos($, [session.cwd, ...Object.values(await jobs($)).map(j => j.cwd)])
59 const asking = new Set((await openQuestions($)).map(d => d.from))
60 const shown = Object.values(await jobs($)).filter(
61 j => j.status === 'running' || asking.has(j.name) || (j.endedAt !== undefined && now - j.endedAt < LINGER_MS),
62 )
63 const rows: Row[] = []
64 for (const j of shown) {
65 let state = 'reported'
66 if (j.status === 'running') state = lastStatus.get(j.name) ?? 'working'
67 if (j.status === 'running' && j.wait && state !== 'blocked' && state !== 'gone') state = 'waiting'
68 rows.push({
69 name: j.name,
70 profile: j.profile,
71 state,
72 startedAt: j.startedAt,
73 endedAt: j.endedAt,
74 ...(j.status === 'running' && j.wait ? { note: `${elapsed(now - j.wait.since)} · ${j.wait.for}` } : {}),
75 ...(byCwd[j.cwd] ? { repo: byCwd[j.cwd] } : {}),
76 })
77 }
78 const held = await read($, board)
79 if (rows.length === 0 && held.rows.length === 0) return
80 const want = await target($)
81 const wrapUp = await wrappingUp($)
82 await update($, board, b => ({ ...b, now, rows, target: want, wrapUp }))
83}
84hooks/commands.ts 186 lines1import type { EngineInterface } from 'claude-code'
2import { allProfiles, BUILT_IN, config, customized, resetSettings, smallTasks, target, turnedOff, userConfigPath } from './config'
3import { orchestrate, toggleWrapUp } from './coordinator'
4import { openQuestions, pinnedItems } from './owner'
5import { cmd, session } from './session'
6import { closeSettings, openSettings, settingsOpen } from './settings'
7import { toggleSide } from './side'
8import { jobs } from './state'
9import { pruneMerged } from './worktrees'
10
11// The slash commands, all named <prefix><name> (see session.prefix).
12export async function registerCommands($: EngineInterface) {
13 const prefix = session.prefix
14 await $.command.register({ name: `${prefix}config`, description: 'Change the settings: target, built-in profiles, prefix and the rest.' })
15 for (const name of ['help', 'h'])
16 await $.command.register({
17 name: `${prefix}${name}`,
18 description: 'How herdr-agents works: commands, panel, profiles and settings.',
19 immediate: true,
20 })
21 await $.command.register({
22 name: `${prefix}side`,
23 description: 'Open or close the sidebar: the work by issue or PR, what needs you, and a box to answer it.',
24 immediate: true,
25 })
26 await $.command.register({ name: `${prefix}workers`, description: 'List the herdr workers of this session.' })
27 await $.command.register({ name: `${prefix}decisions`, description: 'List the questions and to-dos waiting for you.' })
28 await $.command.register({ name: `${prefix}prune`, description: 'Remove worktrees whose PR is merged.' })
29 await $.command.register({ name: `${prefix}update`, description: 'Pull the clone this mod was installed from and install it again.' })
30 await $.command.register({
31 name: `${prefix}orchestrate`,
32 description: 'Make this session the coordinator of worker agents for this folder, with a goal.',
33 argumentHint: '<goal, e.g. the issues labelled ready>',
34 })
35 await $.command.register({
36 name: `${prefix}wrap-up`,
37 description: `Start no new herdr workers; the running ones finish. ${cmd('wrap-up')} off ends it.`,
38 argumentHint: '[off]',
39 immediate: true,
40 })
41}
42
43// Commands are named at session start, so one hook answers them all; undefined for
44// any other command.
45export async function runCommand($: EngineInterface, command: string, args: string) {
46 const prefix = session.prefix
47 if (!command.startsWith(prefix)) return undefined
48 switch (command.slice(prefix.length)) {
49 case 'orchestrate':
50 return orchestrate($, args)
51 case 'wrap-up':
52 return { text: await toggleWrapUp($, !/^(off|resume)$/i.test(args.trim())) }
53 case 'side':
54 return { text: await toggleSide($) }
55 case 'prune':
56 return prune($)
57 case 'update':
58 return update($)
59 case 'decisions':
60 return listDecisions($)
61 case 'workers':
62 return listWorkers($)
63 case 'config':
64 if (await settingsOpen($)) {
65 await closeSettings($)
66 return { text: 'Settings closed.' }
67 }
68 await openSettings($)
69 return { text: `Settings are above the prompt: alt+d to move into them, Done or ${cmd('config')} again to close.` }
70 case 'help':
71 case 'h':
72 return help($)
73 default:
74 return undefined
75 }
76}
77
78async function prune($: EngineInterface) {
79 const done = await pruneMerged($)
80 return { text: done.length ? `Removed ${done.length} merged worktree(s):\n${done.join('\n')}` : 'No merged, clean worktrees to remove.' }
81}
82
83// `/hrd-update`: pulls the clone install.sh ran from (recorded in the mod's `clone` file) and runs its
84// install.sh again. Running sessions reload the mod by themselves, so the answer says only what changed.
85async function update($: EngineInterface) {
86 const clone = (await $.fs.read(`${$.plugin.root}/clone`).catch(() => '')).trim()
87 if (!clone) return { text: 'Nothing updated: no clone is recorded for herdr-agents. Run ./install.sh from your clone once.' }
88 const before = (await run($, ['git', '-C', clone, 'rev-parse', '--short', 'HEAD'])).stdout.trim()
89 const pull = await run($, ['git', '-C', clone, 'pull', '--ff-only'], 120_000)
90 if (pull.exitCode !== 0)
91 return { text: `Update stopped: git pull --ff-only failed in ${clone}, install.sh did not run: ${lastLines(pull.stderr)}` }
92 const after = (await run($, ['git', '-C', clone, 'rev-parse', '--short', 'HEAD'])).stdout.trim()
93 const install = await run($, [`${clone}/install.sh`], 120_000)
94 if (install.exitCode !== 0) return { text: `Update stopped: install.sh failed in ${clone}: ${lastLines(install.stderr)}` }
95 const version = JSON.parse(await $.fs.read(`${clone}/.claude-plugin/plugin.json`)).version
96 const reload = 'running sessions reload it by themselves.'
97 if (before === after) return { text: `herdr-agents is up to date at ${after}, version ${version}. install.sh ran again; ${reload}` }
98 return { text: `herdr-agents updated from ${before} to ${after}, version ${version}. Installed; ${reload}` }
99}
100
101// Runs a command. One that cannot start, or runs past its timeout, reads as failed, its error as stderr.
102async function run($: EngineInterface, argv: string[], timeoutMs = 30_000) {
103 return $.process.run(argv, { timeoutMs }).catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: String(err) }))
104}
105
106// The last three lines of a command's stderr, on one line.
107function lastLines(stderr: string) {
108 return stderr.trim().split('\n').slice(-3).join(' / ') || 'no output on stderr'
109}
110
111async function listDecisions($: EngineInterface) {
112 const open = await openQuestions($)
113 const items = await pinnedItems($)
114 if (open.length === 0 && items.length === 0) return { text: 'Nothing waiting for you.' }
115 return {
116 text: [
117 ...items.map(t => `T${t.id} ${t.kind === 'do' ? 'to do' : 'to know'} (${t.from}) ${t.text}`),
118 ...open.map(d => `D${d.id} (${d.from}) ${d.question}`),
119 ].join('\n\n'),
120 }
121}
122
123async function listWorkers($: EngineInterface) {
124 const list = Object.values(await jobs($))
125 if (list.length === 0) return { text: 'No herdr workers in this session.' }
126 return { text: list.map(j => `${j.name} ${j.profile} ${j.status} ${j.cwd}`).join('\n') }
127}
128
129function commandRows(rows: [string, string][]) {
130 const width = Math.max(...rows.map(([name]) => name.length)) + 3
131 return rows.map(([name, what]) => ` ${name.padEnd(width)}${what}`)
132}
133
134// The guide the help command prints, with this folder's settings filled in.
135async function help($: EngineInterface) {
136 const c = await config($)
137 const all = await allProfiles($)
138 const off = await turnedOff($)
139 const small = await smallTasks($)
140 const { statePath, limit } = await resetSettings($)
141 const tags = (name: string) =>
142 [customized(name, all[name]) ? 'customized' : '', off.has(name) ? 'off' : '']
143 .filter(Boolean)
144 .map(tag => ` · ${tag}`)
145 .join('')
146 const profileRows = (title: string, names: string[]) =>
147 names.length ? [` ${title}`, ...names.map(name => ` ${name.padEnd(12)} ${all[name]?.note ?? all[name]?.kind}${tags(name)}`)] : []
148 return {
149 text: [
150 'This session coordinates worker agents that run in herdr tabs; their reports come back here as prompts.',
151 '',
152 'Start',
153 ...commandRows([
154 [`${cmd('orchestrate')} <goal>`, `make this session the coordinator, e.g. ${cmd('orchestrate')} the issues labelled ready`],
155 [`${cmd('wrap-up')} [off]`, 'start no new workers and let the running ones finish; off resumes'],
156 [cmd('side'), 'the sidebar beside the chat: work by issue or PR, what needs you, an answer box'],
157 [cmd('decisions'), 'the questions and to-dos waiting for you'],
158 [cmd('workers'), "this session's workers"],
159 [cmd('prune'), 'remove worktrees whose PR is merged'],
160 [cmd('update'), 'pull the clone this mod was installed from, and install it again'],
161 [cmd('config'), 'change these settings, in the panel above the prompt'],
162 [`${cmd('help')}, ${cmd('h')}`, 'this guide'],
163 ]),
164 '',
165 'Panel (above the prompt)',
166 ' To-dos the coordinator pinned for you, then its questions, then the workers.',
167 ' Answer a question with 1–4 from an empty prompt, or alt+d (ctrl+x tab), arrows and Enter; Esc goes back.',
168 ' Details shows every worker; Wrap up / Keep going toggles the wrap-up.',
169 ` While ${cmd('side')} is open beside the chat, it shows all of this and the band steps aside.`,
170 '',
171 'Profiles (spawn takes one)',
172 ...profileRows('Built in', BUILT_IN),
173 ...profileRows(
174 'Yours',
175 Object.keys(all).filter(name => !BUILT_IN.includes(name)),
176 ),
177 ` Small tasks go to ${small ?? 'nobody (smallTasks)'}`,
178 '',
179 'Settings',
180 ` ${await userConfigPath($)}, with ${session.cwd}/.claude/herdr-agents.json over it`,
181 ` target ${await target($)} workers · state file ${statePath} · compact past ${limit} tokens · prefix ${session.prefix}${c.wrapUp === true ? ' · wrapUp on everywhere' : ''}`,
182 ` ${cmd('config')} changes them. Keys: target, resetAt, statePath, trustRoots, commandPrefix, profiles, disable, smallTasks, playbook, pruneMinutes, closeIdleMinutes, repos, wrapUp. See the README.`,
183 ].join('\n'),
184 }
185}
186hooks/bridge.ts 60 lines1import type { EngineInterface } from 'claude-code'
2import { BRIDGE_ACTIONS_KEPT, BRIDGE_DIR } from './defaults'
3import { boardAct, boardInputs, boardSnapshot, type BoardAction } from './side'
4
5// The board for apps outside Claude Code, such as contr (a native herdr client):
6// ~/.herdr-agents/board/<session id>.json holds what the sidebar shows, rewritten when it
7// changes (docs/board-schema.json), and each file the app drops in <session id>.actions/
8// is one press of a sidebar button, done on the next pass and removed. What came of the
9// last few is in the snapshot's `actions`.
10
11// What the snapshot last written was built from, and what came of the last actions.
12let written = ''
13let results: { id: string; ok: boolean; message: string }[] = []
14
15async function paths($: EngineInterface) {
16 const dir = `${(await $.env.get('HOME')) ?? ''}/${BRIDGE_DIR}`
17 const id = await $.session.id()
18 return { dir, file: `${dir}/${id}.json`, inbox: `${dir}/${id}.actions` }
19}
20
21// Runs with the band's refresh: the actions waiting first, so the snapshot shows what they did.
22export async function publishBoard($: EngineInterface) {
23 const { file, inbox } = await paths($)
24 await takeActions($, inbox)
25 const inputs = (await boardInputs($)) + JSON.stringify(results)
26 if (inputs === written) return
27 const snap = { ...(await boardSnapshot($)), actions: results }
28 await $.process.run(['mkdir', '-p', inbox])
29 // Written beside it and moved over it, so a reader never sees half a file.
30 await $.fs.write(`${file}.tmp`, JSON.stringify(snap, null, 2))
31 const moved = await $.process.run(['mv', '-f', `${file}.tmp`, file])
32 if (moved.exitCode === 0) written = inputs
33}
34
35async function takeActions($: EngineInterface, inbox: string) {
36 if (!(await $.fs.exists(inbox))) return
37 const files = (await $.fs.list(inbox))
38 .filter(f => f.kind === 'file' && f.name.endsWith('.json'))
39 .map(f => f.name)
40 .sort()
41 for (const name of files) {
42 const path = `${inbox}/${name}`
43 let result: (typeof results)[number]
44 try {
45 const a = JSON.parse(await $.fs.read(path)) as BoardAction
46 result = { id: name, ok: true, message: await boardAct($, a) }
47 } catch (err) {
48 result = { id: name, ok: false, message: String(err instanceof Error ? err.message : err).slice(0, 200) }
49 }
50 await $.process.run(['rm', '-f', path])
51 results = [...results, result].slice(-BRIDGE_ACTIONS_KEPT)
52 }
53}
54
55// The session is ending: its board and inbox go.
56export async function removeBoard($: EngineInterface) {
57 const { file, inbox } = await paths($)
58 await $.process.run(['rm', '-rf', file, `${file}.tmp`, inbox])
59}
60hooks/config.ts 140 lines1import type { EngineInterface } from 'claude-code'
2import type { Profile, Settings } from '../types'
3import { RESET_AT, STATE_OWNER, TARGET, WRAP_UP } from './defaults'
4import { session } from './session'
5
6// Claude workers run in their reviewed auto mode (each approval goes to the
7// CLI's own automatic reviewer). A worker waiting at a prompt is reported as
8// blocked. Profiles in herdr-agents.json add to these or replace them by name,
9// which is where the person adds other CLIs (codex, opencode) or approval modes.
10export const PROFILES: Record<string, Profile> = {
11 // The subagents in this repo's agents/ folder, which Claude Code names after the plugin.
12 // `claude --agent` ignores a subagent's `effort:` line, so each profile passes --effort.
13 planner: {
14 kind: 'claude',
15 args: ['--agent', 'herdr-agents:planner', '--effort', 'high', '--permission-mode', 'auto'],
16 note: 'Claude planner agent (Opus high)',
17 },
18 builder: {
19 kind: 'claude',
20 args: ['--agent', 'herdr-agents:builder', '--effort', 'xhigh', '--permission-mode', 'auto'],
21 note: 'Claude builder agent (Haiku xhigh)',
22 },
23 reviewer: {
24 kind: 'claude',
25 args: ['--agent', 'herdr-agents:reviewer', '--effort', 'high', '--permission-mode', 'auto'],
26 note: 'Claude reviewer agent (Opus high)',
27 },
28 // No subagent: the full toolset, for root-cause hunts and fixes that failed twice.
29 debugger: {
30 kind: 'claude',
31 args: ['--model', 'opus', '--effort', 'medium', '--permission-mode', 'auto'],
32 note: 'Claude Opus medium, full tools (debugging, tricky fixes)',
33 },
34}
35
36// The built-in profiles are the playbook's steps: a profile of the same name in
37// herdr-agents.json changes its model or flags, and `disable` turns off any but the
38// builder, which every issue needs. `smallTasks` names a profile for small,
39// well-defined tasks (none by default).
40export const BUILT_IN = Object.keys(PROFILES)
41
42export const OPTIONAL = ['planner', 'reviewer', 'debugger']
43
44export async function readJson($: EngineInterface, path: string): Promise<Settings> {
45 if (!(await $.fs.exists(path))) return {}
46 try {
47 return JSON.parse(await $.fs.read(path))
48 } catch {
49 return {}
50 }
51}
52
53export async function userConfigPath($: EngineInterface): Promise<string> {
54 return `${(await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`}/herdr-agents.json`
55}
56
57// ~/.claude/herdr-agents.json (or $CLAUDE_CONFIG_DIR's), with the repo's own
58// <session cwd>/.claude/herdr-agents.json over it (its profiles merged by name),
59// so each repo can set its target, state file, profiles and the rest.
60export async function config($: EngineInterface): Promise<Settings> {
61 const user = await readJson($, await userConfigPath($))
62 const repo = session.cwd ? await readJson($, `${session.cwd}/.claude/herdr-agents.json`) : {}
63 return { ...user, ...repo, profiles: { ...user.profiles, ...repo.profiles } }
64}
65
66// Every profile, the turned-off built-ins too.
67export async function allProfiles($: EngineInterface): Promise<Record<string, Profile>> {
68 return { ...PROFILES, ...(await config($)).profiles }
69}
70
71export async function turnedOff($: EngineInterface): Promise<Set<string>> {
72 const named = (await config($)).disable
73 return new Set((Array.isArray(named) ? named : []).filter((name: string) => OPTIONAL.includes(name)))
74}
75
76// The profiles spawn takes.
77export async function profiles($: EngineInterface): Promise<Record<string, Profile>> {
78 const all = await allProfiles($)
79 for (const name of await turnedOff($)) delete all[name]
80 return all
81}
82
83export async function smallTasks($: EngineInterface): Promise<string | undefined> {
84 const name = (await config($)).smallTasks
85 return typeof name === 'string' && (await profiles($))[name] ? name : undefined
86}
87
88// A built-in profile whose settings differ from the one shipped.
89export function customized(name: string, p: Profile | undefined) {
90 const shipped = PROFILES[name]
91 return !!shipped && !!p && JSON.stringify(p) !== JSON.stringify(shipped)
92}
93
94// Workers that read a worktree without building in it; they never block a builder spawn.
95export function reviewsOnly(profile: string) {
96 return profile === 'reviewer' || profile === 'planner'
97}
98
99// Where the coordinator keeps its state, and the context size past which it is reset
100// (see maybeReset). The state file belongs to the session that last wrote it (recorded
101// under STATE_OWNER in $.store); any other session saves to a handoff file of its own.
102export async function resetSettings($: EngineInterface) {
103 const c = await config($)
104 const named = (typeof c.statePath === 'string' ? c.statePath : 'COORDINATOR-STATE.md').replace(
105 /^~(?=\/)/,
106 (await $.env.get('HOME')) ?? '~',
107 )
108 const shared: string = named.startsWith('/') ? named : `${session.cwd}/${named}`
109 const id: string = await $.session.id()
110 const owner = (await $.store.get(STATE_OWNER)) as { path: string; session: string } | undefined
111 const mine = !owner || owner.path !== shared || owner.session === id
112 return {
113 limit: typeof c.resetAt === 'number' ? c.resetAt : RESET_AT,
114 coordinator: mine,
115 statePath: mine ? shared : `${session.cwd}/.herdr-agents/handoff-${id.slice(0, 8)}.md`,
116 shared,
117 }
118}
119
120// Records this session as the owner of the shared state file once it writes it.
121export async function claimState($: EngineInterface, file: unknown) {
122 const { shared } = await resetSettings($)
123 if (file === shared) await $.store.set(STATE_OWNER, { path: shared, session: await $.session.id() })
124}
125
126export async function target($: EngineInterface): Promise<number> {
127 const c = await config($)
128 return typeof c.target === 'number' ? c.target : TARGET
129}
130
131// The owner's wrap-up switch: while it is on, no new worker starts (a reviewer excepted:
132// it finishes an open PR) and reports stop asking for more, while the running workers
133// finish. The wrap-up command turns it on for the coordinators of this session's folder
134// (kept in $.store, so every session there sees it); `wrapUp: true` in the user's
135// settings turns it on everywhere. Wrap-up off clears both.
136export async function wrappingUp($: EngineInterface): Promise<boolean> {
137 const folders = ((await $.store.get(WRAP_UP)) ?? {}) as Record<string, boolean>
138 return folders[session.cwd] === true || (await config($)).wrapUp === true
139}
140hooks/coordinator.ts 179 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3import type { Reset } from '../types'
4import { refresh } from './board'
5import { config, resetSettings, smallTasks, target, turnedOff, userConfigPath } from './config'
6import { SAVE_WAIT_MS, WRAP_UP } from './defaults'
7import { pinned } from './owner'
8import { cmd, session } from './session'
9import { jobs } from './state'
10
11// The coordinator's side: the playbook it follows, the worker target and wrap-up,
12// and keeping its context small.
13
14// The context reset under way (see maybeReset), and whether /hrd-orchestrate ran here.
15const resetAtom = atom({ plugin: 'herdr-agents', key: 'reset' } as const, null as Reset | null)
16const orchestrating = atom({ plugin: 'herdr-agents', key: 'orchestrating' } as const, false)
17
18// What the coordinator does after two failed fix rounds.
19export function afterFailedRounds(off: Set<string>) {
20 return off.has('debugger') ? 'ask the owner with ask_owner' : 'spawn `debugger`'
21}
22
23export async function setWrapUp($: EngineInterface, on: boolean) {
24 const folders = { ...((await $.store.get(WRAP_UP)) ?? {}) } as Record<string, boolean>
25 if (on) folders[session.cwd] = true
26 else delete folders[session.cwd]
27 await $.store.set(WRAP_UP, folders)
28 const path = await userConfigPath($)
29 // Parsed here, not through config(): a broken file throws instead of being overwritten.
30 const c = !on && (await $.fs.exists(path)) ? JSON.parse(await $.fs.read(path)) : {}
31 if (c.wrapUp !== undefined) {
32 delete c.wrapUp
33 await $.fs.write(path, `${JSON.stringify(c, null, 2)}\n`)
34 }
35 void refresh($)
36}
37
38// What the orchestrate command hands the session, and again after each compaction.
39// `playbook` in herdr-agents.json (a line or a list of lines) adds the person's own.
40export async function playbook($: EngineInterface, statePath: string) {
41 const want = await target($)
42 const off = await turnedOff($)
43 const extra = (await config($)).playbook ?? []
44 const small = await smallTasks($)
45 const steps = [
46 off.has('planner')
47 ? 'spawn `builder` with the issue; it plans before it builds'
48 : "spawn `planner` for anything unplanned or non-trivial (its plan lands in PLAN.md), then `builder` with the plan's path",
49 off.has('reviewer')
50 ? "then review the builder's PR yourself (diff and checks) before it merges"
51 : "then `reviewer` on the builder's PR",
52 ]
53 return [
54 'You coordinate worker agents for the repo(s) in this folder: you plan, start and check them and land their PRs; you write little code yourself.',
55 '- Rules: read AGENTS.md or CLAUDE.md first. They name the base branch, the checks, the PR format, who merges and what needs the owner; workers read them too.',
56 `- State: keep ${statePath} current, updated after every report: Running (worker, profile, worktree, issue or PR, what it does), Ready (planned items that can start now, at least 3), Waiting (CI, review, merge), Decisions, Next. A fresh context carries on from it alone.`,
57 "- Reports: a <herdr-agent> report starts with the worker's summary. Read its full report file only when the summary is not enough; give a fix builder that path instead of copying the report.",
58 '- Worktrees: each worker gets its own git worktree off the base branch, next to the repo (`git worktree add -b <branch> ../<repo>-<slug> origin/<base>`); never two writers in one.',
59 `- Each issue: ${steps.join(', ')}. Send blockers to a fresh builder: stop the one that wrote the PR, then spawn \`builder\` in the same worktree with the PR and the blockers; after two failed fix rounds, ${afterFailedRounds(off)}.${small ? ` Small, well-defined tasks may go to \`${small}\`.` : ''}`,
60 `- Throughput: keep ${want} workers busy; each report says how many work. Split big work into small PRs and run independent slices at once.`,
61 '- Waiting: never poll or sleep; reports, WAIT.json checks and decisions arrive by themselves.',
62 '- The owner: ask only what is theirs (product, design or rule choices, approvals) with ask_owner, a few at a time; settle the rest yourself with decided. Merge only as the repo rules allow.',
63 '- Things the owner must do (test a build, merge a PR that waits for them) or should know: pin each with pin_for_owner, never only in a message, and unpin it with unpin_for_owner once it is done.',
64 `- When the owner runs ${cmd('wrap-up')}: start nothing new, let the running workers finish, then update the state file and stop.`,
65 ...(Array.isArray(extra) ? extra : [extra]).map((line: string) => `- ${line}`),
66 ].join('\n')
67}
68
69// Turns wrap-up on or off and, in a session that has started workers, tells its model.
70export async function toggleWrapUp($: EngineInterface, on: boolean) {
71 await setWrapUp($, on)
72 const live = Object.values(await jobs($)).filter(j => j.status === 'running')
73 if (Object.keys(await jobs($)).length > 0) {
74 void $.prompt.submit({
75 text: on
76 ? `<herdr-context>The owner is wrapping up: start no new workers (a reviewer for an open PR is allowed). Let the ${live.length} running finish, send them fixes with message and merge what is ready. When the last one has reported, update the state file and stop.</herdr-context>`
77 : `<herdr-context>The owner ended the wrap-up: keep ${await target($)} workers busy again from the Ready list.</herdr-context>`,
78 })
79 }
80 const names = live.map(j => j.name).join(', ')
81 return on
82 ? `Wrapping up: no new herdr workers start. ${live.length ? `${live.length} still running finish: ${names}.` : 'None are running.'} ${cmd('wrap-up')} off ends it.`
83 : 'Wrap-up off: workers start again.'
84}
85
86// The live workers, for the compaction summary to keep.
87export async function roster($: EngineInterface) {
88 return Object.values(await jobs($))
89 .filter(j => j.status === 'running')
90 .map(j => `- ${j.name} (${j.profile}) in ${j.cwd}, job folder ${j.dir}`)
91 .join('\n')
92}
93
94let resetting = false
95
96// Keeps a coordinator's context small. Past the limit it asks the model to
97// save its state to the state file; once that file is newer than the ask (or
98// SAVE_WAIT_MS passed), it compacts with a short summary pointing at the file.
99// Only a session that has started workers is reset.
100export async function maybeReset($: EngineInterface, tokens: number) {
101 if (resetting || Object.keys(await jobs($)).length === 0) return
102 const { limit, statePath, coordinator, shared } = await resetSettings($)
103 const reset = await read($, resetAtom)
104 if (tokens < limit) {
105 if (reset) await update($, resetAtom, () => null)
106 return
107 }
108 const now = await $.clock.now()
109 if (!reset) {
110 await update($, resetAtom, (): Reset => ({ stage: 'saving', at: now }))
111 await $.prompt.submit({
112 text: [
113 `<herdr-context tokens="${Math.round(tokens / 1000)}k" limit="${Math.round(limit / 1000)}k">`,
114 ...(coordinator
115 ? [`This session's context is past the limit. Update ${statePath} now so a fresh context can carry on from it alone:`]
116 : [
117 `This session's context is past the limit. It is not the coordinator: leave ${shared} alone (another session owns it)`,
118 `and write this session's own handoff to ${statePath} so a fresh context can carry on from it alone:`,
119 ]),
120 'every running worker (name, profile, worktree, issue or PR, what it is doing), open PRs and their state,',
121 "what waits on review, CI or merge, the owner's decisions and standing instructions from this session,",
122 'open questions for the owner, ids already claimed (migration numbers and the like), and the next steps.',
123 'Replace stale content and keep it under about 150 lines. Then answer in one line.',
124 'The mod compacts the conversation right after.',
125 '</herdr-context>',
126 ].join('\n'),
127 })
128 return
129 }
130 let saved = false
131 try {
132 saved = (await $.fs.exists(statePath)) && (await $.fs.stat(statePath)).mtimeMs > reset.at
133 } catch {}
134 if (!saved && now - reset.at < SAVE_WAIT_MS) return
135
136 resetting = true
137 try {
138 const live = await roster($)
139 const items = await pinned($)
140 const result = await $.session.compact({
141 instructions: [
142 `Keep this summary short, under 1,500 words. This session's full state is in ${statePath}:`,
143 'say to read it first and trust it over this summary.',
144 "Keep only what that file may lack: the owner's latest messages and anything that happened after it was saved.",
145 live ? `Workers still running in herdr tabs:\n${live}` : 'No herdr workers are running.',
146 ...(items ? [`Still pinned for the owner (unpin_for_owner once done):\n${items}`] : []),
147 ].join('\n'),
148 })
149 if ('skip' in result && result.skip) return
150 await update($, resetAtom, () => null)
151 $.ui.toast('herdr-agents: context compacted')
152 const coordinating = await read($, orchestrating)
153 await $.prompt.submit({
154 text: `<herdr-context>The conversation was compacted to keep the context small. Read ${statePath} before acting on the next report.${coordinating ? `\n${await playbook($, statePath)}` : ''}</herdr-context>`,
155 })
156 } catch {
157 // A turn is running; try again after the next one.
158 } finally {
159 resetting = false
160 }
161}
162
163export async function orchestrate($: EngineInterface, args: string) {
164 const goal = args.trim()
165 const { statePath } = await resetSettings($)
166 await update($, orchestrating, () => true)
167 void $.prompt.submit({
168 text: [
169 '<herdr-context>You are now the coordinator of this folder.',
170 await playbook($, statePath),
171 goal
172 ? `Goal: ${goal}`
173 : 'Goal: none given. Read the state file and the open issues, then propose the first ones to the owner with ask_owner.',
174 '</herdr-context>',
175 ].join('\n'),
176 })
177 return { text: `Coordinating${goal ? `: ${goal}` : ''}. State file: ${statePath}. ${cmd('wrap-up')} stops new work.` }
178}
179hooks/defaults.ts 85 lines1// Defaults and tuning values in one place. The first group are the defaults of the
2// settings in herdr-agents.json (see config.ts); the rest are the mod's own.
3
4// Workers the coordinator keeps busy (`target`).
5export const TARGET = 4
6// Context tokens past which the coordinator saves its state and is compacted (`resetAt`).
7export const RESET_AT = 200_000
8// What every slash command starts with (`commandPrefix`).
9export const PREFIX = 'hrd-'
10// How often merged worktrees are pruned (`pruneMinutes`); 0 turns it off.
11export const PRUNE_MINUTES = 30
12// How long a reported worker's tab may sit idle before it is closed (`closeIdleMinutes`); 0 keeps it.
13export const CLOSE_IDLE_MINUTES = 30
14
15// A worker's name: also its herdr tab's label.
16export const NAME = /^[a-z0-9][a-z0-9-]{0,39}$/
17// A report without a header (see report.ts) longer than this goes to the coordinator as its first line and its file's path.
18export const INLINE_LINES = 6
19export const INLINE_CHARS = 700
20// A report's header (see report.ts): the lines it may start within, and the summary's size.
21export const HEADER_SCAN_LINES = 12
22export const SUMMARY_LINES = 3
23export const SUMMARY_LINE_CHARS = 240
24// A follow-up to a worker up to this long is typed into its prompt as well as saved to a file.
25export const MESSAGE_CHARS = 4000
26// How long a worker may sit with an unchanged screen, not working and without
27// RESULT.md, before it is reported as stopped.
28export const QUIET_MS = 5 * 60_000
29// The same for a worker whose status lines show background work of its own (a shell,
30// a monitor, a subagent): a dev server it left running must not hide it for good.
31export const QUIET_BUSY_MS = 60 * 60_000
32// How often a prompt is sent or submitted again when herdr sees no work start after it.
33export const PROMPT_TRIES = 3
34// How often the sweep reads herdr for every running worker at once (one `herdr agent list`).
35export const SWEEP_MS = 60_000
36// How many sweeps in a row must miss a worker's tab before it is reported gone: one failed
37// or late list does not end a job.
38export const GONE_MISSES = 2
39// How long a prompt that never arrived waits before its worker is reported blocked.
40export const LOST_REPORT_MS = 10_000
41// Prompts for the coordinator (reports, owner answers) wait this long after the last one,
42// and after its turn ends, so the ones that arrive together go out as one prompt (see outbox.ts).
43export const BATCH_MS = 3000
44// How long a reported worker stays on the band.
45export const LINGER_MS = 60_000
46
47// How long a settled decision stays on the band.
48export const SETTLED_MS = 10 * 60_000
49// How long a worker's question waits for the coordinator before the person is pinged anyway.
50export const QUIET_MAX_MS = 3 * 60_000
51
52// If the coordinator hasn't saved its state this long after being asked, compact anyway.
53export const SAVE_WAIT_MS = 10 * 60_000
54
55// The band: to-dos shown (the decisions command lists the rest), worker lines in the
56// compact view (Details shows them all), and the narrowest worker column; a band at
57// least twice as wide shows two workers per line.
58export const TODO_ROWS = 6
59export const WORKER_LINES = 2
60export const WORKER_CELL = 56
61
62// The sidebar (/hrd-side): the width it asks for when docked, its fixed rows (work
63// table, needs-you list, the answer box), how long a finished task still shows when it
64// first appears, and how often it reads the GitHub state of the PRs it lists.
65export const SIDE_COLUMNS = 64
66export const SIDE_WORK_ROWS = 8
67export const SIDE_NEED_ROWS = 6
68export const SIDE_BOX_ROWS = 8
69export const SIDE_RECENT_MS = 3 * 60 * 60_000
70export const PR_POLL_MS = 2 * 60_000
71
72// Keys in $.store, which every session on the machine shares.
73export const STATE_OWNER = 'stateOwner'
74export const WRAP_UP = 'wrapUp'
75// A session's jobs, under this prefix and its id (see state.ts), kept this long after their last change.
76export const JOBS_PREFIX = 'jobs:'
77export const JOBS_KEEP_MS = 3 * 24 * 60 * 60_000
78// The outbox's copy in $.store (see outbox.ts), under this prefix and the session's id.
79export const OUTBOX_PREFIX = 'outbox:'
80
81// The board for apps outside Claude Code (see bridge.ts), under the home folder, and how
82// many of their last actions the snapshot reports.
83export const BRIDGE_DIR = '.herdr-agents/board'
84export const BRIDGE_ACTIONS_KEPT = 10
85hooks/outbox.ts 121 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3import type { OutboxItem } from '../types'
4import { BATCH_MS, JOBS_KEEP_MS, OUTBOX_PREFIX } from './defaults'
5import { belowTarget } from './target'
6
7// The prompts for the coordinator that come close together go out as one: reports (each a
8// <herdr-agent> element), the owner's answers and the to-dos they ticked. An item waits until
9// BATCH_MS have passed since the last one and since the coordinator's turn ended, and nothing
10// is sent while a turn runs: a prompt sent then would only queue behind it. The outbox is also
11// kept in $.store, so a reload finds what it held (see restoreOutbox).
12
13const outbox = atom({ plugin: 'herdr-agents', key: 'outbox' } as const, [] as OutboxItem[])
14
15// Whether a main-loop turn runs, when an item last came in or a turn last ended, and whether
16// a loop is waiting to flush (only one at a time). A reload resets busy to false, so a batch can
17// go out during a turn that was running before it: that is acceptable, as the prompt only queues
18// behind that turn and nothing is lost.
19let busy = false
20let lastAt = 0
21let flushing = false
22
23// The main loop's turn started or ended (register.tsx).
24export function turnStarted() {
25 busy = true
26}
27export async function turnEnded($: EngineInterface) {
28 busy = false
29 lastAt = await $.clock.now()
30 void kick($)
31}
32
33// Queues a prompt for the coordinator. A batch with a report ends with the worker count (see flush).
34export async function post($: EngineInterface, text: string, report = false) {
35 const item: OutboxItem = report ? { text, report: true } : { text }
36 await update($, outbox, list => [...list, item])
37 lastAt = await $.clock.now()
38 await save($)
39 void kick($)
40}
41
42// The sweep's tick (register.tsx): a batch that a refused prompt kept goes out without a new post or turn's end.
43export function retry($: EngineInterface) {
44 void kick($)
45}
46
47// Waits for the quiet and flushes, while items are held. A post or a turn's end that finds the
48// loop running needs nothing more: the loop looks again after its wait.
49async function kick($: EngineInterface) {
50 if (flushing) return
51 flushing = true
52 try {
53 while ((await read($, outbox)).length > 0) {
54 await $.clock.sleep(BATCH_MS)
55 // A turn is running: its end starts the loop again.
56 if (busy) return
57 if ((await $.clock.now()) - lastAt < BATCH_MS) continue
58 // A prompt that could not be sent keeps its items; the next post, turn's end or sweep tries again.
59 if (!(await flush($))) return
60 }
61 } catch {
62 // Background work: a failed step ends the loop, and the next post or turn's end starts it again.
63 } finally {
64 flushing = false
65 }
66}
67
68// Takes the held items and sends them as one prompt. False when the send failed: the items go back.
69// With no items there is nothing to send: a loop that wakes after a reload emptied the state sends
70// no empty prompt, and the copy in $.store is left for the restore.
71async function flush($: EngineInterface): Promise<boolean> {
72 let items: OutboxItem[] = []
73 await update($, outbox, list => {
74 items = list
75 return []
76 })
77 if (items.length === 0) return true
78 try {
79 let text = items.map(i => i.text).join('\n\n')
80 if (items.some(i => i.report)) text += await belowTarget($)
81 await save($)
82 await $.prompt.submit({ text: text.trim() })
83 return true
84 } catch (err) {
85 await update($, outbox, list => [...items, ...list])
86 await save($)
87 $.ui.toast(`herdr-agents: a prompt for the coordinator was not sent (${err}); it goes with the next one`)
88 return false
89 }
90}
91
92// The held items in $.store under the session's key, or none once they are out.
93async function save($: EngineInterface) {
94 const items = await read($, outbox)
95 const key = `${OUTBOX_PREFIX}${await $.session.id()}`
96 if (items.length > 0) await $.store.set(key, { at: lastAt, items })
97 else await $.store.delete(key)
98}
99
100// session.start: a reload may have left items held. $.state keeps the outbox only sometimes (as
101// with the jobs), so its copy in $.store is taken when the state is empty, and the batch goes out
102// at once. Other sessions' copies older than the jobs' are removed, as the jobs' copies are.
103export async function restoreOutbox($: EngineInterface) {
104 const now = await $.clock.now()
105 const mine = `${OUTBOX_PREFIX}${await $.session.id()}`
106 for (const key of await $.store.keys()) {
107 if (!key.startsWith(OUTBOX_PREFIX) || key === mine) continue
108 const { at = 0 } = ((await $.store.get(key)) ?? {}) as { at?: number }
109 if (now - at > JOBS_KEEP_MS) await $.store.delete(key)
110 }
111 if ((await read($, outbox)).length === 0) {
112 const kept = ((await $.store.get(mine)) ?? {}) as { at?: number; items?: OutboxItem[] }
113 const items = kept.items ?? []
114 if (items.length > 0) {
115 lastAt = kept.at ?? 0
116 await update($, outbox, () => items)
117 }
118 }
119 void kick($)
120}
121hooks/owner.ts 219 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3import type { Decision, Settled, Todo } from '../types'
4import { SETTLED_MS } from './defaults'
5import { notify } from './herdr'
6import { post } from './outbox'
7
8// What waits on the person: questions (ask_owner, workers' DECISION: lines) and
9// to-dos (pin_for_owner), pinned on the band until answered or ticked off.
10
11const decisions = atom({ plugin: 'herdr-agents', key: 'decisions' } as const, [] as Decision[])
12const settled = atom({ plugin: 'herdr-agents', key: 'settled' } as const, [] as Settled[])
13const lastId = atom({ plugin: 'herdr-agents', key: 'lastId' } as const, 0)
14const todos = atom({ plugin: 'herdr-agents', key: 'todos' } as const, [] as Todo[])
15const lastTodo = atom({ plugin: 'herdr-agents', key: 'lastTodo' } as const, 0)
16
17// A decision's name in a list: its title, else the question's first words.
18export function decisionTitle(d: Decision) {
19 return (
20 d.title ??
21 d.question
22 .replace(/^\s*(TEST \(ignore\)\s*:?\s*)?(Q\d+\s*:\s*)?([#\w-]*\d+\s*:?\s*)?/i, '')
23 .replace(/^[^\p{L}\p{N}]+/u, '')
24 .split(/\s+/)
25 .slice(0, 4)
26 .join(' ')
27 .replace(/[?:,.;]+$/, '')
28 )
29}
30
31export function openQuestions($: EngineInterface) {
32 return read($, decisions)
33}
34export function answered($: EngineInterface) {
35 return read($, settled)
36}
37export function pinnedItems($: EngineInterface) {
38 return read($, todos)
39}
40
41// Pins a question for the person on the band and notifies them. A `quiet` one
42// (a worker's DECISION: line) waits for the coordinator to read it first: see
43// pingQuiet. Ids never repeat within a session, so a stale id cannot close a
44// newer question.
45export async function ask(
46 $: EngineInterface,
47 from: string,
48 question: string,
49 options?: string[],
50 recommended?: number,
51 title?: string,
52 quiet = false,
53) {
54 const at = await $.clock.now()
55 const used = [...(await read($, decisions)), ...(await read($, settled))].map(d => d.id)
56 let id = 0
57 await update($, lastId, n => (id = Math.max(n, 0, ...used) + 1))
58 await update($, decisions, list => [
59 ...list,
60 {
61 id,
62 from,
63 question,
64 at,
65 ...(title ? { title } : {}),
66 ...(options?.length ? { options, recommended } : {}),
67 ...(quiet ? { quiet } : {}),
68 },
69 ])
70 if (!quiet) await notify($, `Decision D${id} needed (${from})`, question)
71 return id
72}
73
74// Pings the person for the quiet questions `pick` selects, once.
75export async function pingQuiet($: EngineInterface, pick: (d: Decision) => boolean) {
76 let due: Decision[] = []
77 await update($, decisions, list => {
78 due = list.filter(d => d.quiet && pick(d))
79 return due.length ? list.map(d => (due.includes(d) ? { ...d, quiet: false } : d)) : list
80 })
81 if (due.length === 1) await notify($, `Decision D${due[0]!.id} needed (${due[0]!.from})`, due[0]!.question)
82 else if (due.length > 1)
83 await notify($, `${due.length} decisions need you`, due.map(d => `D${d.id} ${d.title ?? d.question}`).join(' · '))
84}
85
86// The person picked an answer on the band: unpin it and hand it to the model.
87export async function choose($: EngineInterface, d: Decision, answer: string) {
88 await update($, decisions, list => list.filter(x => x.id !== d.id))
89 $.ui.toast(`D${d.id}: ${answer}`)
90 await post(
91 $,
92 [
93 `<herdr-decision id="D${d.id}" from="${d.from}">`,
94 `Question: ${d.question}`,
95 `The owner chose: ${answer}`,
96 'It is already unpinned. Act on it now (tell the worker with message, record it in the state file).',
97 '</herdr-decision>',
98 ].join('\n'),
99 )
100}
101
102// Pins one line for the person at the top of the band. The same text already
103// pinned keeps its id. Ids never repeat within a session.
104export async function pin($: EngineInterface, text: string, kind: Todo['kind'], from: string) {
105 const same = (await read($, todos)).find(t => t.text === text)
106 if (same) return { id: same.id, isNew: false }
107 const at = await $.clock.now()
108 let id = 0
109 await update($, lastTodo, n => (id = n + 1))
110 await update($, todos, list => [...list, { id, kind, text, from, at }])
111 if (kind === 'do') await notify($, `For you: T${id}`, text, false)
112 else $.ui.toast(`T${id}: ${text}`)
113 return { id, isNew: true }
114}
115
116// The person ticked an item off on the band: unpin it and, for a `do` item,
117// tell the model, which may be waiting on it (a merge, a test).
118export async function tick($: EngineInterface, t: Todo) {
119 await update($, todos, list => list.filter(x => x.id !== t.id))
120 if (t.kind !== 'do') return
121 await post(
122 $,
123 [
124 `<herdr-owner-done id="T${t.id}" from="${t.from.replace(/"/g, "'")}">`,
125 `The owner ticked off: ${t.text}`,
126 'It is already unpinned. Carry on from it (check it, tell the worker with message, record it in the state file).',
127 '</herdr-owner-done>',
128 ].join('\n'),
129 )
130}
131
132// The items pinned for the person, for the compaction summary to keep.
133export async function pinned($: EngineInterface) {
134 return (await read($, todos)).map(t => `- T${t.id} (${t.kind}, ${t.from}): ${t.text}`).join('\n')
135}
136
137// The main-loop turn reading worker reports, and the workers whose reports it reads:
138// their DECISION: questions ping the person once that turn ends, unless it settled them.
139let reading: { turnId: string; names: Set<string> } | undefined
140
141export function noteReports(turnId: string, text: string) {
142 const names = new Set([...text.matchAll(/<herdr-agent name="([^"]+)"/g)].map(m => m[1]!))
143 reading = names.size ? { turnId, names } : undefined
144}
145
146export async function pingAfterReports($: EngineInterface, turnId: string) {
147 if (reading?.turnId !== turnId) return
148 const { names } = reading
149 reading = undefined
150 await pingQuiet($, d => names.has(d.from))
151}
152
153export type AskArgs = { question: string; title?: string; options?: string[]; recommended?: number; from?: string }
154
155export async function askOwner($: EngineInterface, e: AskArgs) {
156 const id = await ask($, e.from ?? 'coordinator', e.question, e.options, e.recommended, e.title)
157 return { result: `Pinned as D${id} and notified the person. Call decided with "D${id}" once they answer.` }
158}
159
160export type DecidedArgs = { ids: string[]; answer: string; by: string }
161
162export async function settle($: EngineInterface, e: DecidedArgs) {
163 const ids = new Set((e.ids as string[]).map(s => Number(String(s).replace(/^D/i, ''))))
164 const by: Settled['by'] = e.by === 'owner' ? 'owner' : 'coordinator'
165 const answer = String(e.answer ?? '').trim() || 'closed'
166 const at = await $.clock.now()
167 let gone: Decision[] = []
168 let left = 0
169 await update($, decisions, list => {
170 gone = list.filter(d => ids.has(d.id))
171 const kept = list.filter(d => !ids.has(d.id))
172 left = kept.length
173 return kept
174 })
175 if (gone.length) {
176 await update($, settled, list => [
177 ...list.filter(x => at - x.at < SETTLED_MS),
178 ...gone.map(d => ({ id: d.id, from: d.from, question: d.question, ...(d.title ? { title: d.title } : {}), answer, by, at })),
179 ])
180 const names = gone.map(d => `D${d.id}${d.title ? ` ${d.title}` : ''}`).join(', ')
181 // A question the person was already pinged for gets a silent notification;
182 // a quiet one they never heard of only shows on the band.
183 if (by === 'coordinator' && gone.some(d => !d.quiet)) await notify($, `${names} settled by coordinator`, answer, false)
184 else $.ui.toast(`${names} settled by ${by}: ${answer}`)
185 }
186 const missing = [...ids].filter(id => !gone.some(d => d.id === id)).map(id => `D${id}`)
187 return {
188 result: `Unpinned ${gone.length}.${missing.length ? ` Not pinned (already answered or wrong id): ${missing.join(', ')}.` : ''} ${left} question(s) still wait for the person.`,
189 }
190}
191
192export type PinArgs = { text: string; kind?: string; from?: string }
193
194export async function pinForOwner($: EngineInterface, e: PinArgs) {
195 const text = String(e.text ?? '')
196 .replace(/\s+/g, ' ')
197 .trim()
198 if (!text) return { deny: 'text is empty' }
199 const from = String(e.from ?? '').trim() || 'coordinator'
200 const { id, isNew } = await pin($, text, e.kind === 'know' ? 'know' : 'do', from)
201 return { result: `${isNew ? 'Pinned' : 'Already pinned'} as T${id}. Unpin it with unpin_for_owner once it is done.` }
202}
203
204export async function unpinForOwner($: EngineInterface, e: { ids?: string[] }) {
205 const ids = new Set(((e.ids ?? []) as string[]).map(s => Number(String(s).replace(/^T/i, ''))))
206 let gone: Todo[] = []
207 let left = 0
208 await update($, todos, list => {
209 gone = list.filter(t => ids.has(t.id))
210 const kept = list.filter(t => !ids.has(t.id))
211 left = kept.length
212 return kept
213 })
214 const missing = [...ids].filter(id => !gone.some(t => t.id === id)).map(id => `T${id}`)
215 return {
216 result: `Unpinned ${gone.length}.${missing.length ? ` Not pinned (already done or wrong id): ${missing.join(', ')}.` : ''} ${left} item(s) still pinned for the person.`,
217 }
218}
219hooks/session.ts 10 lines1import { PREFIX } from './defaults'
2
3// What this load of the mod knows about its session. A reload starts the module over,
4// and session.start fills these in again: the folder, the command prefix from the
5// settings, and the band's id (from its last drawing) for moving its focus ring.
6export const session = { cwd: '', prefix: PREFIX, bandId: '' }
7
8// A command as the person types it, e.g. cmd('wrap-up') is /hrd-wrap-up.
9export const cmd = (name: string) => `/${session.prefix}${name}`
10hooks/settings.tsx 388 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3import type { SettingsView } from '../types'
4import type { BandRender } from './band'
5import { refresh } from './board'
6import { allProfiles, BUILT_IN, customized, OPTIONAL, readJson, userConfigPath } from './config'
7import { CLOSE_IDLE_MINUTES, PREFIX, PRUNE_MINUTES, RESET_AT, TARGET } from './defaults'
8import { tilde } from './format'
9import { session } from './session'
10import { sideOpen, toggleSide } from './side'
11
12// /hrd-config: the settings, listed on the band above the prompt as /config lists
13// its own. Enter flips a switch, steps a choice (›) or opens a field; each change
14// is written to the user's or the folder's herdr-agents.json at once.
15
16const settingsView = atom(
17 { plugin: 'herdr-agents', key: 'settings' } as const,
18 {
19 scope: 'user',
20 paths: { user: '', folder: '' },
21 home: '',
22 user: {},
23 folder: {},
24 profiles: [],
25 } as SettingsView,
26)
27
28export async function settingsOpen($: EngineInterface) {
29 return (await read($, settingsView)).isOpen === true
30}
31
32// The settings pane's values for a picker that writes nothing, or `null`.
33export const INHERIT = '*inherit'
34
35export const NONE = '*none'
36
37export const EDIT = 'cfg-edit'
38
39// One row of the settings pane. `text` rows open a field, `switch` and `choice` rows
40// change on Enter, `fixed` rows only show. `raw` is what this file holds; `value`
41// what is in effect, `isDefault` when no file sets it.
42export type SettingRow = {
43 key: string
44 label: string
45 kind: 'text' | 'switch' | 'choice' | 'fixed'
46 value: string
47 raw: string
48 isDefault: boolean
49 hint: string
50 detail?: string
51}
52
53// The settings typed into a field, with their label, default, unit and hint.
54type TextSetting = 'target' | 'resetAt' | 'statePath' | 'pruneMinutes' | 'closeIdleMinutes' | 'commandPrefix'
55
56export const TEXT_SETTINGS: [key: TextSetting, label: string, initial: unknown, unit: string, hint: string][] = [
57 ['target', 'Workers to keep busy', TARGET, '', 'How many workers the coordinator keeps running.'],
58 [
59 'resetAt',
60 'Compact the coordinator past',
61 RESET_AT,
62 ' tokens',
63 'Past this much context the coordinator saves its state and is compacted.',
64 ],
65 ['statePath', 'State file', 'COORDINATOR-STATE.md', '', "The coordinator's notes: relative to the folder, absolute, or ~/…."],
66 [
67 'pruneMinutes',
68 'Prune merged worktrees every',
69 PRUNE_MINUTES,
70 ' min',
71 'How often merged worktrees are removed; 0 turns it off. New sessions.',
72 ],
73 [
74 'closeIdleMinutes',
75 'Close idle worker tabs after',
76 CLOSE_IDLE_MINUTES,
77 ' min',
78 "A reported worker's tab closes once idle this long; 0 keeps it. New sessions.",
79 ],
80 ['commandPrefix', 'Command prefix', PREFIX, '', 'What every command starts with. New sessions.'],
81]
82
83export const NUMBERS: Record<string, number> = { target: 1, resetAt: 10_000, pruneMinutes: 0, closeIdleMinutes: 0 }
84
85export async function loadSettings($: EngineInterface, note?: string, error?: string) {
86 const paths = { user: await userConfigPath($), folder: `${session.cwd}/.claude/herdr-agents.json` }
87 const user = await readJson($, paths.user)
88 const folder = await readJson($, paths.folder)
89 const home = (await $.env.get('HOME')) ?? ''
90 const all = await allProfiles($)
91 const old = await read($, settingsView)
92 const scope = old.paths.user ? old.scope : (await $.fs.exists(paths.folder)) ? 'folder' : 'user'
93 await update($, settingsView, p => ({
94 ...p,
95 scope,
96 paths,
97 home,
98 user,
99 folder,
100 profiles: Object.entries(all).map(([name, p]) => ({
101 name,
102 note: p.note ?? p.kind,
103 builtIn: BUILT_IN.includes(name),
104 customized: customized(name, p),
105 })),
106 note,
107 error,
108 }))
109}
110
111export function settingRows(pane: SettingsView): SettingRow[] {
112 const mine = pane[pane.scope]
113 const merged = { ...pane.user, ...pane.folder }
114 // Where a value this file leaves out comes from.
115 const source = (key: TextSetting) => (pane.scope === 'folder' && pane.user[key] !== undefined ? ' (all folders)' : ' (default)')
116 const off: string[] = merged.disable ?? []
117 const small = mine.smallTasks !== undefined ? mine.smallTasks : merged.smallTasks
118 const builtIn = (name: string): SettingRow => {
119 const p = pane.profiles.find(x => x.name === name)
120 const detail = `${p?.note ?? ''}${p?.customized ? ' · customized' : ''}`
121 if (!OPTIONAL.includes(name))
122 return {
123 key: `cfg-off-${name}`,
124 label: `${name[0]!.toUpperCase()}${name.slice(1)}`,
125 kind: 'fixed',
126 value: 'always on',
127 raw: '',
128 isDefault: true,
129 hint: '',
130 }
131 return {
132 key: `cfg-off-${name}`,
133 label: `${name[0]!.toUpperCase()}${name.slice(1)}`,
134 kind: 'switch',
135 value: off.includes(name) ? 'off' : 'on',
136 raw: '',
137 isDefault: !off.includes(name),
138 detail,
139 hint: {
140 planner: 'Plans each issue first. Off: the builder plans before it builds.',
141 reviewer: 'Reviews each PR. Off: the coordinator reviews the diff and checks itself.',
142 debugger: 'Takes over after two failed fix rounds. Off: the coordinator asks you.',
143 }[name]!,
144 }
145 }
146 return [
147 {
148 key: 'cfg-scope',
149 label: 'Edit settings for',
150 kind: 'choice',
151 value: pane.scope === 'user' ? 'all folders' : 'this folder',
152 raw: '',
153 isDefault: false,
154 detail: tilde(pane.paths[pane.scope], pane.home),
155 hint: "Which file the changes go to. This folder's file overrides the one for all folders.",
156 },
157 ...TEXT_SETTINGS.map(
158 ([key, label, initial, unit, hint]): SettingRow => ({
159 key: `cfg-${key}`,
160 label,
161 kind: 'text',
162 value: mine[key] !== undefined ? `${mine[key]}${unit}` : `${merged[key] ?? initial}${unit}${source(key)}`,
163 raw: mine[key] === undefined ? '' : String(mine[key]),
164 isDefault: mine[key] === undefined,
165 hint,
166 }),
167 ),
168 {
169 key: 'cfg-wrapUp',
170 label: 'Wrap up',
171 kind: 'switch',
172 value: mine.wrapUp === true ? 'on' : 'off',
173 raw: '',
174 isDefault: mine.wrapUp !== true,
175 hint: 'On: no new workers start, the running ones finish. In the all-folders file it holds every coordinator.',
176 },
177 {
178 key: 'cfg-view',
179 label: 'Show workers in',
180 kind: 'choice',
181 value: (mine.view ?? merged.view) === 'side' ? 'the sidebar' : 'the band',
182 raw: '',
183 isDefault: mine.view === undefined,
184 hint: 'The band above the prompt or the sidebar beside the chat; only one shows. /hrd-side switches for this session.',
185 },
186 builtIn('planner'),
187 builtIn('builder'),
188 builtIn('reviewer'),
189 builtIn('debugger'),
190 {
191 key: 'cfg-smallTasks',
192 label: 'Small tasks go to',
193 kind: 'choice',
194 value: small ? small : 'nobody',
195 raw: '',
196 isDefault: mine.smallTasks === undefined,
197 hint: 'A profile the coordinator may give small, well-defined tasks.',
198 },
199 ]
200}
201
202export async function pressRow($: EngineInterface, key: string) {
203 const pane = await read($, settingsView)
204 const row = settingRows(pane).find(r => r.key === key)
205 if (!row) return
206 if (row.kind === 'text') {
207 await update($, settingsView, p => ({ ...p, editing: key, note: undefined, error: undefined }))
208 await $.ui.focus({ requestId: session.bandId, key: EDIT }).catch(() => {})
209 } else if (key === 'cfg-scope') {
210 await update($, settingsView, p => ({
211 ...p,
212 scope: p.scope === 'user' ? ('folder' as const) : ('user' as const),
213 note: undefined,
214 error: undefined,
215 }))
216 } else if (key === 'cfg-wrapUp') {
217 await saveSetting($, 'wrapUp', row.value === 'on' ? '' : 'true')
218 } else if (key === 'cfg-view') {
219 await saveView($, row.value === 'the sidebar' ? 'band' : 'side')
220 } else if (key === 'cfg-smallTasks') {
221 // Steps through nobody and each profile that is on.
222 const merged = { ...pane.user, ...pane.folder }
223 const off: string[] = merged.disable ?? []
224 const choices = [NONE, ...pane.profiles.filter(p => !off.includes(p.name)).map(p => p.name)]
225 const now = row.value === 'nobody' ? NONE : row.value
226 await saveSmallTasks($, choices[(choices.indexOf(now) + 1) % choices.length]!)
227 } else if (key.startsWith('cfg-off-')) {
228 await toggleOff($, key.slice('cfg-off-'.length))
229 }
230}
231
232export async function closeSettings($: EngineInterface) {
233 await update($, settingsView, p => ({ ...p, isOpen: false, focused: undefined, editing: undefined, note: undefined, error: undefined }))
234}
235
236export async function editDone($: EngineInterface, key: string, value: string) {
237 await update($, settingsView, p => ({ ...p, editing: undefined }))
238 await saveSetting($, key.slice('cfg-'.length), value)
239 await $.ui.focus({ requestId: session.bandId, key }).catch(() => {})
240}
241
242// Reads the file the pane edits, changes it with `fn` and writes it back. A file that is
243// not valid JSON is left alone, so a hand edit in progress is never overwritten.
244export async function editConfig($: EngineInterface, fn: (c: Record<string, unknown>) => string) {
245 const { scope, paths } = await read($, settingsView)
246 const path = paths[scope]
247 let c: Record<string, unknown> = {}
248 if (await $.fs.exists(path)) {
249 try {
250 c = JSON.parse(await $.fs.read(path))
251 } catch {
252 return loadSettings($, undefined, `${path} is not valid JSON; fix it by hand first.`)
253 }
254 }
255 const note = fn(c)
256 await $.fs.write(path, `${JSON.stringify(c, null, 2)}\n`)
257 await loadSettings($, `${note} in ${path}.`)
258 void refresh($)
259}
260
261export async function saveSetting($: EngineInterface, key: string, raw: string) {
262 const text = raw.trim()
263 let value: unknown = text
264 if (text && key in NUMBERS) {
265 value = Number(text)
266 if (!Number.isInteger(value) || (value as number) < NUMBERS[key]!)
267 return loadSettings($, undefined, `${key} must be a whole number of at least ${NUMBERS[key]}.`)
268 }
269 if (text && key === 'commandPrefix' && !/^[a-z0-9_-]{0,20}$/.test(text))
270 return loadSettings($, undefined, 'commandPrefix takes up to 20 lowercase letters, digits, _ and -.')
271 if (key === 'wrapUp') value = text === 'true'
272 await editConfig($, c => {
273 if (!text || value === false) {
274 delete c[key]
275 return `Removed ${key}`
276 }
277 c[key] = value
278 return `Saved ${key} = ${text}`
279 })
280}
281
282export async function saveSmallTasks($: EngineInterface, value: string) {
283 const { scope } = await read($, settingsView)
284 await editConfig($, c => {
285 // Nobody is the default for all folders; a folder writes null to override theirs.
286 if (value === INHERIT || (value === NONE && scope === 'user')) delete c.smallTasks
287 else c.smallTasks = value === NONE ? null : value
288 return value === INHERIT ? 'Removed smallTasks' : `Saved smallTasks = ${value === NONE ? 'nobody' : value}`
289 })
290}
291
292// Saves where the workers show and shows them there now. The band is the default for all
293// folders; a folder writes it to override theirs.
294export async function saveView($: EngineInterface, view: 'band' | 'side') {
295 const { scope } = await read($, settingsView)
296 await editConfig($, c => {
297 if (view === 'band' && scope === 'user') delete c.view
298 else c.view = view
299 return `Saved view = ${view}`
300 })
301 if ((view === 'side') !== (await sideOpen($))) await toggleSide($)
302}
303
304// Turns a built-in profile off or on in the file the pane edits. A folder file without
305// `disable` starts from the one for all folders, since its own replaces that one.
306export async function toggleOff($: EngineInterface, name: string) {
307 const pane = await read($, settingsView)
308 const base: string[] = pane[pane.scope].disable ?? (pane.scope === 'folder' ? pane.user.disable : undefined) ?? []
309 const isOff = base.includes(name)
310 const next = isOff ? base.filter(n => n !== name) : [...base, name]
311 await editConfig($, c => {
312 if (next.length || (pane.scope === 'folder' && pane.user.disable?.length)) c.disable = next
313 else delete c.disable
314 return `Turned ${name} ${isOff ? 'on' : 'off'}`
315 })
316}
317
318export async function openSettings($: EngineInterface) {
319 await loadSettings($)
320 await update($, settingsView, p => ({ ...p, isOpen: true, focused: undefined, editing: undefined }))
321}
322
323// The band's focus ring: while the settings show, ❯ marks the row it is on.
324export async function trackFocus($: EngineInterface, element: string | undefined) {
325 if ((await read($, settingsView)).isOpen)
326 await update($, settingsView, p => ({ ...p, focused: element, editing: element === EDIT ? p.editing : undefined }))
327}
328
329// The settings drawn on the band, or undefined while they are closed (or on the mobile
330// app, which draws no fields yet).
331export async function drawSettings($: EngineInterface, e: BandRender) {
332 const settings = await read($, settingsView)
333 if (settings.isOpen && !e.props.hasSurvey && e.surface !== 'mobile') {
334 const { Box, Button, Input, Text } = $.ui.resolve(e)
335 const list = settingRows(settings)
336 const focused = list.find(r => r.key === settings.focused)
337 const width = Math.max(...list.map(r => r.label.length)) + 4
338 const hint = focused
339 ? [focused.hint, focused.detail].filter(Boolean).join(' · ')
340 : tilde(settings.paths[settings.scope], settings.home)
341 return (
342 <Box flexDirection="column">
343 {list.map(row =>
344 row.key === settings.editing ? (
345 <Input
346 key={EDIT}
347 label={`❯ ${row.label.padEnd(width)}`}
348 value={row.raw}
349 placeholder={row.value}
350 submitLabel="save"
351 onSubmit={(value: string) => void editDone($, row.key, value)}
352 />
353 ) : row.kind === 'fixed' ? (
354 <Text dimColor>{` ${row.label.padEnd(width)}${row.value}`}</Text>
355 ) : (
356 <Button key={row.key} plain onPress={() => void pressRow($, row.key)}>
357 {`${settings.focused === row.key ? '❯' : ' '} ${row.label.padEnd(width)}`}
358 <Text dimColor={row.isDefault}>{row.value}</Text>
359 {row.kind === 'choice' ? <Text dimColor>{' ›'}</Text> : null}
360 </Button>
361 ),
362 )}
363 <Text> </Text>
364 {/* One line whatever the row's hint, so moving through the rows never
365 changes the band's height. */}
366 <Text dimColor wrap="truncate-end">{` ${hint}`}</Text>
367 {settings.error ? (
368 <Text color="error">{` ${settings.error}`}</Text>
369 ) : settings.note ? (
370 <Text color="success">{` ${settings.note}`}</Text>
371 ) : null}
372 {/* Done comes first: the keys after it change with the focus, and a click
373 focuses before it presses, so a Done after them would move away under it. */}
374 <Box columnGap={2} paddingLeft={2}>
375 <Button key="cfg-done" plain role="dismiss" onPress={() => void closeSettings($)}>
376 Done
377 </Button>
378 <Text dimColor wrap="truncate-end">
379 {settings.editing
380 ? 'Enter to save · empty for the default · ↑↓ to leave it'
381 : `${settings.focused ? '' : 'alt+d to enter · '}Enter to change · ↑↓ to move · Esc back to the prompt`}
382 </Text>
383 </Box>
384 </Box>
385 )
386 }
387}
388